Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/fx-core/src/common/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export class FeatureFlags {
};
static readonly MCPForDADT = {
name: FeatureFlagName.MCPForDADT,
defaultValue: "false",
defaultValue: "true",
};
static readonly MCPForDADCR = {
name: FeatureFlagName.MCPForDADCR,
Expand Down Expand Up @@ -165,7 +165,7 @@ export class FeatureFlagManager {
}
listEnabled(): string[] {
return this.list()
.filter((f) => isFeatureFlagEnabled(f.name))
.filter((f) => this.getBooleanValue(f))
Comment thread
HuihuiWu-Microsoft marked this conversation as resolved.
.map((f) => f.name);
}
}
Expand Down
192 changes: 119 additions & 73 deletions packages/fx-core/src/core/FxCore.declarativeAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ import { manifestUtils } from "../component/driver/teamsApp/utils/ManifestUtils"
import { pluginManifestUtils } from "../component/driver/teamsApp/utils/PluginManifestUtils";
import { normalizePath } from "../component/driver/teamsApp/utils/utils";
import * as declarativeAgentHelperModule from "../component/generator/declarativeAgent/helper";
import { deriveMCPServerNameFromUrl } from "../component/generator/declarativeAgent/helper";
import {
deriveMCPNamespaceFromUrl,
deriveMCPRegistrationIdFromUrl,
deriveMCPServerNameFromUrl,
} from "../component/generator/declarativeAgent/helper";
import { templateDefaultOnActionError } from "../component/generator/generator";
import { GeneratorContext } from "../component/generator/generatorAction";
import * as openApiSpecHelperModule from "../component/generator/openApiSpec/helper";
Expand All @@ -55,7 +59,6 @@ import { pathUtils } from "../component/utils/pathUtils";
import { UserCancelError, assembleError } from "../error/common";
import { ActionStartOptions, QuestionNames } from "../question/constants";
import { CreateNewPluginManifestSentinel } from "../question/scaffold/vsc/teamsProjectTypeNode";
import type { Answers, BuildTarget } from "../v4";
import { ConcurrentLockerMW } from "./middleware/concurrentLocker";
import { ErrorHandlerMW } from "./middleware/errorHandler";
import { modifyProjectFrontDoor } from "./modifyProjectFrontDoor";
Expand All @@ -77,19 +80,6 @@ function targetRelativePath(projectPath: string, filePath: string): string {
return path.relative(projectPath, absolute).replace(/\\/g, "/");
}

function stringAnswer(answers: Answers, key: string): string | undefined {
const value = answers[key];
return typeof value === "string" ? value : undefined;
}

function unsupportedModifyTarget(target: BuildTarget): SystemError {
return new SystemError({
source: "Scaffold",
name: "UnsupportedModifyEngine",
message: `The add MCP server flow does not handle the '${target.engine}' modify target '${target.templateId}'.`,
});
}

