fix(fx-core): make MCP OAuth metadata discovery robust and failures visible - #16458
Conversation
…isible Scaffolding a declarative agent against an MCP server could silently produce an m365agents.yml that cannot provision, with no signal to the developer. Three defects, one invariant restored: ATK never silently produces an unprovisionable auth configuration. 1. Well-known discovery tried a single URL. RFC 8414 defines the insertion form (/.well-known/oauth-authorization-server/<path>) but many providers only answer the append form, or only OpenID Connect discovery. Entra (login.microsoftonline.com/common/v2.0) answers exactly one of the four combinations, so discovery 404'd and both endpoints were dropped. resolveMCPOAuthMetadata now probes the four candidates in order and accepts the first response carrying both authorization_endpoint and token_endpoint. An explicitly configured wellKnownUrl is still used verbatim. 2. MCP warnings never reached the user. Declarative-agent projects route through generateScaffoldingSummary, an allowlist that only formats spec-parser and manifest-length warnings, so every MCP warning was dropped. MCP warning types are camelCase with an mcp prefix while spec-parser types are kebab-case, so a prefix predicate lets all six through without leaking the spec-parser warnings the API-plugin flows deliberately suppress. 3. oauth/register was emitted with the authorizationUrl and tokenUrl keys simply absent when discovery failed, and provision later died on an opaque error. It now writes the fill-in placeholders and reports it, mirroring the existing dcr/register behaviour. Only static oauth is substituted; entra-sso resolves its endpoints from Entra. Wired through both the v3 and v4 injectors and all six call sites. Fixes #16451
✅ VscUse Test Plan — All tests passedWhy these tests: All changed files are in packages/fx-core MCP OAuth scaffolding paths; the fix targets MCP OAuth metadata discovery for declarative agents, so DA_MCP_Oauth_Remote is primary, with DA_MCP_None_Remote and DA_MCP_Entra_SSO_Remote as related MCP auth sanity checks, and Feature_DA_Add_MCP_Server to cover the end-to-end DA+MCP scaffold flow. Branch diff: Results: ✅ 4 passed · ❌ 0 failed (of 4 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
E2E Test Selection — AI SelectedWhy these tests: Changes in packages/fx-core (MCP auth scaffolding, declarative agent helpers, question nodes) trigger teamsApp/, teamsAgent/, and all declarativeAgent/* tests (especially MCP variants); changes in packages/vscode-extension (autoOpenHelper, telemetry) additionally bring in all feature/* and teamsApp/* tests per rules 1 and 3. Cases selected (27):
Need to run more tests?Comment on this PR:
Then re-run the workflow. |
…der path Both cases scaffold static `oauth` while metadata discovery fails, which now also reports that the injected oauth/register carries fill-in placeholders. Two warnings is the intended outcome and matches the oauth-dynamic cases that already asserted the discovery error plus the dcr placeholder. Assert the content of the second warning rather than only bumping the count, so the assertion keeps distinguishing the placeholder warning from a duplicate of the first.
❌ VscUse Test Plan — Tests unknownWhy these tests: All changed files are in packages/fx-core relating to MCP OAuth metadata discovery and declarative agent scaffolding; selected the three DA_MCP_* remote plans to cover the OAuth/Entra/none auth paths, plus the two Feature plans that exercise adding an MCP server and configuring OAuth auth from the VS Code UI. Branch diff: Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
`probeMCPServerAuth` collapsed a successful `initialize`, a WAF rejection and
a DNS failure into the same `{ requiresAuth: false }`, so nothing downstream
could tell "this URL works" from "we could not find out". Replace the boolean
`endpointNotFound` with a three-state verdict:
- `confirmed` — 401, or 2xx carrying a JSON-RPC envelope
- `notEndpoint` — 403, 404, 405, or 2xx without one
- `undetermined`— 5xx, other statuses, timeouts, transport failures
A 2xx status alone is not proof: substrate-sdf.office.com answers the same
POST with 200 and HTML. The envelope in the body is, and searching the body
text rather than parsing frames makes the check work for the
`text/event-stream` framing streamable-HTTP servers reply with.
Success also means "no authorization required" is a real answer, not an
absence of one: learn.microsoft.com/api/mcp serves its three tools
unauthenticated.
Consume the verdict in two tiers, per ADR-0020. The URL question still blocks
on 404 alone — the one answer no valid endpoint was measured giving. The
weaker shapes only warn after scaffolding, because both measured 403s came
from a WAF intercepting ahead of the application, and a WAF fronting a valid
endpoint could reject the probe the same way. Blocking a legitimate URL is
worse than missing a wrong one.
Widening the negative signal beyond 404 raises detection from 6 of 12
measured wrong URLs to 10, while still misclassifying none of the 9 valid
endpoints.
The negative statuses are enumerated rather than generalized to "any 4xx":
429 and 408 are transient and would slander a URL that is in fact correct.
Correct two claims in the fact page that the Microsoft Learn measurements
falsify: 405 does occur, including from a valid endpoint answering GET, and
the 404 rule's recall is 6 of 12, not 6 of 9.
❌ VscUse Test Plan — Tests failureWhy these tests: PR fixes MCP OAuth metadata discovery in declarative agent scaffolding (mcpAuthScaffolder, mcpAuthAction, declarativeAgent helper) and also touches vscode-extension autoOpenHelper/telemetry, so the most relevant plans cover DA MCP OAuth/Entra remote flows and the DA MCP server/OAuth auth Feature plans. Branch diff: Results: ✅ 4 passed · ❌ 1 failed (of 5 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
The patch-coverage gate flagged six new lines that no existing test reached: the non-envelope 200 body and the non-fallback metadata failure in the probe, the oauth placeholder warning on both the DT and the static-tools scaffold paths, and the notification raised at the end of the scaffolding summary. The existing ShowScaffoldingWarningSummary tests all end in the catch block, so the new one keeps the log spy quiet to let the function run to completion.
❌ VscUse Test Plan — Tests failureWhy these tests: PR fixes MCP OAuth metadata discovery in declarativeAgent helper and mcpAuthScaffolder, directly exercising MCP OAuth/auth scaffolding flows and the DA Add MCP Server feature path. Branch diff: Results: ✅ 4 passed · ❌ 1 failed (of 5 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
The strings this PR introduced were long and partly redundant, so this reworks them: - The input-time rejection drops the "nothing is listening for MCP at this address" clause; the 404 already says that. - The advisory warning now reports the HTTP status the server actually answered instead of claiming the server "did not answer", and its nls key and warning type are renamed mcpServerUrlNotFound -> mcpServerUrlNotAnEndpoint to match what it really detects. - The OAuth placeholder warning takes the two placeholder tokens as format parameters instead of hard-coding them, so the message and the constants written into m365agents.yml cannot drift apart. - The VS Code notification no longer repeats what its own buttons say. - Fixes a "repsonse" typo. The DCR placeholder warning is deliberately left alone: it predates this PR and is already translated into 14 locales, so adding a format parameter would make util.format append the raw token to every translated string until the loc pipeline re-runs.
❌ VscUse Test Plan — Tests unknownWhy these tests: PR fixes MCP OAuth metadata discovery in declarative agent scaffolding (mcpAuthScaffolder, mcpAuthAction, declarativeAgent/helper) — the most directly relevant plans cover DA MCP OAuth remote flow, DA OAuth action import, DA OAuth auth configuration feature, and DA MCP server addition as a sanity check. Branch diff: Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
❌ VscUse Test Plan — Tests unknownWhy these tests: PR fixes MCP OAuth metadata discovery in declarative agent scaffolding (fx-core mcpAuthScaffolder, mcpAuthAction, declarativeAgent helper) and touches vscode-extension autoOpenHelper/telemetry, so MCP-specific DA plans and DA OAuth/auth configuration feature plans are most relevant. Branch diff: Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
❌ VscUse Test Plan — Tests failureWhy these tests: PR fixes MCP OAuth metadata discovery bugs in declarative agent scaffolding (mcpAuthScaffolder, mcpAuthAction, helper files), directly exercised by DA MCP OAuth/None/Entra remote plans and the Feature DA MCP/OAuth auth configuration plans. Branch diff: Results: ✅ 4 passed · ❌ 1 failed (of 5 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
The "add MCP server" flow was a structural copy of the create subtree that never mirrored its validation, so a url the create flow rejects outright was accepted without a word when added to an existing project. Extract the 404 rejection rule into validateMCPServerUrlIsEndpoint and call it from both question nodes, so both flows reject exactly the same urls under ADR-0020's single blocking status. Raise the mcpServerUrlNotAnEndpoint warning on the DT addPlugin branch too, where the create flow raises it. That branch already probes the server for auth metadata but discarded endpointStatus; since it never fetches tools, a wrong url otherwise left no trace at all. The non-DT branch is unchanged on purpose: it fetches tools and already warns via mcpNoToolsFetched, matching create.
❌ VscUse Test Plan — Tests failureWhy these tests: PR fixes MCP OAuth metadata discovery bugs in declarative agent scaffolding (mcpAuthScaffolder, mcpAuthAction, declarativeAgent/helper), directly exercising DA MCP OAuth flows and DA OAuth auth configuration features. Branch diff: Results: ✅ 2 passed · ❌ 3 failed (of 5 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
A value like `taskmaster-mcp-server.azurewebsites.net` never reached the network: axios cannot build a request from a scheme-less url, so the probe threw with no status, was classified `undetermined`, and the url was accepted. The same value with `https://` in front was correctly rejected, which made the behaviour look arbitrary. Rule out anything that is not an absolute http(s) url syntactically, before probing. This is a shape check, not a network heuristic, so it does not touch ADR-0020's rule about which server answers may block. http stays allowed: a locally hosted MCP server is a normal thing to point at.
…ers in the yml When endpoint discovery fails, addPlugin writes `oauth/register` with placeholder URLs and pushes a warning that only ever reaches `logProvider.warning` — the output channel. The function then shows a success notification, so the developer is told the action was added and finds out about the placeholders when provision fails. Create raises the same case to a notification (showMCPAuthPlaceholderNotification, driven by GlobalKey.CreateWarnings), a path add-action does not go through. Raise it here too, with an action that opens the file holding the placeholders. VS Code only: the CLI already prints every warning through the log provider.
❌ VscUse Test Plan — Tests failureWhy these tests: PR fixes MCP OAuth metadata discovery for declarative agents — DA_MCP_Oauth_Remote directly covers the OAuth registration fix; DA_MCP_None_Remote and DA_MCP_Entra_SSO_Remote provide regression coverage for the other MCP auth paths; Feature_DA_Add_MCP_Server covers the VS Code scaffolding flow touched by mcpAuthScaffolder/autoOpenHelper changes. Branch diff: Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
`inputs.projectPath` is `string | undefined`, and the narrowing that holds at the call site is lost inside the `.then` closure, so `tsc` rejected the call even though the guard was right there. Resolve the path before the callback, where the narrowing still applies. Build-only fix; the tests never caught it because vitest does not type-check.
❌ VscUse Test Plan — Tests failureWhy these tests: PR fixes MCP OAuth metadata discovery in declarative agent scaffolding (mcpAuthScaffolder.ts, mcpAuthAction.ts, declarativeAgent/helper.ts), so MCP-specific DA plans and OAuth/auth configuration Feature plans are the most relevant. Branch diff: Results: ✅ 2 passed · ❌ 3 failed (of 5 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
…nings The warning said discovery failed but not why, so the developer's only lead was the server URL. A wrong URL is one cause; picking an authentication type the server does not implement is the other, and it produces the same dead end. Both warnings now say so. The strings are shared by create, add-action and the v4 scaffolder, so all three flows change together.
❌ VscUse Test Plan — Tests failureWhy these tests: PR fixes MCP OAuth metadata discovery bugs in declarative agent scaffolding (mcpAuthScaffolder, mcpAuthAction, declarativeAgent/helper) and VS Code autoOpenHelper; DA_MCP_Oauth_Remote directly covers the OAuth flow being fixed, DA_MCP_Entra_SSO_Remote and DA_MCP_None_Remote provide related MCP auth coverage, and Feature_DA_Add_MCP_Server covers the VS Code extension MCP server addition flow. Branch diff: Results: ✅ 3 passed · ❌ 1 failed (of 4 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
…e names it Google's Gmail, Calendar and Drive MCP servers authorize the individual tool calls rather than initialize, so an unauthenticated initialize is answered 200 with no WWW-Authenticate header and discovery had no resource_metadata URL to follow. It fell straight through to treating the server as its own authorization server, and every well-known form on the server host 404s, so the yml was written with oauth placeholders. Each of those servers does publish an RFC 9728 document, reachable only at the section 3.1 location derived from the resource URL. Probe that before assuming the server is its own authorization server.
❌ VscUse Test Plan — Tests failureWhy these tests: The PR fixes MCP OAuth metadata discovery in declarative agent scaffolding (mcpAuthScaffolder, mcpAuthAction, declarativeAgent/helper) — DA_MCP_Oauth_Remote is the primary target; DA_MCP_None_Remote and DA_MCP_Entra_SSO_Remote cover adjacent MCP auth flows; Feature_DA_Add_MCP_Server covers the MCP server addition UI path; DA_Add_Action_Import_Existing_API_Basic_OAuth covers the OAuth action import flow also touched by the diff. Branch diff: Results: ✅ 3 passed · ❌ 2 failed (of 5 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
…ting a url A 2024-11-05 server has no streamable-HTTP endpoint to POST to, so it answers the `initialize` POST with a 4xx -- the transport spec names 405 and 404 explicitly, and prescribes a GET that opens an event stream as the disambiguation. Treating that 4xx as proof of a wrong url therefore condemns a whole generation of valid servers, and 404 is the shape that blocks at input time. The probe now performs that GET before forming a `notEndpoint` verdict, reading the stream only as far as the `endpoint` event and giving up on a byte cap and a timeout so a talkative stream cannot hang the question walk. Only the `notEndpoint` path pays the extra round-trip. Also records why 400 stays out of the negative-status list: the spec requires a server to answer 400 when it rejects the MCP-Protocol-Version, so a valid endpoint can produce one -- as Google's servers do for any unroutable path.
✅ VscUse Test Plan — All tests passedWhy these tests: The PR fixes MCP OAuth metadata discovery in declarative agent scaffolding (fx-core mcpAuthScaffolder, mcpAuthAction, declarativeAgent helper) and touches vscode-extension autoOpenHelper, making DA_MCP_Oauth_Remote the primary target, DA_MCP_Entra_SSO_Remote relevant as a related auth-discovery path, and Feature_DA_Add_MCP_Server relevant for the VS Code extension flow changes. Branch diff: Results: ✅ 3 passed · ❌ 0 failed (of 3 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
The two warnings describe the same failure - the MCP server is reachable but does not advertise its OAuth endpoints - yet they were worded differently, so the create-DA and add-MCP-server flows read as two unrelated problems. Reword the OAuth message to match the DCR one verbatim except for the sentence naming the placeholders, which stay different because the two actions write different yml fields. The server URL and the placeholder tokens are dropped from the OAuth message, so its three format arguments are removed at every call site.
…g notification The scaffolding notification phrased the missing OAuth endpoints its own way, so the same failure read differently depending on whether it was raised during create, add MCP server, or scaffolding. Show the warning content the core already produced and drop the duplicate string.
❌ VscUse Test Plan — Tests failureWhy these tests: PR fixes MCP OAuth metadata discovery in declarative agent scaffolding (mcpAuthScaffolder, mcpAuthAction, declarativeAgent helper) and touches vscode-extension autoOpenHelper; selected MCP-specific DA plans plus OAuth auth configuration feature plan to cover all affected code paths. Branch diff: Results: ✅ 3 passed · ❌ 2 failed (of 5 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
❌ VscUse Test Plan — Tests failureWhy these tests: PR fixes MCP OAuth metadata discovery for declarative agents; selected DA_MCP_* remote plans to cover the OAuth/Entra/None auth flows and Feature plans for MCP server addition and OAuth auth configuration in the VS Code extension. Branch diff: Results: ✅ 4 passed · ❌ 1 failed (of 5 plans) Plans run:
ℹ️ How were these tests selected?GitHub Copilot (Claude Sonnet 4.6, high reasoning) analysed the PR title, description, and the diff between |
Fixes #16451
Problem
Scaffolding a declarative agent against an MCP server could silently produce an
m365agents.ymlthat cannot provision, with no signal to the developer. Two customer reports trace back to the same shape of failure.Report A (#16451) —
dcr/registerwas written withwellKnownAuthorizationServer:. The warning existed but never reached the UI; provision later failed with the opaqueDcrWellKnownInvalidError.Report B —
oauth/registerwas written with theauthorizationUrlandtokenUrlkeys simply absent:Root causes
Six independent defects, each verified by live probing against the actual servers involved.
1. Discovery implemented only one of the two MCP auth generations
Two generations are in the wild:
resource_metadata="…"→ protected-resource doc →authorization_servers[0]→ well-known on the issuerresource_metadatarealm/errorATK implemented only the first, so against a 2025-03-26 server the chain dead-ended at step one and endpoints came back empty. This is the defect behind the reported repro.
2. Well-known discovery only ever tried one URL form
RFC 8414 defines the insertion form, but many providers only answer the append form, and some only answer OpenID Connect discovery. For the issuer in report B:
login.microsoftonline.com/.well-known/oauth-authorization-server/common/v2.0— the only one ATK triedlogin.microsoftonline.com/common/v2.0/.well-known/oauth-authorization-serverlogin.microsoftonline.com/.well-known/openid-configuration/common/v2.0login.microsoftonline.com/common/v2.0/.well-known/openid-configurationThe 404 threw, the catch left
endpoints = {}, and the injector wroteundefinedfor both URLs — producing exactly the YAML above.3. MCP warnings were silently dropped
Declarative-agent projects carry the
copilotGptcapability, soShowScaffoldingWarningSummaryroutes togenerateScaffoldingSummary— an allowlist that only formats six spec-parserWarningTypes plus manifest-length warnings. All MCP warning types fell through it, including the placeholder warning that report A depended on.4.
oauth/registerhad no placeholder pathdcr/registeralready degraded gracefully to a fill-in placeholder. The staticoauthpath did not — it emittedundefined, and the YAML serializer dropped the keys entirely, leaving nothing for the developer to notice or edit.5. Discovery started only from a
401challengeA server hands out the location of its protected-resource document inside
WWW-Authenticate. A server that authorizes the individual tool calls rather thaninitializenever sends one — Google's Gmail, Calendar and Drive servers answer an unauthenticatedinitializewith200and a complete JSON-RPC result:With no
resource_metadatato follow, discovery fell straight through to defect 1's fallback and treated the server as its own authorization server. Every one of those candidates 404s, so the YAML got placeholders:oauth-authorization-server/openid-configurationforms ondrivemcp.googleapis.comdrivemcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1— never triedauthorization_servers: ["https://accounts.google.com/"]The document was there the whole time. RFC 9728 §3.1 derives its location from the resource URL by inserting the well-known segment between host and path, so a client never needs to be told.
6. A
4xxwas read as proof of a wrong URLThe URL check blocks on
404, justified by "no valid endpoint in the sample ever returned one". Checking that claim against the transport spec rather than against the sample turns up a valid server that must:A 2024-11-05 HTTP+SSE server has no streamable-HTTP endpoint to POST to, so a
4xxis its normal answer to the probe — and404is the shape that blocks at input time. The measurements could not have caught this: every/ssepath probed is either a streamable-HTTP endpoint or authorization-gated ahead of transport dispatch, so the case is missing from the sample rather than missing from the world.Changes
Both auth generations are now probed. When no protected-resource document is advertised,
resolveMCPOAuthMetadatafalls back to well-known candidates derived from the MCP server URL itself, including its origin root. The two candidate ladders are deliberately different: candidates derived from an issuer never include origin-root forms, because for a tenant-scoped issuer the root form can return a valid-but-wrong-tenant document, which is worse than failing.Multi-candidate discovery — the four forms (insertion/append ×
oauth-authorization-server/openid-configuration) are probed in order, accepting the first response carrying bothauthorization_endpointandtoken_endpoint, so a 200 from a partial document does not shadow a later complete one. An explicitly configuredwellKnownUrlis still used verbatim and never substituted. On total failure the error lists every URL attempted, which turns an unactionable message into a diagnosable one.The protected-resource document is derived, not waited for. Between the advertised
resource_metadatachain and the "server is its own authorization server" fallback, discovery now probes the RFC 9728 locations derived from the MCP server URL — §3.1 insertion first, origin root second. It runs only when nothing was advertised, so servers that do challenge are unaffected. Insertion goes first because it is the form that generalizes: of the five servers measured, all five serve it andapi.githubcopilot.com404s the origin-root form.Warning pass-through —
generateScaffoldingSummarynow admits MCP warnings. The predicate is a prefix check rather than a hardcoded list: MCP warning types are camelCase with anmcpprefix (mcpAuthRequired,mcpNoToolsFetched,mcpAuthOAuthUrlPlaceholder, …) while spec-parser types are kebab-case (operationid-missing,generate-card-failed, …). Verified against the actualWarningTypeenum — no collision. This covers every MCP type at once, cannot drift as new ones are added, and does not leak the spec-parser warnings that the API-plugin and message-extension flows deliberately suppress.OAuth URL placeholders — the injector now writes the angle-bracketed tokens
PLEASE_FILL_IN_AUTHORIZATION_URL/PLEASE_FILL_IN_TOKEN_URLand reports it, mirroring the existingdcr/registerbehaviour. Substitution applies only to staticoauth;entra-ssoresolves its endpoints from Entra at provision time and must not be given placeholders. Implemented in both the v3 injector and the v4 YAML mutator, and wired into all six call sites.A notification the developer cannot miss — the placeholder case now raises a VS Code notification with Open m365agents.yml and Create New App actions, rather than only appending to the output channel.
The misleading next-step invitation is suppressed — a project holding fill-in placeholders cannot provision, debug, or preview, so offering those as the next step sends the developer into a guaranteed failure.
showLocalDebugMessagenow takes a suppression flag; the stale-invitation global state is still consumed so it cannot resurface on the next window open.A wrong MCP server URL is reported. Falling back to the MCP server's own origin made one case worse: a mistyped URL resolves that host's authorization server, which is correct for the host but useless for the agent. The paths that fetch tools detect this independently (a wrong URL yields zero tools), but the dynamic-tool-discovery path performs no tool fetch by design and had no backstop. The probe now uses an
initializePOST and classifies the URL into three states instead of reporting only whether authorization is required:confirmednotEndpointundeterminedA 2xx status alone is not proof —
substrate-sdf.office.com/answers the same POST with 200 and HTML — so the JSON-RPC envelope in the body is what confirms the endpoint. The body text is searched rather than the frames parsed, which makes the check work for thetext/event-streamframing streamable-HTTP servers reply with. This also makes "no authorization required" a real answer rather than the absence of one:learn.microsoft.com/api/mcpserves its three tools unauthenticated, and previously that outcome was indistinguishable from a DNS failure.A
4xxno longer settles that verdict on its own. Before reportingnotEndpoint, the probe performs theGETthe spec prescribes and looks for theendpointevent a legacy HTTP+SSE server opens with. Only the rejection path pays the extra round-trip — a confirmed or undetermined URL never does. The stream is read only as far as that event and abandoned on an 8 KB cap and a 5 s timeout, so a talkative or hanging stream cannot stall the question walk.400is deliberately still not a negative signal, for a reason the same spec supplies: a server must answer400when it rejects theMCP-Protocol-Version, so a valid endpoint can produce one — as Google's servers do for any unroutable path.Create and "add MCP server" now share one rule. The add-action question tree was a structural copy of the create subtree that never mirrored its validation, so a URL the create flow rejects outright was accepted without a word when added to an existing project. Both nodes now call the same
validateMCPServerUrl, and the dynamic-tool-discovery branch ofaddPluginraises the samenotEndpointwarning as create. The non-DT branch is unchanged on purpose: it fetches tools and already warns viamcpNoToolsFetched, exactly as create does.A URL with no scheme is rejected on shape, before the probe.
taskmaster-mcp-server.azurewebsites.netwas accepted whilehttps://taskmaster-mcp-server.azurewebsites.netwas blocked, which made the rule look arbitrary. axios cannot build a request from a scheme-less value, so the probe threw before any round-trip, reported no status, and the URL fell through asundetermined. The check is syntactic — an absolutehttp/httpsURL or nothing — so it does not touch which server answers may block.httpstays allowed: a locally hosted MCP server is a normal thing to point at.Adding an action surfaces the placeholder case too.
addPluginwroteoauth/registerwith placeholders and pushed a warning that only ever reached the output channel — then showed a success notification, so the developer was told the action was added and discovered the placeholders when provision failed. The create flow raises this case to a notification through a path add-action does not go through; add-action now raises it itself, with an action that opens the file holding the placeholders. VS Code only — the CLI already prints every warning through the log provider.What the developer sees
The two checks are independent, and each has its own signal.
A URL that is not an MCP endpoint is rejected while it is being typed, in create and in "add MCP server" alike:
A URL can be a valid MCP server and still not publish the OAuth details we need — several public MCP servers behave this way. Scaffolding then succeeds with placeholders in
m365agents.yml, and the developer is told before provision rather than after:Evidence
Measured against nine reachable production MCP endpoints and fourteen truncated forms. Full table in the new fact page.
learn.microsoft.com/api/mcp(valid, no auth)text/event-stream, JSON-RPC resultmcp.canva.com/,taskmaster-mcp-server.azurewebsites.net/(truncated)api.githubcopilot.com/,mcp.monday.com/,mcp.notion.com/,api.analytics.lseg.com/lfa/(truncated)learn.microsoft.com/api/(truncated)learn.microsoft.com/api,learn.microsoft.com/(truncated)substrate-sdf.office.com/(truncated)api.moodys.com/genai-ready-data/m1/(truncated)Five findings shaped the implementation:
GETis not a usable probe. Canva's origin root answersGETwith 200 HTML while answeringPOSTwith 404, so a GET-based check clears a wrong URL. Conversely Learn's valid endpoint answersGETwith 405. Hence thePOSTprobe.GETwith 405. Both measured 403s came from Akamai intercepting ahead of the application, and a WAF fronting a valid endpoint could reject the probe the same way. A 2xx with no envelope is also that legacy transport's normalPOSTack, whose JSON-RPC result arrives on the separate SSE stream. Wrongly blocking a legitimate URL is worse than missing a wrong one.5xxmeans unreachable, not wrong, and URL shape carries no signal: HubSpot's endpoint is the origin root, and Monday's is/sse. No shape heuristic is admissible.The one miss worth knowing about is Moody's: its gateway mounts authorization on the path prefix, so the truncated URL returns a well-formed 401 carrying a
resource_metadatadocument, and discovery succeeds with plausible-but-wrong endpoints. No status-code rule can catch that.The shipped rule was then re-run end-to-end against all twenty URLs, calling the validator itself over the real network rather than reasoning from the table: 8/8 valid endpoints accepted (zero false positives), 6 of the truncated forms blocked, 4 accepted with a warning (405 / 403 / 403 / 200-without-envelope), and the scheme-less form blocked by the shape check. Moody's truncated URL is the one silent miss, exactly as predicted.
That 8/8 was a weaker guarantee than it looked, and closing the gap is defect 6. Every valid endpoint in the sample is auth-gated and answers 401 before transport dispatch, so none of them ever exercised a method restriction — a follow-up probe of the
/ssepaths on Monday, Notion, GitHub and HubSpot returned 401 to bothPOSTandGET. An unauthenticated legacy SSE server, the realistichttp://localhostcase, is not represented at all, and zero observed false positives out of eight bounds the true rate only at ~31% with 95% confidence. Rather than widen the sample, the spec-prescribedGETnow rules that class out by construction.Discovery was re-verified the same way —
resolveMCPOAuthMetadatacalled over the real network with no advertised metadata URL, as the scaffold flow calls it:gmailmcp.googleapis.com/mcp/v1accounts.google.com/o/oauth2/v2/auth+oauth2.googleapis.com/tokencalendarmcp.googleapis.com/mcp/v1drivemcp.googleapis.com/mcp/v1mcp.notion.com/mcpmcp.notion.com/authorize+/token(unchanged)api.githubcopilot.com/mcpgithub.com/login/oauth/authorize+/access_token(now resolves without a challenge too)learn.microsoft.com/api/mcpFinally the whole rule was swept over every server named in this PR — 32 URLs, calling
probeMCPServerAuthandresolveMCPOAuthMetadataover the real network:confirmed, 1undetermined(substrate-sdf, ring still 503) — zero false positiveslearn.microsoft.com/api/mcpnotEndpoint; 4 missed — Moody's path-prefix 401,substrate-sdf's 503, and two Google forms answering 400Two further observations from that sweep are recorded on the fact page:
resource, so a wrong-but-plausible Google URL still resolves to real endpoints — the same class of miss as Moody's, and RFC 9728 §3.3'sresourcecheck cannot catch it.api.githubcopilot.com/mcp/sseis not an endpoint but answers 401 from a gate ahead of routing, advertising aresource_metadatadocument that itself answers 401 — no candidate resolves, so the wrong URL surfaces one stage after the probe rather than by it.Net effect is one invariant: ATK never silently produces an unprovisionable auth configuration. Either discovery succeeds, or the YAML contains a conspicuous placeholder and the developer is told about it.
Docs
docs/02-architecture/external-dependencies/mcp-remote-servers.md+ code map — external-dependency fact page recording the measured wire behavior, with a reproduction recipe so the table can be re-verified when servers change.ADR-0020(Accepted) — where the URL-validity check belongs and whether it should block. Six options captured; the decision is the two-tier rule above, with the reasoning for rejecting both pure positive validation and a uniform block recorded. Its zero-false-positive premise is now qualified by the spec-mandated HTTP+SSE case and the construction that handles it.Tests
Full suites green, eslint clean.
fx-core— 291 files, 5282 passed, 0 failedvscode-extension— 93 files, 1090 passed, 0 failedNew coverage:
wellKnownUrlissues exactly one requestresource_metadatais advertised, and when the advertised document is unreachableoauthwith no endpoints, and with only one of the two, both write placeholders and set the flagentra-ssois not given placeholdersnotEndpointverdict stands when the fallback GET is not an event stream, names no endpoint, errors out, or runs past the byte caphttp(s)URL, without contacting the server, and still accepthttp://localhostaddPluginon the DT path warns when the probe reports the URL is not an endpointaddPluginnotifies in VS Code when the yml is left with OAuth placeholders, and its action opens that fileScope
Deliberately not in this PR:
confirmedendpoint to the user as positive feedback at input time — the probe now distinguishes the state, nothing renders it