History

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

Rhys Sullivan committed 6 months ago

Commit 5fa63e41 parent 9f7f6f03 files · +144 −0Browse files

Unified · Split

3 files
  1. .changeset/mcp-oauth-probe-prm-fallback.md +14 −0
  2. packages/plugins/mcp/src/sdk/probe-shape.test.ts +46 −0
  3. packages/plugins/mcp/src/sdk/probe-shape.ts +84 −0
.changeset/mcp-oauth-probe-prm-fallback.md+14−0
@@ -0,0 +1,14 @@
1---
2"@executor-js/plugin-mcp": patch
3"executor": patch
4---
5
6Fix: remote MCP servers that return a bare `401` without a usable
7`WWW-Authenticate` challenge (e.g. Datadog's `mcp.datadoghq.com`) now trigger
8the OAuth sign-in flow instead of falling back to the manual-credentials
9prompt. When a `401` would otherwise be rejected, the endpoint probe also
10checks for RFC 9728 protected-resource metadata at the path-scoped
11well-known URL; a document there whose `resource` matches the endpoint is
12enough to classify it as an OAuth-protected MCP server. The check stays
13narrow — path-scoped metadata plus a `resource`-to-endpoint match — so a
14generic OAuth-protected API is not misclassified as MCP.
packages/plugins/mcp/src/sdk/probe-shape.test.ts+46−0
@@ -182,6 +182,52 @@ describe("probeMcpEndpointShape", () => { · 181 unmodified lines
182 ),182 ),
183 );183 );
184 184
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
185 it.effect("rejects 401 + Bearer with empty body as auth-required", () =>231 it.effect("rejects 401 + Bearer with empty body as auth-required", () =>
186 withServer(232 withServer(
187 () =>233 () =>
packages/plugins/mcp/src/sdk/probe-shape.ts+84−0
@@ -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. */
115const ProtectedResourceMetadata = Schema.Struct({
116 resource: Schema.String,
117 authorization_servers: Schema.Array(Schema.String),
118});
119const 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. */
128const 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. */
138const 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. */
159const 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
111const ErrorMessageShape = Schema.Struct({ message: Schema.String });183const ErrorMessageShape = Schema.Struct({ message: Schema.String });
112const decodeErrorMessageShape = Schema.decodeUnknownOption(ErrorMessageShape);184const 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",