MCP for outboundReference

The Two-Minute Check That Found a Dead jwks_uri in Our MCP Server

A two-minute check for remote MCP OAuth discovery: confirm every advertised URL resolves. Ours did not — the jwks_uri defect and the fix.

What we ranLive probe of our own MCP server's OAuth discovery chain on 2026-07-21, which found and fixed a dead jwks_uri advertised in both discovery documents.

A remote MCP server survives three round trips before a client holding nothing but its URL can connect: a 401 naming where to look, a resource-metadata document naming the authorization server, an authorization-server document naming the endpoints. The check is simple — does every URL those documents hand back actually resolve? Run it against our own server, and one didn't.

The chain a remote MCP client has to walk

Three specs chain together, and a client with only your URL walks them in order: call the endpoint and get a 401 naming the resource metadata, fetch the protected resource metadata (RFC 9728) to learn the authorization server, fetch the authorization server metadata (RFC 8414) to learn the endpoints and where to register.

Here's the first hop, captured against https://app.heykairon.com/mcp:

curl -s -D - -o /dev/null -X POST \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0"}}}' \
  https://app.heykairon.com/mcp
HTTP/2 401
content-type: application/json; charset=utf-8
www-authenticate: Bearer resource_metadata="https://app.heykairon.com/.well-known/oauth-protected-resource"

An invalid bearer token produces the identical challenge — checked with Authorization: Bearer not-a-real-token. GET and DELETE on /mcp both return 405.

Fetching the resource_metadata URL returns the protected resource document (RFC 9728):

{
  "resource": "https://app.heykairon.com/mcp",
  "authorization_servers": ["https://app.heykairon.com"],
  "jwks_uri": "https://app.heykairon.com/api/auth/mcp/jwks",
  "scopes_supported": ["openid", "profile", "email", "offline_access"],
  "bearer_methods_supported": ["header"],
  "resource_signing_alg_values_supported": ["RS256"]
}

That names the authorization server, whose metadata (RFC 8414) lists the actual endpoints:

{
  "issuer": "https://app.heykairon.com",
  "authorization_endpoint": ".../api/auth/mcp/authorize",
  "token_endpoint": ".../api/auth/mcp/token",
  "userinfo_endpoint": ".../api/auth/mcp/userinfo",
  "jwks_uri": ".../api/auth/mcp/jwks",
  "registration_endpoint": ".../api/auth/mcp/register",
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "subject_types_supported": ["public"]
}

S256 only for PKCE, and none among the token endpoint auth methods, is the combination a public client needs. No implicit grant.

The registration_endpoint is what lets a client show up with nothing but the server's URL and register itself (RFC 7591):

curl -s -X POST -H "Content-Type: application/json" \
  -d '{"client_name":"probe-doc-example","redirect_uris":["http://localhost:8765/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none"}' \
  https://app.heykairon.com/api/auth/mcp/register
