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
95 ),95 ),
96 );96 );
97 97
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
98 // cubic.dev/api/mcp shape: bare `Bearer` challenge, no resource_metadata.127 // cubic.dev/api/mcp shape: bare `Bearer` challenge, no resource_metadata.
99 // The JSON-RPC error body is what tells us this is MCP rather than some128 // The JSON-RPC error body is what tells us this is MCP rather than some
100 // other OAuth/API-key protected service.129 // 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
191 reason: "401 without Bearer WWW-Authenticate — not an MCP auth challenge",191 reason: "401 without Bearer WWW-Authenticate — not an MCP auth challenge",
192 } as const;192 } as const;
193 }193 }
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 }
194 // SSE responses can't carry a JSON-RPC error envelope; accept the204 // SSE responses can't carry a JSON-RPC error envelope; accept the
195 // Bearer challenge alone in that case (rare but spec-permissible).205 // Bearer challenge alone in that case (rare but spec-permissible).
196 if (isSse) return { kind: "mcp", requiresAuth: true } as const;206 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.).
197 const body = yield* readBody(response);212 const body = yield* readBody(response);
198 if (!isJsonRpcEnvelope(body)) {213 if (!isJsonRpcEnvelope(body)) {
199 return {214 return {
200 kind: "not-mcp",215 kind: "not-mcp",
201 category: "auth-required",216 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",
203 } as const;218 } as const;
204 }219 }
205 return { kind: "mcp", requiresAuth: true } as const;220 return { kind: "mcp", requiresAuth: true } as const;