@@ -108,6 +108,78 @@ const isOAuthErrorBody = (body: string): boolean => {
108108 return typeof obj . error === "string" ;
109109} ;
110110
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+
111183const ErrorMessageShape = Schema . Struct ( { message : Schema . String } ) ;
112184const decodeErrorMessageShape = Schema . decodeUnknownOption ( ErrorMessageShape ) ;
113185
@@ -203,6 +275,13 @@ export const probeMcpEndpointShape = (
203275 if ( response . status === 401 ) {
204276 const wwwAuth = readHeader ( response . headers , "www-authenticate" ) ;
205277 if ( ! wwwAuth || ! / ^ \s * b e a r e r \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+ }
206285 return {
207286 kind : "not-mcp" ,
208287 category : "auth-required" ,
@@ -245,6 +324,11 @@ export const probeMcpEndpointShape = (
245324 // arrays or other shapes that fail both checks.
246325 const body = yield * readBody ( response ) ;
247326 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+ }
248332 return {
249333 kind : "not-mcp" ,
250334 category : "auth-required" ,
0 commit comments