Skip to content

Commit 5fa63e4

Browse files
authored
Detect OAuth MCP servers that omit the 401 challenge (#819)
* Detect OAuth MCP servers that omit the 401 challenge Some MCP servers return a bare 401 with no usable WWW-Authenticate challenge, so the wire-shape probe could not distinguish them from an unrelated OAuth-protected API and the user landed on the manual credentials prompt instead of an OAuth sign-in. When a 401 would otherwise be rejected, fall back to fetching the RFC 9728 path-scoped protected-resource metadata; a document whose resource matches the endpoint is enough to classify it as MCP and start the OAuth flow. * Add changeset for MCP OAuth probe fallback
1 parent 9f7f6f0 commit 5fa63e4

3 files changed

Lines changed: 144 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@executor-js/plugin-mcp": patch
3+
"executor": patch
4+
---
5+
6+
Fix: remote MCP servers that return a bare `401` without a usable
7+
`WWW-Authenticate` challenge (e.g. Datadog's `mcp.datadoghq.com`) now trigger
8+
the OAuth sign-in flow instead of falling back to the manual-credentials
9+
prompt. When a `401` would otherwise be rejected, the endpoint probe also
10+
checks for RFC 9728 protected-resource metadata at the path-scoped
11+
well-known URL; a document there whose `resource` matches the endpoint is
12+
enough to classify it as an OAuth-protected MCP server. The check stays
13+
narrow — path-scoped metadata plus a `resource`-to-endpoint match — so a
14+
generic OAuth-protected API is not misclassified as MCP.

packages/plugins/mcp/src/sdk/probe-shape.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,52 @@ describe("probeMcpEndpointShape", () => {
182182
),
183183
);
184184

185+
// Datadog shape: bare `401 {"errors":["Unauthorized"]}` with no
186+
// WWW-Authenticate header at all, but the server still publishes
187+
// RFC 9728 protected-resource metadata at the path-scoped well-known
188+
// URL. The metadata probe is what lets us classify it as MCP+auth.
189+
it.effect("classifies 401 w/o WWW-Authenticate but with RFC 9728 metadata as MCP+auth", () =>
190+
withServer(
191+
(request) => {
192+
if (request.url.includes("/.well-known/oauth-protected-resource")) {
193+
const host = request.headers.host ?? "127.0.0.1";
194+
return HttpServerResponse.jsonUnsafe({
195+
resource: `http://${host}/probe`,
196+
authorization_servers: [`http://${host}`],
197+
});
198+
}
199+
return HttpServerResponse.jsonUnsafe({ errors: ["Unauthorized"] }, { status: 401 });
200+
},
201+
(endpoint) =>
202+
Effect.gen(function* () {
203+
const result = yield* probeMcpEndpointShape(endpoint);
204+
expect(result).toEqual({ kind: "mcp", requiresAuth: true });
205+
}),
206+
),
207+
);
208+
209+
// The metadata probe must not rescue a 401 when the published
210+
// `resource` describes some unrelated resource on the same host.
211+
it.effect("rejects 401 when RFC 9728 metadata resource doesn't match endpoint", () =>
212+
withServer(
213+
(request) => {
214+
if (request.url.includes("/.well-known/oauth-protected-resource")) {
215+
const host = request.headers.host ?? "127.0.0.1";
216+
return HttpServerResponse.jsonUnsafe({
217+
resource: `http://${host}/some-other-api`,
218+
authorization_servers: [`http://${host}`],
219+
});
220+
}
221+
return HttpServerResponse.jsonUnsafe({ errors: ["Unauthorized"] }, { status: 401 });
222+
},
223+
(endpoint) =>
224+
Effect.gen(function* () {
225+
const result = yield* probeMcpEndpointShape(endpoint);
226+
expect(result).toMatchObject({ kind: "not-mcp", category: "auth-required" });
227+
}),
228+
),
229+
);
230+
185231
it.effect("rejects 401 + Bearer with empty body as auth-required", () =>
186232
withServer(
187233
() =>

packages/plugins/mcp/src/sdk/probe-shape.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
111183
const ErrorMessageShape = Schema.Struct({ message: Schema.String });
112184
const 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*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+
}
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

Comments
 (0)