async function scaffoldAddMcpServerFromV4(
options: ScaffoldAddMcpServerFromV4Options
): Promise<Result<undefined, FxError>> {
Expand Down Expand Up @@ -428,6 +418,8 @@ export class FxCoreDeclarativeAgentPart {
const isGenerateFromApiSpec =
inputs[QuestionNames.ActionType] === ActionStartOptions.apiSpec().id;
const isGenerateFromMCP = inputs[QuestionNames.ActionType] === ActionStartOptions.mcp().id;
const useMCPDTModifyFlow =
isGenerateFromMCP && featureFlagManager.getBooleanValue(FeatureFlags.MCPForDADT);

// VS Code MCP "Add Action" flow: aligned with the "DA with MCP" scaffolding
// behavior. Only the MCP server URL is collected from the user. The toolkit
Expand All @@ -441,13 +433,9 @@ export class FxCoreDeclarativeAgentPart {
// OAuth registration, since the CLI has no equivalent notification UX.
//
// Under the Dynamic Tool Discovery (DT) flag this early redirect is skipped
// so that VS Code runs the same inline scaffolder as the CLI — the
// CodeLens "fetch action" hop is absorbed into add-action itself.
if (
isGenerateFromMCP &&
inputs.platform === Platform.VSCode &&
!featureFlagManager.getBooleanValue(FeatureFlags.MCPForDADT)
) {
// so that VS Code runs the same inline scaffolder as the CLI. DT owns the
// generated content shape; it must not depend on the global v4 feature flag.
if (isGenerateFromMCP && inputs.platform === Platform.VSCode && !useMCPDTModifyFlow) {
return await this.addPluginFromMCP(inputs, context);
}

Expand Down Expand Up @@ -543,10 +531,7 @@ export class FxCoreDeclarativeAgentPart {
// VS Code MCP inline flow (DT on) skips the confirmation modal to match
// the leaner `updateActionWithMCP` UX; writes happen as soon as the last
// required answer is provided. CLI interactive and other flows still show it.
const skipConfirmModal =
isGenerateFromMCP &&
inputs.platform === Platform.VSCode &&
featureFlagManager.getBooleanValue(FeatureFlags.MCPForDADT);
const skipConfirmModal = useMCPDTModifyFlow && inputs.platform === Platform.VSCode;

if (!skipConfirmModal) {
const confirmRes = await context.userInteraction.showMessage(
Expand Down Expand Up @@ -652,59 +637,120 @@ export class FxCoreDeclarativeAgentPart {
);
}

// Dynamic Tool Discovery flow: the v4 modify package renders the dynamic
// plugin manifest and reuses the same MCP auth steps as create.
if (featureFlagManager.getBooleanValue(FeatureFlags.MCPForDADT)) {
// Dynamic Tool Discovery flow: write a URL-only plugin manifest directly
// and reuse the shared MCP auth injector. DT owns the generated content
// and must NOT depend on the v4 modify front door. The runtime carries
// only the MCP server URL (dynamic discovery is the host default) — no
// static tools list, no `mcp_tool_description`, no
// `enable_dynamic_discovery`.
if (useMCPDTModifyFlow) {
const authType = inputs[QuestionNames.MCPForDAAuthType] as string | undefined;
const dispatchRes = await fxCoreDeclarativeAgentDeps.modifyProjectFrontDoor(
inputs,
{ addCapability: "add-action", actionSource: "mcp" },
{
mcpServerUrl,
teamsManifestPath: targetRelativePath(projectPath, teamsManifestPath),
authType: authType || "none",
},
{
scaffoldV4: async (
_inputs: Inputs,
target: BuildTarget,
answers: Answers,
resolvedPackage?: ResolvedV4ChannelPackage
) => {
if (target.engine !== "v4") {
return err(unsupportedModifyTarget(target));
}
const answerMcpServerUrl = stringAnswer(answers, "mcpServerUrl");
const answerAuthType = stringAnswer(answers, "authType") ?? "none";
if (answerMcpServerUrl === undefined) {
return err(
new SystemError({
source: "Scaffold",
name: "ModifyInputMissing",
message: "The add MCP server modify package did not resolve mcpServerUrl.",
})
);
// Name the plugin namespace + oauth/register action after the MCP server
// URL (shared verbatim with the create flow via deriveMCPNamespaceFromUrl
// / deriveMCPRegistrationIdFromUrl). This keeps one registration per
// server: re-adding the same server reuses the same `oauth/register`
// (idempotent on registrationId), while a different server gets its own.
const namespace = deriveMCPNamespaceFromUrl(mcpServerUrl);
const registrationId = deriveMCPRegistrationIdFromUrl(mcpServerUrl);

destinationPluginManifestPath =
await copilotGptManifestUtils.getDefaultNextAvailablePluginManifestPath(
appPackageFolder,
undefined
);

const pluginManifest: any = {
$schema: "https://developer.microsoft.com/json-schemas/copilot/plugin/v2.4/schema.json",
schema_version: "v2.4",
name_for_human: manifestRes.value.name?.short ?? path.basename(projectPath),
description_for_human: "Action powered by MCP server",
contact_email: "publisher-email@example.com",
namespace,
functions: [],
runtimes: [
{
type: "RemoteMCPServer",
spec: { url: mcpServerUrl },
run_for_functions: ["*"],
auth: deriveMCPManifestOAuth(authType, registrationId) ?? { type: "None" },
},
],
};

if (authType && authType !== "none") {
// The add question tree does not collect the auth-server metadata /
// well-known URL, so — mirroring the create flow — probe the MCP
// server to discover it when the caller didn't pass one. The result
// feeds resolveMCPAuthEndpoints so oauth/register + dcr/register get
// real authorization/token/well-known URLs. Only oauth / oauth-dynamic
// consume metadata; entra-sso resolves no endpoints, so skip the
// (10s-timeout) probe for it.
const needsMetadataProbe = authType === "oauth" || authType === "oauth-dynamic";
if (needsMetadataProbe && !inputs[QuestionNames.MCPForDAAuthMetadataUrl]) {
try {
const { probeMCPServerAuth } = await import("../component/utils/mcpToolFetcher");
const authProbe = await probeMCPServerAuth(mcpServerUrl);
if (authProbe.authMetadataUrl) {
inputs[QuestionNames.MCPForDAAuthMetadataUrl] = authProbe.authMetadataUrl;
}
return fxCoreDeclarativeAgentDeps.scaffoldAddMcpServerFromV4({
templateId: target.templateId,
projectPath,
platform: inputs.platform,
teamsManifestPath,
appName: manifestRes.value.name?.short ?? path.basename(projectPath),
mcpServerUrl: answerMcpServerUrl,
authType: answerAuthType,
resolvedPackage,
} catch {
// best-effort probe; endpoint resolution below tolerates undefined
}
}
// Endpoint resolution is best-effort and MUST NOT block the
// oauth/register injection: an unreachable server or missing metadata
// leaves endpoints empty and the injector still writes the action
// (with placeholder URLs) so the developer can fill them in later.
let endpoints: ResolvedMCPAuthEndpoints = {};
try {
endpoints = await resolveMCPAuthEndpoints(authType, inputs);
} catch (error: any) {
mcpWarnings.push({
type: "mcpAuthMetadataError",
content: getLocalizedString(
"core.MCPForDA.mcpAuthMetadataMissingError",
error.message
),
});
}
try {
const ymlPath = pathUtils.getYmlFilePath(inputs.projectPath);
if (ymlPath) {
await injectMCPAuthActionToYml({
ymlPath,
authType,
authName: namespace,
registrationId,
mcpServerUrl,
endpoints,
});
},
callCoreMethod: (_inputs: Inputs, target: BuildTarget) =>
Promise.resolve(err(unsupportedModifyTarget(target))),
resolveArtifactSnapshot: fxCoreDeclarativeAgentDeps.resolveV4TemplateArtifactSnapshot,
}
} catch (error: any) {
mcpWarnings.push({
type: "mcpAuthMetadataError",
content: getLocalizedString(
"core.MCPForDA.mcpAuthMetadataMissingError",
error.message
),
});
}
}

await fs.ensureFile(destinationPluginManifestPath);
await fs.writeJSON(destinationPluginManifestPath, pluginManifest, { spaces: 4 });

const addActionRes = await copilotGptManifestUtils.addAction(
declarativeCopilotManifestPath,
actionId,
normalizePath(path.relative(appPackageFolder, destinationPluginManifestPath), true)
);
if (dispatchRes.isErr()) {
return err(dispatchRes.error);
if (addActionRes.isErr()) {
return err(addActionRes.error);
}

for (const warning of mcpWarnings) {
context.logProvider.warning(warning.content);
}
return ok(undefined);
} else {
// Load tools from file if provided and not yet loaded
const existingTools = inputs[QuestionNames.MCPForDAAvailableTools];
Expand Down
8 changes: 7 additions & 1 deletion packages/fx-core/src/question/inputs/AddPluginInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ export interface AddPluginInputs extends Inputs {
/** @description MCP Tools Definition File */
"mcp-tools-file-path"?: string;
/** @description Select Authentication Type */
"mcp-da-auth-type"?: "oauth" | "entra-sso" | "none";
"mcp-da-auth-type"?: "oauth" | "oauth-dynamic" | "entra-sso" | "none";
/** @description OAuth Client ID */
"mcp-da-client-id"?: string;
/** @description OAuth Client Secret */
"mcp-da-client-secret"?: string;
/** @description OAuth Scopes (optional) */
"mcp-da-scopes"?: string;
/** @description Select Teams manifest.json File */
"manifest-path"?: string;
}
18 changes: 17 additions & 1 deletion packages/fx-core/src/question/options/AddPluginOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,23 @@ export const AddPluginOptions: CLICommandOption[] = [
type: "string",
description: "Select Authentication Type",
default: "oauth",
choices: ["oauth", "entra-sso", "none"],
choices: ["oauth", "oauth-dynamic", "entra-sso", "none"],
},
{
name: "mcp-da-client-id",
type: "string",
description: "OAuth Client ID",
},
{
name: "mcp-da-client-secret",
type: "string",
description: "OAuth Client Secret",
},
{
name: "mcp-da-scopes",
type: "string",
description: "OAuth Scopes (optional)",
required: false,
},
{
name: "manifest-file",
Expand Down
9 changes: 9 additions & 0 deletions packages/fx-core/tests/common/featureFlags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ describe("FeatureFlagManager", () => {
const stringRes = featureFlagManager.getStringValue(FeatureFlags.MCPForDADCR);
chai.assert.equal(stringRes, "true");
});
it("MCPForDADT defaults to true", async () => {
mockedEnvRestore = mockedEnv({ [FeatureFlags.MCPForDADT.name]: undefined });
const booleanRes = featureFlagManager.getBooleanValue(FeatureFlags.MCPForDADT);
chai.assert.isTrue(booleanRes);
const stringRes = featureFlagManager.getStringValue(FeatureFlags.MCPForDADT);
chai.assert.equal(stringRes, "true");
});
it("MCPForDADCR can be disabled by environment variable", async () => {
mockedEnvRestore = mockedEnv({ TEAMSFX_MCP_FOR_DA_DCR: "false" });
const booleanRes = featureFlagManager.getBooleanValue(FeatureFlags.MCPForDADCR);
Expand All @@ -61,5 +68,7 @@ describe("FeatureFlagManager", () => {
const list = featureFlagManager.listEnabled();
chai.assert.include(list, "TEAMSFX_CLI_DOTNET");
chai.assert.include(list, "SME_OAUTH");
chai.assert.include(list, FeatureFlags.MCPForDADT.name);
chai.assert.include(list, FeatureFlags.MCPForDADCR.name);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ import sampleConfigV3 from "../../common/samples-config-v3.json";
import { MockTools, randomAppName } from "../../core/utils";

const originalTemplateConfig = { ...templateConfig };
// eslint-disable-next-line @typescript-eslint/no-require-imports
const packageJson = require("../../../package.json");
const originalPackageVersion = (packageJson as any).version;

const mockedSampleInfo: SampleConfig = {
id: "test-id",
Expand Down Expand Up @@ -870,6 +873,7 @@ describe("render template", () => {
afterEach(async () => {
vi.restoreAllMocks();
Object.assign(templateConfig, originalTemplateConfig);
(packageJson as any).version = originalPackageVersion;
if (await fs.pathExists(tmpDir)) {
await fs.remove(tmpDir);
}
Expand Down Expand Up @@ -1121,6 +1125,10 @@ describe("render template", () => {
await buildFakeTemplateZip(templateName, mockFileName);

vi.spyOn(templateHelper, "useLocalTemplate").mockReturnValue(false);
// Force a stable package version so getTemplateUrl runs the local-vs-remote
// version comparison instead of the rc short-circuit (which returns a
// remote URL and triggers the fallback this test guards against).
(packageJson as any).version = "3.0.0";
(templateConfig as any).localVersion = "9.9.9";
(templateConfig as any).version = "~3.0.0";
const tagList = "1.0.0\n 2.0.0\n 2.1.0\n 3.0.0";
Expand Down
Loading
Loading