History

fix(mcp): accept resource_metadata= as MCP signal on 401 challenges

The body-shape gate rejected sentry.dev's 401 because its body is a spec-compliant RFC 6750 OAuth error envelope (`{error:"invalid_token", ...}`), not JSON-RPC. Sentry advertises full RFC 9728/8414 metadata via the `resource_metadata=` attribute of WWW-Authenticate, which is the MCP authorization spec's mandated signal. Accept `resource_metadata=` as sufficient for MCP+auth on 401, falling back to the JSON-RPC body check only for bare Bearer challenges (the cubic.dev / static-API-key path). Railway-style OAuth-protected non-MCP endpoints still get rejected because they don't include `resource_metadata=` and don't return JSON-RPC bodies.

Rhys Sullivan committed 6 months ago

Commit 70ebac21 parent 8b4e7932 files · +45 −1Browse files

Unified · Split

2 files
  1. packages/plugins/mcp/src/sdk/probe-shape.test.ts +29 −0
  2. packages/plugins/mcp/src/sdk/probe-shape.ts +16 −1
packages/plugins/mcp/src/sdk/probe-shape.test.ts+29−0
@@ -95,6 +95,35 @@ describe("probeMcpEndpointShape", () => { · 94 unmodified lines
9595 ),
9696 );
9797
98+ // mcp.sentry.dev/mcp/ shape: spec-compliant `resource_metadata=`
99+ // attribute, body is RFC 6750 OAuth-shape (`{error: "invalid_token",
100+ // ...}`), not JSON-RPC. The `resource_metadata=` attribute alone is
101+ // enough to classify as MCP — the body-shape gate is for the bare-Bearer
102+ // case where we have no other signal.
103+ it.effect("classifies 401 with resource_metadata + OAuth error body as MCP+auth", () =>
104+ withServer(
105+ () =>
106+ HttpServerResponse.jsonUnsafe(
107+ {
108+ error: "invalid_token",
109+ error_description: "Missing or invalid access token",
110+ },
111+ {
112+ status: 401,
113+ headers: {
114+ "www-authenticate":
115+ 'Bearer realm="OAuth", resource_metadata="https://mcp.example/.well-known/oauth-protected-resource/mcp/", error="invalid_token"',
116+ },
117+ },
118+ ),
119+ (endpoint) =>
120+ Effect.gen(function* () {
121+ const result = yield* probeMcpEndpointShape(endpoint);
122+ expect(result).toEqual({ kind: "mcp", requiresAuth: true });
123+ }),
124+ ),
125+ );
126+
98127 // cubic.dev/api/mcp shape: bare `Bearer` challenge, no resource_metadata.
99128 // The JSON-RPC error body is what tells us this is MCP rather than some
100129 // other OAuth/API-key protected service.
packages/plugins/mcp/src/sdk/probe-shape.ts+16−1
@@ -191,15 +191,30 @@ export const probeMcpEndpointShape = ( · 190 unmodified lines
191191 reason: "401 without Bearer WWW-Authenticate — not an MCP auth challenge",
192192 } as const;
193193 }
194+ // Spec-compliant MCP signal: the auth spec mandates a
195+ // `resource_metadata=` attribute pointing at the server's
196+ // RFC 9728 document. Real OAuth-protected MCP servers
197+ // (sentry.dev, etc.) include it. This attribute is rare on
198+ // unrelated OAuth services and is the cleanest accept signal
199+ // we have when the 401 body is RFC 6750 OAuth-shape rather
200+ // than JSON-RPC.
201+ if (/(?:^|[\s,])resource_metadata\s*=/i.test(wwwAuth)) {
202+ return { kind: "mcp", requiresAuth: true } as const;
203+ }
194204 // SSE responses can't carry a JSON-RPC error envelope; accept the
195205 // Bearer challenge alone in that case (rare but spec-permissible).
196206 if (isSse) return { kind: "mcp", requiresAuth: true } as const;
207+ // Fallback for non-OAuth MCP servers that use static API
208+ // keys and don't publish RFC 9728 metadata (cubic.dev). The
209+ // JSON-RPC body shape is what separates them from
210+ // OAuth-protected non-MCP services that also issue bare
211+ // Bearer challenges (Railway-style GraphQL, etc.).
197212 const body = yield* readBody(response);
198213 if (!isJsonRpcEnvelope(body)) {
199214 return {
200215 kind: "not-mcp",
201216 category: "auth-required",
202− reason: "401 + Bearer but body is not a JSON-RPC envelope",
217+ reason: "401 + Bearer without resource_metadata or JSON-RPC body",
203218 } as const;
204219 }
205220 return { kind: "mcp", requiresAuth: true } as const;