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.
Unified · Split
| @@ -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 some | 128 | // 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. |
| @@ -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 the | 204 | // 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; |