Skip to content

Serve the sample authorization server over loopback HTTP - #1802

Open
anneheartrecord wants to merge 2 commits into
modelcontextprotocol:mainfrom
anneheartrecord:fix/sample-oauth-server-loopback-http
Open

Serve the sample authorization server over loopback HTTP#1802
anneheartrecord wants to merge 2 commits into
modelcontextprotocol:mainfrom
anneheartrecord:fix/sample-oauth-server-loopback-http

Conversation

@anneheartrecord

Copy link
Copy Markdown

Fixes #689.

Root cause

The sample never gets as far as the authorization server. Reproduced against current main, with the failure isolated to one request:

  1. POST http://localhost:7071/401, WWW-Authenticate: Bearer resource_metadata="http://localhost:7071/.well-known/oauth-protected-resource/"
  2. GET that document → {"resource":"http://localhost:7071","authorization_servers":["https://localhost:7029"], ...}
  3. GET https://localhost:7029/.well-known/oauth-authorization-serverfails

Step 3 fails because TestOAuthServer hosts the authorization server on the ASP.NET Core developer certificate, and VS Code's HTTP stack carries its own CA list instead of using the operating system trust store. Node reports it as TypeError: fetch failed, cause DEPTH_ZERO_SELF_SIGNED_CERT — which is exactly the Error populating auth metadata: TypeError: fetch failed line in the trace on microsoft/vscode#261120, where this was chased from the VS Code side and left as "something to do with your cert on localhost".

From there VS Code treats it as a server without metadata and falls back to getDefaultMetadataForUrl(mcpServerUrl), the compatibility path for the 2025-03-26 spec. That fallback has no registration endpoint, so it prompts for a client id (which reads as "DCR not supported"), and its authorization endpoint is http://localhost:7071/authorize — the 404 in the original report.

So the answer to "sample, TestOAuthServer, or SDK auth handling" is TestOAuthServer, and neither of the two symptoms is the bug — both are downstream of one unreachable metadata document. The .NET client sample works because HttpClient does use the OS trust store, where dotnet dev-certs https --trust put the certificate. The repo's own OAuth tests work because OAuthTestBase turns certificate validation off.

What changed

TestOAuthServer now listens over plain HTTP on loopback when it's run standalone, so its metadata is reachable without trusting anything first. --https (and the https launch profile) keeps the developer certificate available. The constructor still defaults to HTTPS, so the tests that construct Program directly are untouched.

ProtectedMcpServer points at http://localhost:7029 and sets RequireHttpsMetadata = false, scoped with a comment and a README note saying not to do that against anything but a loopback authority you control.

Verifying

I don't have VS Code on this machine, so I drove the flow it performs with Node's fetch — same HTTP stack, same certificate behaviour. Before: step 3 fails with DEPTH_ZERO_SELF_SIGNED_CERT and http://localhost:7071/authorize returns 404. After: metadata → dynamic client registration → authorization code → token → an authenticated initialize returning 200. The token exchange also proves the sample's JwtBearer backchannel fetches its signing keys over the HTTP authority.

dotnet build is clean and tests/ModelContextProtocol.AspNetCore.Tests passes (582 tests, net10.0). The new test covers the two things this change can regress: the scheme the discovery document advertises has to match the origin it's hosted on, and the standalone default has to stay HTTP.

One thing to call out

RFC 8414 wants an https issuer identifier, and in the default mode this fixture now publishes "issuer": "http://localhost:7029". That felt like the right trade for a loopback test server whose whole job is to be easy to point a client at — but it does change what plain dotnet run does, so if you'd rather keep HTTPS as the default and make it --http to opt out, say the word and I'll flip it. The samples would then need the README to tell people to pass the flag, which is the part I expect to keep tripping people up.

The ProtectedMcpServer sample pairs with TestOAuthServer, which hosts the
authorization server on the ASP.NET Core developer certificate. Clients that
keep their own CA list rather than using the OS trust store cannot fetch
https://localhost:7029/.well-known/oauth-authorization-server from it. VS Code
is one: the fetch fails, it treats that as a server without metadata, and falls
back to the pre-2025-06-18 defaults derived from the MCP server URL. That drops
the registration endpoint, so it asks for a client id, and then sends the
browser to http://localhost:7071/authorize, which 404s.

Host the standalone server over plain HTTP on loopback so its metadata is
reachable without trusting anything first, and keep the developer certificate
available behind --https. Tests construct Program directly and are unaffected.
@jeffhandley

Copy link
Copy Markdown
Contributor

Thank you for diving into this, @anneheartrecord! I want to make sure we can verify this against the VS Code setup but the PR looks really promising. It will take us a little time to get it validated and post reviews, but I wanted to know we'll get to it in about a week.

@anneheartrecord

Copy link
Copy Markdown
Author

Hi @jeffhandley — circling back on the ~week you mentioned on Aug 13. Completely understand if it is still in the queue. I am following up mainly to try to make the VS Code validation cheaper, since that was the part you flagged as the gate.

The check does not actually need VS Code. What matters is that VS Code's HTTP stack is Node/undici, which carries its own CA list rather than the OS trust store — so dotnet dev-certs https --trust satisfies the .NET sample but not VS Code. Any Node 18+ fetch reproduces it exactly:

  • Against current main, GET https://localhost:7029/.well-known/oauth-authorization-server fails with TypeError: fetch failed, cause DEPTH_ZERO_SELF_SIGNED_CERT. That one unreachable document is what produces both reported symptoms downstream — the /authorize 404 and the "DCR not supported" client-id prompt are just VS Code falling back to getDefaultMetadataForUrl.
  • With this PR, I ran the full flow VS Code performs — 401 → protected-resource metadata → AS metadata → dynamic client registration → authorization code → token → authenticated initialize — and all seven steps pass, the last returning 200. The token exchange also confirms the sample's JwtBearer backchannel fetches JWKS over the HTTP authority.