HTTP/2 201
{
  "client_id": "<redacted>",
  "client_id_issued_at": 1784639646,
  "redirect_uris": ["http://localhost:8765/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "client_name": "probe-doc-example"
}

Four calls, no credentials, and a client now has everything it needs to start an authorization-code flow with PKCE.

The check: does every advertised URL resolve?

Walking the chain only proves the documents exist and parse. It doesn't prove they're honest. The check that matters is smaller: take every URL a discovery document hands back and request it. Both of ours name a jwks_uri at https://app.heykairon.com/api/auth/mcp/jwks. We tried it, and the paths around it:

/api/auth/mcp/jwks                   -> 404
/api/auth/jwks                       -> 404
/api/auth/jwks.json                  -> 404
/api/auth/oauth2/jwks                -> 404
/api/auth/mcp/.well-known/jwks.json  -> 404
/.well-known/jwks.json               -> 200, but content-type text/html

The 200 is worse than the 404s: it's the web app's SPA fallback returning <!doctype html>, not a key set. Two discovery documents advertised a jwks_uri, and every path we tried under it came back empty or wrong.

The impact is narrow, stated carefully. The server validates its own tokens — an invalid bearer token still gets rejected with the same 401 shown above — so nothing about live auth was broken. What breaks is a client that tries to verify a token's signature locally against the advertised key set: it follows jwks_uri and lands on a 404 or an HTML page. Many MCP clients never do this, which is likely why the mismatch had gone unnoticed. Before the fix below, a case-sensitive search for jwks across apps/api/src returned nothing, so the metadata wasn't coming from our code — it's the auth library, and the mismatch is probably a mounting or version detail in that integration. Confirming that needed more than this check did.

(That search returns hits today, because the fix itself added them. Worth saying plainly: an absence claim is only true as of a commit, and this post's whole premise is that you run the checks yourself rather than take a write-up's word for it.)

This is the same discipline behind checking any integration claim against the wire instead of the docs — it's what turned up the exact error payloads when we wired Search Console into an MCP server.

The fix was removal, not construction

The missing piece was never the JWKS endpoint. apps/api/src/auth/auth.ts already documented why one isn't needed: access tokens are opaque and introspected in-process via auth.api.getMcpSession, because the resource server is the same program that issued them. There's no signed artifact for a client to verify — the auth library's MCP plugin emits jwks_uri unconditionally, but this deployment shouldn't advertise one.

Both specs make that omission valid: jwks_uri is OPTIONAL in RFC 8414 §2 and in RFC 9728 §2. Dropping it describes the server truthfully. Building a working JWKS endpoint instead would have meant issuing signed tokens — an architecture change, not a fix for a metadata bug.

Shipped in PR #547 (98a01a6d).

The trap: a wrapper that never ran

The first attempt at the fix wrapped res.json to strip the field before the response left the server. It would never have run:

// better-call/dist/node.mjs
function toNodeHandler(handler) {
  return async (req, res) => setResponse(res, await handler(getRequest({ ... })));
}

toNodeHandler hands the whole response to better-call's setResponse, which writes the Node response itself and never touches Express's res.json. The wrapper sat on a method the framework adapter never calls. Caught by reading the adapter source, not by a failing test — the existing tests would have passed against the no-op, because they only asserted the field was absent, and it was already absent from their stub path.

The working fix calls the web-standard handler directly, reads its Response, drops the field, and sends the rest through Express by hand. Two regression tests, checked for being non-vacuous rather than trusted on sight: reverting the strip fails both (2 failed | 8 passed), restoring it passes 10/10.

The lesson generalizes past this one bug. A wrapper around a framework adapter can be a silent no-op — the adapter has its own path to the wire, and the wrapper is watching a door nothing walks through. A test suite that only asserts the desired end state can't catch that, because the no-op and the real fix produce the same passing assertion whenever the stub doesn't exercise the adapter's actual code path. The way to catch it here was reading what toNodeHandler actually does with the response, not adding another assertion on top of the same stub.

Verified after deploy, and what's still not accurate

Redeployed, the same probes were rerun:

protected-resource:      jwks_uri absent
authorization-server:    jwks_uri absent
                         issuer, authorization_endpoint, token_endpoint,
                         registration_endpoint, code_challenge_methods_supported intact
POST /mcp (no token):    401 + www-authenticate: Bearer resource_metadata="..."

One inaccuracy was left in place on purpose. resource_signing_alg_values_supported: ["RS256"] still sits in the protected-resource document, and with opaque tokens nothing is actually signed — it's the same class of problem the jwks_uri was. It stayed because it fails differently: it points at no dead URL and breaks no client that reads it, and removing it means first deciding whether the server issues signed ID tokens under the openid scope it already advertises. That's a judgment call, made and recorded here, not a clean bill of health.

Scope

This is a probe of one server, ours, run on 2026-07-21. No client was run against the JWKS gap to see whether anything actually broke in practice — the impact described above is reasoned from the spec, not observed. No authorization code was exchanged and no token was issued; registration was the last step this check exercised.

Running it against your own server costs four curl calls and a pass over whatever URLs come back in the two JSON documents. The same method pointed at tools/list instead of the discovery chain found the next gap: 40 tools returning zero tool annotations, and what the spec-documented defaults make a client assume in their absence. More checks built the same way, against our own stack rather than argued about in the abstract, are collected on the Kairon blog.