Skip to content

Commit 5d6cb8a

Browse files
feat: MCP in DA (#14613)
* feat(mcp): add support for mcp for da * perf: update ui * perf: update flow * perf: update code * perf: update code * perf: update * perf: update * perf: update * docs: update readme * perf: update feature flag * perf: update * perf: remove image * fix: ActionStartOptions not found * fix: some issues * fix: get well known metadata URL * feat: support da entra sso auth * feat: remove template * refactor: remove pnpm-lock change * fix: description of Entra SSO client ID question * fix: codelens issue * fix: sanitize mcp server name issue * test: add test - 1 * test: add test - 2 * test: add test - 3 * chore: remove unused strings * test: add test - 4 * test: add test - 5 * test: add test - 6 * test: add test - 7 * chore: remove unused variables --------- Co-authored-by: Bowen Song <[email protected]>
1 parent 9e17d4c commit 5d6cb8a

33 files changed

+3364
-19
lines changed

packages/api/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export enum Stage {
8686
addKnowledge = "addKnowledge",
8787
setSensitivityLabel = "setSensitivityLabel",
8888
installApp = "installApp",
89+
updateActionWithMCP = "updateActionWithMCP",
8990
}
9091

9192
export enum TelemetryEvent {

packages/fx-core/resource/package.nls.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@
439439
"core.createProjectQuestion.OauthClientId": "Enter client id for OAuth registration in OpenAPI Description Document",
440440
"core.createProjectQuestion.OauthClientSecret": "Enter client secret for OAuth registration in OpenAPI Description Document",
441441
"core.createProjectQuestion.OauthClientSecretConfirm": "Microsoft 365 Agents Toolkit uploads the client id/secret for OAuth Registration to Developer Portal. It is used by Teams client to securely access your API at runtime. Microsoft 365 Agents Toolkit doesn't store your client id/secret.",
442+
"core.createProjectQuestion.EntraSSOClientId": "Enter Microsoft Entra SSO client id for registration in OpenAPI Description Document",
442443
"core.createProjectQuestion.apiMessageExtensionAuth.title": "Authentication Type",
443444
"core.createProjectQuestion.apiMessageExtensionAuth.placeholder": "Select an authentication type",
444445
"core.createProjectQuestion.invalidApiKey.message": "Invalid client secret. It should be 10 to 512 characters long.",
@@ -1137,5 +1138,19 @@
11371138
"driver.typeSpec.compile.reprovision": "File %s is updated. A new provision process has started to apply the changes.",
11381139
"error.kiota.KiotaGeneratePluginError": "Unable to generate plugin manifest file using Kiota. Error: %s",
11391140
"error.daSpecParser.InvalidSpecError": "OpenAPI specification file is not valid: %s",
1140-
"error.kiotaClient.EmptyResult": "Get empty result when parser OpenAPI description file."
1141+
"error.kiotaClient.EmptyResult": "Get empty result when parser OpenAPI description file.",
1142+
"core.createProjectQuestion.mcpForDa.label": "Start with an MCP server",
1143+
"core.createProjectQuestion.mcpForDa.detail": "Create actions from your existing MCP server",
1144+
"core.createProjectQuestion.mcpForDa.ServerUrl.title": "MCP Server URL",
1145+
"core.createProjectQuestion.mcpForDa.ServerUrl.placeholder": "Enter your MCP server URL(e.g. https://example-mcp.com)",
1146+
"core.createProjectQuestion.mcpForDa.PreFetchTools.title": "Select Operation(s) Copilot can interact with",
1147+
"core.createProjectQuestion.mcpForDa.AuthType.title": "Select Authentication Type",
1148+
"core.createProjectQuestion.mcpForDa.Auth.OAuth": "OAuth (with static registration)",
1149+
"core.createProjectQuestion.mcpForDa.Auth.EntraSSO": "Entra SSO",
1150+
"core.createProjectQuestion.mcpForDa.File.title": "Select the action manifest you want to update",
1151+
"core.MCPForDA.updatePluginManifest": "The operations selected from your MCP server are successfully added for Copilot to interact with. You can go to the '%s' to check on details. Now you are able to provision your declarative agent to continue.",
1152+
"core.MCPForDA.mcpAuthMetadataMissingError": "Unable to find the authentication metadata in the MCP server. Please make sure your MCP server is configured correctly and try again. Details: %s",
1153+
"core.MCPForDA.mcpAuthMetadataUrlNotFound": "Unable to find the authentication metadata from property \"resource_metadata\" from response of the MCP server.",
1154+
"core.MCPForDA.mcpServerMetadataUrlNotFound": "Unable to find the server metadata from property \"authorization_servers\" from repsonse of the Protected Resource Metadata of the MCP server.",
1155+
"core.MCPForDA.authUrlNotFound": "Unable to find the authentication URL(s) from response of Authorization Server Metadata of the MCP server."
11411156
}

packages/fx-core/src/common/featureFlags.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class FeatureFlagName {
3838
static readonly SensitivityLabelEnabled = "TEAMSFX_SENSITIVITY_LABEL";
3939
static readonly DAMetaOS = "TEAMSFX_DA_METAOS";
4040
static readonly CFShortcutMetaOS = "TEAMSFX_CF_SHORTCUT_METAOS";
41+
static readonly MCPForDA = "TEAMSFX_MCP_FOR_DA";
4142
}
4243

4344
export interface FeatureFlag {
@@ -133,6 +134,10 @@ export class FeatureFlags {
133134
name: FeatureFlagName.CFShortcutMetaOS,
134135
defaultValue: "false",
135136
};
137+
static readonly MCPForDA = {
138+
name: FeatureFlagName.MCPForDA,
139+
defaultValue: "true",
140+
};
136141
}
137142

138143
export class FeatureFlagManager {

packages/fx-core/src/component/configManager/actionInjector.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,80 @@ export class ActionInjector {
242242
return undefined;
243243
}
244244

245+
static async injectCreateOAuthActionForMCP(
246+
ymlPath: string,
247+
authType: string,
248+
authName: string,
249+
registrationId: string,
250+
mcpServerUrl: string,
251+
authorizationUrl?: string,
252+
tokenUrl?: string,
253+
refreshUrl?: string
254+
): Promise<AuthActionInjectResult | undefined> {
255+
const ymlContent = await fs.readFile(ymlPath, "utf-8");
256+
257+
const document = parseDocument(ymlContent);
258+
const provisionNode = document.get("provision") as any;
259+
if (provisionNode) {
260+
const hasAuthActionWithSameReferenceId = provisionNode.items.some(
261+
(item: any) =>
262+
(item.get("uses") as string) === "oauth/register" &&
263+
!!item.get("with") &&
264+
!!item.get("writeToEnvironmentFile") &&
265+
(item.get("writeToEnvironmentFile").get("configurationId") as string) === registrationId
266+
);
267+
if (hasAuthActionWithSameReferenceId) {
268+
return undefined;
269+
}
270+
271+
provisionNode.items = provisionNode.items.filter((item: any) => {
272+
const uses = item.get("uses");
273+
return uses;
274+
});
275+
276+
const teamsAppIdEnvName = ActionInjector.getTeamsAppIdEnvName(provisionNode);
277+
if (teamsAppIdEnvName) {
278+
const index: number = provisionNode.items.findIndex(
279+
(item: any) => item.get("uses") === "teamsApp/create"
280+
);
281+
282+
const action: any = {
283+
uses: "oauth/register",
284+
with: {
285+
name: `${authName}`,
286+
appId: `\${{${teamsAppIdEnvName}}}`,
287+
flow: "authorizationCode",
288+
...(authType === "oauth"
289+
? {
290+
authorizationUrl: authorizationUrl,
291+
tokenUrl: tokenUrl,
292+
refreshUrl: refreshUrl ?? undefined,
293+
identityProvider: "Custom",
294+
}
295+
: {
296+
identityProvider: MicrosoftEntraAuthType,
297+
}),
298+
baseUrl: mcpServerUrl,
299+
},
300+
writeToEnvironmentFile: {
301+
configurationId: registrationId,
302+
},
303+
};
304+
provisionNode.items.splice(index + 1, 0, action);
305+
} else {
306+
throw new InjectOAuthActionFailedError();
307+
}
308+
309+
await fs.writeFile(ymlPath, document.toString(), "utf8");
310+
return {
311+
defaultRegistrationIdEnvName: registrationId,
312+
registrationIdEnvName: registrationId,
313+
};
314+
} else {
315+
throw new InjectOAuthActionFailedError();
316+
}
317+
}
318+
245319
static findNextAvailableEnvName(baseEnvName: string, existingEnvNames: string[]): string {
246320
let suffix = 1;
247321
let envName = baseEnvName;

packages/fx-core/src/component/generator/declarativeAgent/generator.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import {
99
AppPackageFolderName,
1010
Context,
11+
DefaultPluginManifestFileName,
1112
err,
1213
FxError,
1314
GeneratorResult,
@@ -65,6 +66,7 @@ export class DeclarativeAgentGenerator extends DefaultTemplateGenerator {
6566
TemplateNames.DeclarativeAgentWithActionFromScratchOAuth,
6667
TemplateNames.DeclarativeAgentWithExistingAction,
6768
TemplateNames.DeclarativeAgentWithTypeSpec,
69+
TemplateNames.DeclarativeAgentWithActionFromMCP,
6870
].includes(inputs[QuestionNames.TemplateName]);
6971
}
7072

@@ -82,6 +84,7 @@ export class DeclarativeAgentGenerator extends DefaultTemplateGenerator {
8284
const solutionNameFromVS =
8385
language === "csharp" ? inputs[QuestionNames.SolutionName] : undefined;
8486

87+
const MCPForDAServerUrl = inputs[QuestionNames.MCPForDAServerUrl];
8588
const replaceMap = {
8689
...Generator.getDefaultVariables(
8790
inputs[QuestionNames.TemplateName] === TemplateNames.DeclarativeAgentWithTypeSpec
@@ -94,6 +97,14 @@ export class DeclarativeAgentGenerator extends DefaultTemplateGenerator {
9497
),
9598
DeclarativeCopilot: "true",
9699
MicrosoftEntra: auth === ApiAuthOptions.microsoftEntra().id ? "true" : "",
100+
...(MCPForDAServerUrl
101+
? {
102+
MCPForDAServerUrl,
103+
ServerName: new URL(MCPForDAServerUrl).host
104+
.replace(/[^a-zA-Z0-9]/g, "")
105+
.substring(0, 10),
106+
}
107+
: {}),
97108
};
98109
const templateName = inputs[QuestionNames.TemplateName];
99110

@@ -143,6 +154,14 @@ export class DeclarativeAgentGenerator extends DefaultTemplateGenerator {
143154
await setGeneralSensitivityLabel(context, declarativeCopilotManifestPathRes.value);
144155
}
145156

157+
// if (
158+
// featureFlagManager.getBooleanValue(FeatureFlags.MCPForDA) &&
159+
// TemplateNames.DeclarativeAgentWithActionFromMCP === inputs[QuestionNames.TemplateName]
160+
// ) {
161+
// const result = await generateForMCPForDA(destinationPath, inputs);
162+
// return result;
163+
// }
164+
146165
if (
147166
featureFlagManager.getBooleanValue(FeatureFlags.EmbeddedKnowledgeEnabled) &&
148167
(inputs.platform === Platform.CLI || inputs.platform === Platform.VSCode)

packages/fx-core/src/component/generator/declarativeAgent/helper.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33
import {
4+
AppPackageFolderName,
45
Context,
56
DefaultApiSpecFolderName,
7+
DefaultPluginManifestFileName,
68
err,
79
FxError,
10+
GeneratorResult,
11+
Inputs,
812
ok,
913
PluginManifestSchema,
1014
Result,
@@ -31,6 +35,7 @@ import {
3135
ItemMetadata,
3236
} from "./oneDriveSharePointHandler";
3337
import { createContext } from "../../../common/globalVars";
38+
import { QuestionNames } from "../../../question/questionNames";
3439

3540
const logMessageKeys = {
3641
failValidateOneDriveSharePointItem:
@@ -348,3 +353,106 @@ export async function getGraphConnectors(): Promise<GCItem[]> {
348353
}
349354
}
350355
}
356+
357+
export async function generateForMCPForDA(
358+
destinationPath: string,
359+
inputs: Inputs
360+
): Promise<Result<GeneratorResult, FxError>> {
361+
// 1. Get ai-plugin.json
362+
const aiPluginFilePath = path.join(
363+
destinationPath,
364+
AppPackageFolderName,
365+
DefaultPluginManifestFileName
366+
);
367+
if (!(await fs.pathExists(aiPluginFilePath))) {
368+
const error = new SystemError(
369+
"MCPForDAPluginManifestNotFound",
370+
"PluginManifestNotFound",
371+
getDefaultString("core.MCPForDA.pluginManifestNotFound", aiPluginFilePath),
372+
getLocalizedString("core.MCPForDA.pluginManifestNotFound", aiPluginFilePath)
373+
);
374+
return err(error);
375+
}
376+
377+
const mcpServerUrl = inputs[QuestionNames.MCPForDAServerUrl];
378+
const serverName = inputs[QuestionNames.MCPForDAServerName];
379+
const mcpTool = inputs[QuestionNames.MCPForDATool];
380+
381+
// 2. Read ai-plugin.json
382+
const aiPluginContent = await fs.readJSON(aiPluginFilePath);
383+
384+
// For dynamic fetch tools, keep the functions empty and add runtime info
385+
if (mcpTool === "dynamic-fetch") {
386+
aiPluginContent.functions = [];
387+
aiPluginContent.runtimes = [
388+
{
389+
type: "RemoteMCPServer",
390+
spec: {
391+
url: mcpServerUrl,
392+
enable_dynamic_discovery: true,
393+
},
394+
},
395+
];
396+
} else {
397+
// For pre-fetch tools, add the tool info to ai-plugin.json
398+
const mcpToolsDetail = inputs[QuestionNames.MCPForDAAvailableTools];
399+
const mcpToolsSelected = inputs[QuestionNames.MCPForDAPreFetchTools];
400+
const mcpAuth = inputs[QuestionNames.MCPForDAAuth];
401+
if (!mcpToolsDetail || !mcpToolsSelected) {
402+
const error = new UserError(
403+
"MCPForDAPreFetchToolsNotFound",
404+
"PreFetchToolsNotFound",
405+
getDefaultString("core.MCPForDA.preFetchToolsNotFound"),
406+
getLocalizedString("core.MCPForDA.preFetchToolsNotFound")
407+
);
408+
return err(error);
409+
}
410+
aiPluginContent.functions = mcpToolsDetail
411+
.filter((tool: any) => tool.name.includes(serverName))
412+
.map((tool: any) => {
413+
const index = tool.name.indexOf(serverName);
414+
const newName = (tool.name as string).substring(
415+
(index as number) + (serverName.length as number) + 1
416+
);
417+
return {
418+
name: newName,
419+
description: tool.description,
420+
inputSchema: tool.inputSchema,
421+
tags: tool.tags,
422+
};
423+
})
424+
.filter((tool: any) => mcpToolsSelected.includes(tool.name))
425+
.map((tool: any) => {
426+
return {
427+
name: tool.name,
428+
description: tool.description,
429+
parameters: {
430+
type: tool.inputSchema.type || "object",
431+
properties: tool.inputSchema.properties,
432+
required: tool.inputSchema.required || [],
433+
},
434+
};
435+
});
436+
aiPluginContent.runtimes = [
437+
{
438+
type: "RemoteMCPServer",
439+
spec: {
440+
url: mcpServerUrl,
441+
enable_dynamic_discovery: false,
442+
},
443+
run_for_functions: aiPluginContent.functions.map((func: any) => func.name),
444+
},
445+
];
446+
if (mcpAuth === "OAuthPluginVault") {
447+
aiPluginContent.runtimes[0].auth = {
448+
type: "OAuthPluginVault",
449+
reference_id: "${{MCP_DA_AUTH_ID}}",
450+
};
451+
}
452+
}
453+
454+
// 3. Write ai-plugin.json
455+
await fs.writeJSON(aiPluginFilePath, aiPluginContent, { spaces: 4 });
456+
457+
return ok([] as GeneratorResult); // Return empty warnings
458+
}

packages/fx-core/src/component/generator/templates/metadata/da.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,10 @@ export const declarativeAgentTemplates: Template[] = [
107107
language: "common",
108108
description: "",
109109
},
110+
{
111+
id: "declarative-agent-with-action-from-mcp",
112+
name: TemplateNames.DeclarativeAgentWithActionFromMCP,
113+
language: "common",
114+
description: "",
115+
},
110116
];

packages/fx-core/src/component/generator/templates/templateNames.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export enum TemplateNames {
1313
DeclarativeAgentWithExistingAction = "api-plugin-existing-api", // handled by DeclarativeAgentGenerator
1414
DeclarativeAgentWithTypeSpec = "declarative-agent-typespec", // handled by DeclarativeAgentGenerator
1515
DeclarativeAgentWithGraphConnector = "declarative-agent-with-graph-connector", // handled by DeclarativeAgentGenerator
16+
DeclarativeAgentWithActionFromMCP = "declarative-agent-with-action-from-mcp", // handled by DeclarativeAgentGenerator
1617

1718
DeclarativeAgentMetaOSNewProject = "declarative-agent-meta-os-new-project", // handled by OfficeAddinGeneratorNew
1819
DeclarativeAgentMetaOSUpgradeProject = "declarative-agent-meta-os-upgrade-project", // handled by OfficeAddinGeneratorNew

packages/fx-core/src/component/generator/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ export function renderTemplateFileData(
171171
const writer = new Writer();
172172
const result = writer.renderTokens(token, new Context(variables));
173173
// Be compatible with current stable templates, can be removed after new template released.
174+
// Disable HTML escaping to prevent URLs and other content from being encoded
175+
Mustache.escape = (value) => value;
174176
return Mustache.render(result, variables, {}, oldPlaceholderDelimiters);
175177
}
176178
// Return Buffer instead of string if the file is not a template. Because `toString()` may break binary resources, like png files.
@@ -227,6 +229,8 @@ export function renderTemplateFileName(
227229
fileData: Buffer,
228230
variables?: { [key: string]: string }
229231
): string {
232+
// Disable HTML escaping to prevent special characters from being encoded
233+
Mustache.escape = (value) => value;
230234
return Mustache.render(fileName, variables, {}, placeholderDelimiters).replace(
231235
templateFileExt,
232236
""

0 commit comments

Comments
 (0)