If it would help, I am glad to drop that probe script into the PR or a gist so you can run the whole thing in about a minute without standing anything up by hand.

One decision that could unblock review even before validation: raising it again so it does not get lost from the PR description. In the new default mode the fixture publishes "issuer": "http://localhost:7029", where RFC 8414 would rather see https. I took that as the right trade for a loopback test server whose job is to be easy to point a client at, but if you would prefer HTTPS to stay the default with --http to opt in, say the word and I will flip it. Better to settle that before review than after.

The branch is still conflict-free against main — happy to rebase, or to split anything out if that makes it easier to review.

@luisangelrod luisangelrod left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed diagnosis. I independently reproduced the new default path on Windows with Node 22:

  • authorization-server metadata: 200
  • dynamic client registration: 200
  • PKCE authorization redirect: 302
  • token exchange: 200
  • authenticated MCP initialize against ProtectedMcpServer: 200 with server info

I also ran the new argument and HTTP discovery cases on net8.0, net9.0, and net10.0; all six focused runs passed.

There is one standards issue I think needs a maintainer decision before HTTP becomes the default. The current MCP authorization security requirements say that all authorization-server endpoints MUST be served over HTTPS: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#communication-security. RFC 8414 also requires an HTTPS issuer and an HTTPS jwks_uri: https://www.rfc-editor.org/rfc/rfc8414.html#section-2. The localhost exception is stated for redirect URIs, not for the authorization server itself.

Because the README presents this as the normal editor-integration setup, the default would demonstrate a configuration that works but does not conform to those requirements. I recommend the alternative already proposed in the PR description: retain HTTPS as the default and expose plain HTTP as an explicit compatibility option such as --http, with the existing warning. If maintainers intentionally want a test-only exception here, documenting that exception and its non-conformance explicitly would make the tradeoff clear.

Environment note: this machine has no ASP.NET Core development certificate, so the HTTPS theory case could not complete locally; an existing HTTPS OAuth test fails identically. The PR''s CI is green, and the HTTP behavior above was fully exercised.

Disclosure: Drafted with AI assistance. I reviewed the diff, verified the cited requirements, and ran the tests and end-to-end flow reported above.

The MCP authorization security requirements and RFC 8414 both require
authorization server endpoints to be served over HTTPS, and the localhost
carve-out covers redirect URIs rather than the authorization server itself.
Defaulting the fixture to plain HTTP therefore demonstrated a configuration
that works but does not conform, in a sample the README presents as the normal
editor-integration setup.

Invert the switch: TestOAuthServer hosts over HTTPS unless --http is passed,
and the launch profiles follow. ProtectedMcpServer reads its authority from
OAuth:ServerUrl with an HTTPS default, and derives RequireHttpsMetadata from
that scheme, so the relaxation only applies when the sample has deliberately
been pointed at a loopback HTTP authority. The READMEs document the --http
pair of commands for clients that cannot fetch metadata from the ASP.NET Core
developer certificate.
@anneheartrecord

Copy link
Copy Markdown
Author

@luisangelrod thank you — that is a more thorough verification than I could do here, and the spec citation settles the question rather than leaving it to taste.

You are right, and I have pushed the inversion in 5848871. I had offered it in the PR description as the alternative; your reading of the requirements is what makes it the correct default rather than a preference:

  • TestOAuthServer now hosts over HTTPS unless --http is passed. The switch is inverted rather than removed, so the workaround still exists for clients that cannot fetch metadata from the developer certificate — it is just no longer what an unqualified dotnet run demonstrates. Launch profiles follow, with https first.
  • ProtectedMcpServer reads its authority from OAuth:ServerUrl, defaulting to https://localhost:7029, and derives RequireHttpsMetadata from that scheme instead of hardcoding false. So the relaxation now applies only when someone has deliberately pointed the sample at a loopback HTTP authority, and never in the default configuration.
  • READMEs give the two-terminal --http pair for VS Code and anything else with its own CA list, alongside the note about why the default HTTPS path fails for those clients.

That last point is the part I am least sure about and would still value a maintainer view on: the default configuration now conforms, but it also no longer works end to end in VS Code without reading the README and passing two extra flags. The original report was someone hitting exactly that wall. Conformance is clearly the right default — I am just not certain the README is a strong enough signpost, and if @jeffhandley would rather the sample detect and warn, or ship a second launch profile pair, I am happy to build that instead.

Verification after the change: TestOAuthServerHostingTests is 3/3 on net10.0, and dotnet format --verify-no-changes is clean on ModelContextProtocol.TestOAuthServer and ProtectedMcpServer. The ModelContextProtocol.AspNetCore.Tests project reports 17 whitespace errors, but they are identical before and after this branch (in HttpHeaderConformanceTests.cs, MapMcpTests.Mrtr.cs, OAuthTestBase.cs and three others) and none are in the file this PR touches, so I left them alone rather than widening the diff.

On the environment note in your review — no dev cert on that machine, so the HTTPS case could not complete: that is now the default path, so it may be worth one more pass with dotnet dev-certs https --trust if you have the appetite. No obligation; CI covers it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ProtectedMcpServer sample doesn't work with VSCode auth flow

3 participants