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+
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+46−0
@@ -182,6 +182,52 @@ describe("probeMcpEndpointShape", () => { · 181 unmodified lines
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+84−0
@@ -108,6 +108,78 @@ const isOAuthErrorBody = (body: string): boolean => { · 107 unmodified lines
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 = ( · 89 unmodified lines
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 = ( · 36 unmodified lines
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",