| @@ -108,6 +108,78 @@ const isOAuthErrorBody = (body: string): boolean => { · 107 unmodified lines |
| 108 | return typeof obj.error === "string"; | 108 | return typeof obj.error === "string"; |
| 109 | }; | 109 | }; |
| 110 | | 110 | |
| | 111 | /** RFC 9728 protected-resource-metadata document. We only need the two |
| | 112 | * fields that prove the document genuinely describes an OAuth-protected |
| | 113 | * resource: `resource` (the resource identifier) and a non-empty |
| | 114 | * `authorization_servers` list. */ |
| | 115 | const ProtectedResourceMetadata = Schema.Struct({ |
| | 116 | resource: Schema.String, |
| | 117 | authorization_servers: Schema.Array(Schema.String), |
| | 118 | }); |
| | 119 | const decodeProtectedResourceMetadata = Schema.decodeUnknownOption( |
| | 120 | Schema.fromJsonString(ProtectedResourceMetadata), |
| | 121 | ); |
| | 122 | |
| | 123 | /** RFC 9728 §3.1 path-scoped well-known URL: insert |
| | 124 | * `/.well-known/oauth-protected-resource` before the resource's path |
| | 125 | * component. `https://host/api/mcp` → `https://host/.well-known/oauth- |
| | 126 | * protected-resource/api/mcp`. This is exactly the URL the MCP |
| | 127 | * authorization spec tells clients to construct. */ |
| | 128 | const protectedResourceMetadataUrl = (endpoint: URL): string => { |
| | 129 | const path = endpoint.pathname === "/" ? "" : endpoint.pathname; |
| | 130 | return `${endpoint.origin}/.well-known/oauth-protected-resource${path}`; |
| | 131 | }; |
| | 132 | |
| | 133 | /** The RFC 9728 `resource` value must actually describe this endpoint |
| | 134 | * before we trust the document — an exact URL match, or a same-origin |
| | 135 | * parent whose path is a prefix of the endpoint's. Guards against a |
| | 136 | * shared host serving protected-resource metadata for some unrelated |
| | 137 | * resource. */ |
| | 138 | const resourceMatchesEndpoint = (resource: string, endpoint: URL): boolean => { |
| | 139 | if (!URL.canParse(resource)) return false; |
| | 140 | const parsed = new URL(resource); |
| | 141 | if (parsed.origin !== endpoint.origin) return false; |
| | 142 | const resourcePath = parsed.pathname.replace(/\/+$/, ""); |
| | 143 | const endpointPath = endpoint.pathname.replace(/\/+$/, ""); |
| | 144 | return endpointPath === resourcePath || endpointPath.startsWith(`${resourcePath}/`); |
| | 145 | }; |
| | 146 | |
| | 147 | /** Workaround for MCP servers that omit (or under-specify) the |
| | 148 | * `WWW-Authenticate` challenge on their 401 — e.g. Datadog's |
| | 149 | * `mcp.datadoghq.com` returns a bare `401 {"errors":["Unauthorized"]}` |
| | 150 | * with no header at all, so the wire-shape gate above can't tell it |
| | 151 | * apart from an unrelated OAuth-protected API and the user lands on the |
| | 152 | * manual-credentials prompt instead of an OAuth sign-in. |
| | 153 | * |
| | 154 | * The MCP authorization spec still requires such servers to publish |
| | 155 | * RFC 9728 metadata at the path-scoped well-known URL. A document there |
| | 156 | * whose `resource` matches this endpoint is a deliberate, MCP-spec- |
| | 157 | * specific signal a generic OAuth API would not emit — strong enough to |
| | 158 | * classify the endpoint as MCP so the OAuth flow can start. */ |
| | 159 | const probeProtectedResourceMetadata = ( |
| | 160 | client: HttpClient.HttpClient, |
| | 161 | endpoint: URL, |
| | 162 | timeoutMs: number, |
| | 163 | ): Effect.Effect<boolean> => |
| | 164 | Effect.gen(function* () { |
| | 165 | const response = yield* client |
| | 166 | .execute( |
| | 167 | HttpClientRequest.get(protectedResourceMetadataUrl(endpoint)).pipe( |
| | 168 | HttpClientRequest.setHeader("accept", "application/json"), |
| | 169 | ), |
| | 170 | ) |
| | 171 | .pipe(Effect.timeout(Duration.millis(timeoutMs))); |
| | 172 | if (response.status < 200 || response.status >= 300) return false; |
| | 173 | const body = yield* response.text.pipe( |
| | 174 | Effect.timeout(Duration.millis(timeoutMs)), |
| | 175 | Effect.catch(() => Effect.succeed("")), |
| | 176 | ); |
| | 177 | const metadata = decodeProtectedResourceMetadata(body); |
| | 178 | if (Option.isNone(metadata)) return false; |
| | 179 | if (metadata.value.authorization_servers.length === 0) return false; |
| | 180 | return resourceMatchesEndpoint(metadata.value.resource, endpoint); |
| | 181 | }).pipe(Effect.catch(() => Effect.succeed(false))); |
| | 182 | |
| 111 | const ErrorMessageShape = Schema.Struct({ message: Schema.String }); | 183 | const ErrorMessageShape = Schema.Struct({ message: Schema.String }); |
| 112 | const decodeErrorMessageShape = Schema.decodeUnknownOption(ErrorMessageShape); | 184 | const decodeErrorMessageShape = Schema.decodeUnknownOption(ErrorMessageShape); |
| 113 | | 185 | |
| @@ -203,6 +275,13 @@ export const probeMcpEndpointShape = ( · 89 unmodified lines |
| 203 | if (response.status === 401) { | 275 | if (response.status === 401) { |
| 204 | const wwwAuth = readHeader(response.headers, "www-authenticate"); | 276 | const wwwAuth = readHeader(response.headers, "www-authenticate"); |
| 205 | if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) { | 277 | if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) { |
| | 278 | // Spec-non-compliant 401 (no `Bearer` challenge). Before |
| | 279 | // giving up, check whether the server still publishes |
| | 280 | // RFC 9728 protected-resource metadata for this path — |
| | 281 | // some real MCP servers (Datadog) do exactly this. |
| | 282 | if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) { |
| | 283 | return { kind: "mcp", requiresAuth: true } as const; |
| | 284 | } |
| 206 | return { | 285 | return { |
| 207 | kind: "not-mcp", | 286 | kind: "not-mcp", |
| 208 | category: "auth-required", | 287 | category: "auth-required", |
| @@ -245,6 +324,11 @@ export const probeMcpEndpointShape = ( · 36 unmodified lines |
| 245 | // arrays or other shapes that fail both checks. | 324 | // arrays or other shapes that fail both checks. |
| 246 | const body = yield* readBody(response); | 325 | const body = yield* readBody(response); |
| 247 | if (!isJsonRpcEnvelope(body) && !isOAuthErrorBody(body)) { | 326 | if (!isJsonRpcEnvelope(body) && !isOAuthErrorBody(body)) { |
| | 327 | // Bearer challenge with no usable accept signal. Same |
| | 328 | // RFC 9728 fallback as the no-`Bearer` case above. |
| | 329 | if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) { |
| | 330 | return { kind: "mcp", requiresAuth: true } as const; |
| | 331 | } |
| 248 | return { | 332 | return { |
| 249 | kind: "not-mcp", | 333 | kind: "not-mcp", |
| 250 | category: "auth-required", | 334 | category: "auth-required", |