From 70ebac2222d642eb5092290c2e8f5f49641fb376 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 9 May 2026 00:59:09 -0700 Subject: [PATCH 1/4] fix(mcp): accept resource_metadata= as MCP signal on 401 challenges The body-shape gate rejected sentry.dev's 401 because its body is a spec-compliant RFC 6750 OAuth error envelope (`{error:"invalid_token", ...}`), not JSON-RPC. Sentry advertises full RFC 9728/8414 metadata via the `resource_metadata=` attribute of WWW-Authenticate, which is the MCP authorization spec's mandated signal. Accept `resource_metadata=` as sufficient for MCP+auth on 401, falling back to the JSON-RPC body check only for bare Bearer challenges (the cubic.dev / static-API-key path). Railway-style OAuth-protected non-MCP endpoints still get rejected because they don't include `resource_metadata=` and don't return JSON-RPC bodies. --- .../plugins/mcp/src/sdk/probe-shape.test.ts | 29 +++++++++++++++++++ packages/plugins/mcp/src/sdk/probe-shape.ts | 17 ++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/plugins/mcp/src/sdk/probe-shape.test.ts b/packages/plugins/mcp/src/sdk/probe-shape.test.ts index fd4d9abd4..1a4d3684b 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.test.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.test.ts @@ -95,6 +95,35 @@ describe("probeMcpEndpointShape", () => { ), ); + // mcp.sentry.dev/mcp/ shape: spec-compliant `resource_metadata=` + // attribute, body is RFC 6750 OAuth-shape (`{error: "invalid_token", + // ...}`), not JSON-RPC. The `resource_metadata=` attribute alone is + // enough to classify as MCP — the body-shape gate is for the bare-Bearer + // case where we have no other signal. + it.effect("classifies 401 with resource_metadata + OAuth error body as MCP+auth", () => + withServer( + () => + HttpServerResponse.jsonUnsafe( + { + error: "invalid_token", + error_description: "Missing or invalid access token", + }, + { + status: 401, + headers: { + "www-authenticate": + 'Bearer realm="OAuth", resource_metadata="https://mcp.example/.well-known/oauth-protected-resource/mcp/", error="invalid_token"', + }, + }, + ), + (endpoint) => + Effect.gen(function* () { + const result = yield* probeMcpEndpointShape(endpoint); + expect(result).toEqual({ kind: "mcp", requiresAuth: true }); + }), + ), + ); + // cubic.dev/api/mcp shape: bare `Bearer` challenge, no resource_metadata. // The JSON-RPC error body is what tells us this is MCP rather than some // other OAuth/API-key protected service. diff --git a/packages/plugins/mcp/src/sdk/probe-shape.ts b/packages/plugins/mcp/src/sdk/probe-shape.ts index 51888cd84..5fcc23006 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.ts @@ -191,15 +191,30 @@ export const probeMcpEndpointShape = ( reason: "401 without Bearer WWW-Authenticate — not an MCP auth challenge", } as const; } + // Spec-compliant MCP signal: the auth spec mandates a + // `resource_metadata=` attribute pointing at the server's + // RFC 9728 document. Real OAuth-protected MCP servers + // (sentry.dev, etc.) include it. This attribute is rare on + // unrelated OAuth services and is the cleanest accept signal + // we have when the 401 body is RFC 6750 OAuth-shape rather + // than JSON-RPC. + if (/(?:^|[\s,])resource_metadata\s*=/i.test(wwwAuth)) { + return { kind: "mcp", requiresAuth: true } as const; + } // SSE responses can't carry a JSON-RPC error envelope; accept the // Bearer challenge alone in that case (rare but spec-permissible). if (isSse) return { kind: "mcp", requiresAuth: true } as const; + // Fallback for non-OAuth MCP servers that use static API + // keys and don't publish RFC 9728 metadata (cubic.dev). The + // JSON-RPC body shape is what separates them from + // OAuth-protected non-MCP services that also issue bare + // Bearer challenges (Railway-style GraphQL, etc.). const body = yield* readBody(response); if (!isJsonRpcEnvelope(body)) { return { kind: "not-mcp", category: "auth-required", - reason: "401 + Bearer but body is not a JSON-RPC envelope", + reason: "401 + Bearer without resource_metadata or JSON-RPC body", } as const; } return { kind: "mcp", requiresAuth: true } as const; From e87469b2e41a83aa5127cd78bd325ad77412b085 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 9 May 2026 01:09:41 -0700 Subject: [PATCH 2/4] feat(mcp): live-snapshot probe regression suite + RFC 6750 body accept Add an opt-in test that fires the canonical MCP `initialize` POST at each of 11 real public MCP servers (Sentry, Stripe, Linear, Notion, Atlassian, Zapier, Cubic, ref.tools, Hugging Face, DeepWiki, Context7), captures the raw status / WWW-Authenticate / content-type / body snippet alongside the resulting probe classification, and pins them as vitest snapshots. Default test runs stay offline; opt in via `MCP_PROBE_LIVE=1`. When a real server's behavior shifts the snapshot diff makes the change reviewable, and `vitest -u` regenerates. While running the live suite Atlassian failed: it omits `resource_metadata=` from its WWW-Authenticate and returns an RFC 6750 Bearer error envelope (`{error:"invalid_token", ...}`) rather than JSON-RPC. Add an OAuth-error-body accept path to probe-shape so 401 + bare Bearer + RFC 6750 envelope classifies as MCP+auth, while the GraphQL `{errors:[...]}` shape that motivated the body gate still rejects. --- ...probe-shape-real-servers.live.test.ts.snap | 169 ++++++++++++++++++ .../sdk/probe-shape-real-servers.live.test.ts | 133 ++++++++++++++ packages/plugins/mcp/src/sdk/probe-shape.ts | 54 ++++-- 3 files changed, 341 insertions(+), 15 deletions(-) create mode 100644 packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap create mode 100644 packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts diff --git a/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap b/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap new file mode 100644 index 000000000..e5a794465 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap @@ -0,0 +1,169 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`probeMcpEndpointShape against live MCP servers > atlassian (https://mcp.atlassian.com/v1/sse) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > context7 (https://mcp.context7.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": false, + }, + "raw": { + "bodySnippet": "event: message +data: {"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"Context7","version":"2.2.4","websiteUrl":"https://context7.com","description":"Context7 provides up-to-date documentation and code examples for libraries and frameworks.","icons":[{"src":"https://context7.com/context7-icon-green.png","mimeType":"image/png"}]},"instructions":"Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs.\\n\\nDo not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general pr", + "contentType": "text/event-stream", + "status": 200, + "wwwAuthenticate": "Bearer resource_metadata="https://mcp.context7.com/.well-known/oauth-protected-resource"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > cubic (https://www.cubic.dev/api/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"jsonrpc":"2.0","error":{"code":-32000,"message":"Unauthorized: Valid API key required. Generate one at Settings -> Integrations -> MCP."},"id":null}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > deepwiki (https://mcp.deepwiki.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": false, + }, + "raw": { + "bodySnippet": "event: message +data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"experimental":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"DeepWiki","version":"2.14.3"},"instructions":"DeepWiki MCP provides AI-powered documentation for GitHub repositories.\\n\\nAvailable tools:\\n- read_wiki_structure: Get a list of documentation topics for a repository\\n- read_wiki_contents: View full documentation about a repository\\n- ask_question: Ask any question about a repository and get an AI-powered answer\\n- list_available_repos: List your available repositories (private mode only)\\n- generate_wiki: Generate a codebase wiki for a repository — only use when explicitly requested by the user (private mode only)\\n- devin_knowledge_manage: Manage Devin knowledge notes and suggestions — list, search, get, create, update, delete notes, view folder structure, list/view/dismiss knowledge suggestions (private mode ", + "contentType": "text/event-stream", + "status": 200, + "wwwAuthenticate": null, + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > huggingface (https://huggingface.co/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": false, + }, + "raw": { + "bodySnippet": "{"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":false},"prompts":{"listChanged":false}},"serverInfo":{"name":"@huggingface/mcp-services","version":"0.3.12","title":"Hugging Face","websiteUrl":"https://huggingface.co/mcp","icons":[{"src":"https://huggingface.co/favicon.ico"}]},"instructions":"You have tools for using the Hugging Face Hub. arXiv paper id's are often used as references between datasets, models and papers. There are over 100 tags in use, common tags include 'Text Generation', 'Transformers', 'Image Classification' and so on.\\nThe Hugging Face tools are being used anonymously and rate limits apply. Direct the User to set their HF_TOKEN (instructions at https://hf.co/settings/mcp/), or create an account at https://hf.co/join for higher limits."},"jsonrpc":"2.0","id":1}", + "contentType": "application/json", + "status": 200, + "wwwAuthenticate": null, + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > linear (https://mcp.linear.app/sse) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", resource_metadata="https://mcp.linear.app/.well-known/oauth-protected-resource/sse", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > notion (https://mcp.notion.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", resource_metadata="https://mcp.notion.com/.well-known/oauth-protected-resource/mcp", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > reftools (https://api.ref.tools/mcp) 1`] = ` +{ + "probe": { + "category": "auth-required", + "kind": "not-mcp", + "reason": "401 without Bearer WWW-Authenticate — not an MCP auth challenge", + }, + "raw": { + "bodySnippet": "{"error":"Unauthorized - Authentication required"}", + "contentType": "application/json; charset=utf-8", + "status": 401, + "wwwAuthenticate": null, + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > sentry (https://mcp.sentry.dev/mcp/) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", resource_metadata="https://mcp.sentry.dev/.well-known/oauth-protected-resource", error="invalid_token", error_description="Missing or invalid access token", resource_metadata="https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp/"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > stripe (https://mcp.stripe.com) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"Unauthorized. See https://docs.stripe.com/mcp for usage instructions."}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer resource_metadata=https://mcp.stripe.com/.well-known/oauth-protected-resource", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > zapier (https://mcp.zapier.com/api/mcp/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"jsonrpc":"2.0","id":null,"error":{"code":-31996,"message":"Expected Bearer token for MCP authentication"}}", + "contentType": "application/json; charset=utf-8", + "status": 401, + "wwwAuthenticate": "Bearer realm="Zapier MCP", error="invalid_token"", + }, +} +`; diff --git a/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts b/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts new file mode 100644 index 000000000..65e844f87 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts @@ -0,0 +1,133 @@ +// --------------------------------------------------------------------------- +// Live snapshot regression suite against real public MCP servers. +// +// This file hits the network. It is gated on `MCP_PROBE_LIVE=1` so default +// test runs stay offline — set the env var to opt in: +// +// MCP_PROBE_LIVE=1 vitest run probe-shape-real-servers.live.test.ts +// +// To refresh snapshots (after intentional probe-shape changes, or after a +// real server's behavior shifts), add `-u`: +// +// MCP_PROBE_LIVE=1 vitest run probe-shape-real-servers.live.test.ts -u +// +// Each test fetches `` with the canonical MCP `initialize` POST, +// captures the raw status / content-type / www-authenticate / body +// snippet, and runs the response through `probeMcpEndpointShape`. The +// snapshot pins both the raw signal (so a diff tells you *why* a +// classification changed) and the classification itself. Servers +// occasionally drift versions or reword error strings; that's expected +// and a snapshot update is fine. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Duration, Effect, Stream } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { probeMcpEndpointShape } from "./probe-shape"; + +const MCP_INITIALIZE_BODY = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "executor-probe", version: "0" }, + }, +}); + +const liveServers: ReadonlyArray<{ readonly name: string; readonly url: string }> = [ + { name: "sentry", url: "https://mcp.sentry.dev/mcp/" }, + { name: "stripe", url: "https://mcp.stripe.com" }, + { name: "linear", url: "https://mcp.linear.app/sse" }, + { name: "notion", url: "https://mcp.notion.com/mcp" }, + { name: "atlassian", url: "https://mcp.atlassian.com/v1/sse" }, + { name: "zapier", url: "https://mcp.zapier.com/api/mcp/mcp" }, + { name: "cubic", url: "https://www.cubic.dev/api/mcp" }, + { name: "reftools", url: "https://api.ref.tools/mcp" }, + { name: "huggingface", url: "https://huggingface.co/mcp" }, + { name: "deepwiki", url: "https://mcp.deepwiki.com/mcp" }, + { name: "context7", url: "https://mcp.context7.com/mcp" }, +]; + +interface RawCapture { + readonly status: number; + readonly contentType: string | null; + readonly wwwAuthenticate: string | null; + readonly bodySnippet: string; +} + +const BODY_CAP = 1024; +const REQUEST_TIMEOUT = Duration.seconds(10); +const BODY_READ_TIMEOUT = Duration.seconds(2); + +// Capture the raw probe response with hard timeouts and a body-size cap. +// SSE servers stream forever, so we walk the response stream until the +// running byte count crosses BODY_CAP, then stop. Stream cancellation +// closes the underlying connection. We don't strip dynamic fields +// (server version numbers, rotating error messages) — when those drift +// we want to see it in the snapshot diff. +const readHeaderCi = (headers: Readonly>, name: string): string | null => { + const direct = headers[name]; + if (direct !== undefined) return direct; + const lower = name.toLowerCase(); + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === lower) return v; + } + return null; +}; + +const captureLive = (url: string): Effect.Effect => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.post(url).pipe( + HttpClientRequest.setHeader("content-type", "application/json"), + HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), + HttpClientRequest.bodyText(MCP_INITIALIZE_BODY, "application/json"), + ); + const response = yield* client.execute(request).pipe(Effect.timeout(REQUEST_TIMEOUT)); + + let total = 0; + const chunks = yield* response.stream.pipe( + Stream.takeUntil((chunk: Uint8Array) => { + total += chunk.byteLength; + return total >= BODY_CAP; + }), + Stream.runCollect, + Effect.timeout(BODY_READ_TIMEOUT), + Effect.catch(() => Effect.succeed([] as ReadonlyArray)), + ); + + const decoder = new TextDecoder(); + const bodySnippet = chunks + .map((c) => decoder.decode(c)) + .join("") + .slice(0, BODY_CAP); + + return { + status: response.status, + contentType: readHeaderCi(response.headers, "content-type"), + wwwAuthenticate: readHeaderCi(response.headers, "www-authenticate"), + bodySnippet, + } satisfies RawCapture; + }).pipe(Effect.provide(FetchHttpClient.layer)); + +const live = process.env.MCP_PROBE_LIVE === "1"; + +describe.skipIf(!live)("probeMcpEndpointShape against live MCP servers", () => { + for (const server of liveServers) { + it.effect( + `${server.name} (${server.url})`, + () => + Effect.gen(function* () { + const raw = yield* captureLive(server.url); + const probe = yield* probeMcpEndpointShape(server.url, { + timeoutMs: Duration.toMillis(REQUEST_TIMEOUT), + }); + expect({ raw, probe }).toMatchSnapshot(); + }), + { timeout: Duration.toMillis(REQUEST_TIMEOUT) * 3 }, + ); + } +}); diff --git a/packages/plugins/mcp/src/sdk/probe-shape.ts b/packages/plugins/mcp/src/sdk/probe-shape.ts index 5fcc23006..725fc7a58 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.ts @@ -72,24 +72,42 @@ class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{ readonly cause: unknown; }> {} +const decodeJsonString = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +const asObject = (body: string): Record | null => { + if (!body) return null; + const parsed = decodeJsonString(body); + if (Option.isNone(parsed)) return null; + const value = parsed.value; + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +}; + /** Quick check that a body parses as a JSON-RPC 2.0 envelope. The MCP wire * protocol is JSON-RPC 2.0, so a real MCP server's response to `initialize` * (whether 2xx with the result, or a 401 error envelope) carries this * shape. Non-MCP services don't — GraphQL APIs return `{errors:[...]}`, * REST APIs return their own envelope, marketing pages return HTML. */ -const decodeJsonString = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); - const isJsonRpcEnvelope = (body: string): boolean => { - if (!body) return false; - const parsed = decodeJsonString(body); - if (Option.isNone(parsed)) return false; - const value = parsed.value; - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const obj = value as Record; + const obj = asObject(body); + if (!obj) return false; if (obj.jsonrpc !== "2.0") return false; return "result" in obj || "error" in obj || "method" in obj; }; +/** Quick check that a body parses as an RFC 6750 OAuth Bearer error + * envelope (`{error: "invalid_token", error_description?: ..., ...}`). + * Real MCP servers like Atlassian return this shape on unauth requests + * even when their WWW-Authenticate omits `resource_metadata=`. The + * GraphQL `{errors: [...]}` envelope, which a non-MCP OAuth-protected + * GraphQL API would return, is explicitly excluded. */ +const isOAuthErrorBody = (body: string): boolean => { + const obj = asObject(body); + if (!obj) return false; + if (Array.isArray(obj.errors)) return false; + return typeof obj.error === "string"; +}; + const ErrorMessageShape = Schema.Struct({ message: Schema.String }); const decodeErrorMessageShape = Schema.decodeUnknownOption(ErrorMessageShape); @@ -204,17 +222,23 @@ export const probeMcpEndpointShape = ( // SSE responses can't carry a JSON-RPC error envelope; accept the // Bearer challenge alone in that case (rare but spec-permissible). if (isSse) return { kind: "mcp", requiresAuth: true } as const; - // Fallback for non-OAuth MCP servers that use static API - // keys and don't publish RFC 9728 metadata (cubic.dev). The - // JSON-RPC body shape is what separates them from - // OAuth-protected non-MCP services that also issue bare - // Bearer challenges (Railway-style GraphQL, etc.). + // Fallback for MCP servers whose 401 omits + // `resource_metadata=`. Two body shapes count: + // - JSON-RPC error (cubic.dev: API-key auth, JSON-RPC + // errors end-to-end). + // - RFC 6750 OAuth Bearer error envelope `{error: + // "invalid_token", ...}` without GraphQL `{errors:[...]}` + // (Atlassian). + // Non-MCP OAuth-protected services that issue bare Bearer + // challenges (Railway-style GraphQL, etc.) return `errors` + // arrays or other shapes that fail both checks. const body = yield* readBody(response); - if (!isJsonRpcEnvelope(body)) { + if (!isJsonRpcEnvelope(body) && !isOAuthErrorBody(body)) { return { kind: "not-mcp", category: "auth-required", - reason: "401 + Bearer without resource_metadata or JSON-RPC body", + reason: + "401 + Bearer without resource_metadata, JSON-RPC body, or OAuth error body", } as const; } return { kind: "mcp", requiresAuth: true } as const; From c527731f7b5ebd0562f83c01764f73a1ba431f48 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 9 May 2026 01:23:22 -0700 Subject: [PATCH 3/4] feat(mcp): cover 29 real MCP servers in live snapshot suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the live-fetch snapshot file from 11 to 29 servers, adding Asana, Canva, Cloudflare bindings/observability/radar, Figma, GitHub Copilot, Intercom, Neon, Netlify, PayPal, Replicate, Square, Supabase, Tavily, Vercel, Webflow, Wix. Several of those (Supabase, GitHub Copilot, Vercel, Neon, Tavily, Replicate) carry RFC 6750 §3.1 `error=` / `error_description=` auth-params on their Bearer challenge but no `resource_metadata=`, and their bodies aren't always RFC 6750 either (Supabase returns `{"message":"Unauthorized"}`). Add `error=` in the Bearer challenge as another accept signal so wire-shape detection classifies them as mcp+auth without needing the URL-token fallback. --- ...probe-shape-real-servers.live.test.ts.snap | 274 ++++++++++++++++++ .../sdk/probe-shape-real-servers.live.test.ts | 39 ++- .../plugins/mcp/src/sdk/probe-shape.test.ts | 25 ++ packages/plugins/mcp/src/sdk/probe-shape.ts | 11 + 4 files changed, 344 insertions(+), 5 deletions(-) diff --git a/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap b/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap index e5a794465..7b9ec0356 100644 --- a/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap +++ b/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap @@ -1,5 +1,20 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`probeMcpEndpointShape against live MCP servers > asana (https://mcp.asana.com/sse) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", resource_metadata="https://mcp.asana.com/.well-known/oauth-protected-resource", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + exports[`probeMcpEndpointShape against live MCP servers > atlassian (https://mcp.atlassian.com/v1/sse) 1`] = ` { "probe": { @@ -15,6 +30,66 @@ exports[`probeMcpEndpointShape against live MCP servers > atlassian (https://mcp } `; +exports[`probeMcpEndpointShape against live MCP servers > canva (https://mcp.canva.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > cloudflare-bindings (https://bindings.mcp.cloudflare.com/sse) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", resource_metadata="https://bindings.mcp.cloudflare.com/.well-known/oauth-protected-resource/sse", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > cloudflare-observability (https://observability.mcp.cloudflare.com/sse) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", resource_metadata="https://observability.mcp.cloudflare.com/.well-known/oauth-protected-resource/sse", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > cloudflare-radar (https://radar.mcp.cloudflare.com/sse) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", resource_metadata="https://radar.mcp.cloudflare.com/.well-known/oauth-protected-resource/sse", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + exports[`probeMcpEndpointShape against live MCP servers > context7 (https://mcp.context7.com/mcp) 1`] = ` { "probe": { @@ -62,6 +137,37 @@ data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabili } `; +exports[`probeMcpEndpointShape against live MCP servers > figma (https://mcp.figma.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "Unauthorized", + "contentType": "text/plain", + "status": 401, + "wwwAuthenticate": "Bearer resource_metadata="https://mcp.figma.com/.well-known/oauth-protected-resource",scope="mcp:connect",authorization_uri="https://api.figma.com/.well-known/oauth-authorization-server"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > github-copilot (https://api.githubcopilot.com/mcp/) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "bad request: missing required Authorization header +", + "contentType": "text/plain; charset=utf-8", + "status": 401, + "wwwAuthenticate": "Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp/"", + }, +} +`; + exports[`probeMcpEndpointShape against live MCP servers > huggingface (https://huggingface.co/mcp) 1`] = ` { "probe": { @@ -77,6 +183,21 @@ exports[`probeMcpEndpointShape against live MCP servers > huggingface (https://h } `; +exports[`probeMcpEndpointShape against live MCP servers > intercom (https://mcp.intercom.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + exports[`probeMcpEndpointShape against live MCP servers > linear (https://mcp.linear.app/sse) 1`] = ` { "probe": { @@ -92,6 +213,39 @@ exports[`probeMcpEndpointShape against live MCP servers > linear (https://mcp.li } `; +exports[`probeMcpEndpointShape against live MCP servers > neon (https://mcp.neon.tech/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"No authorization provided"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer error="invalid_token", error_description="No authorization provided", resource_metadata="https://mcp.neon.tech/.well-known/oauth-protected-resource/mcp"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > netlify (https://netlify-mcp.netlify.app/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{ + "error": "unauthenticated", + "error_description": "You must authenticate to use this tool" + }", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="MCP Server", resource_metadata="https://netlify-mcp.netlify.app/.well-known/oauth-protected-resource"", + }, +} +`; + exports[`probeMcpEndpointShape against live MCP servers > notion (https://mcp.notion.com/mcp) 1`] = ` { "probe": { @@ -107,6 +261,21 @@ exports[`probeMcpEndpointShape against live MCP servers > notion (https://mcp.no } `; +exports[`probeMcpEndpointShape against live MCP servers > paypal (https://mcp.paypal.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", resource_metadata="https://mcp.paypal.com/.well-known/oauth-protected-resource", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + exports[`probeMcpEndpointShape against live MCP servers > reftools (https://api.ref.tools/mcp) 1`] = ` { "probe": { @@ -123,6 +292,21 @@ exports[`probeMcpEndpointShape against live MCP servers > reftools (https://api. } `; +exports[`probeMcpEndpointShape against live MCP servers > replicate (https://mcp.replicate.com/sse) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + exports[`probeMcpEndpointShape against live MCP servers > sentry (https://mcp.sentry.dev/mcp/) 1`] = ` { "probe": { @@ -138,6 +322,21 @@ exports[`probeMcpEndpointShape against live MCP servers > sentry (https://mcp.se } `; +exports[`probeMcpEndpointShape against live MCP servers > square (https://mcp.squareup.com/sse) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + exports[`probeMcpEndpointShape against live MCP servers > stripe (https://mcp.stripe.com) 1`] = ` { "probe": { @@ -153,6 +352,81 @@ exports[`probeMcpEndpointShape against live MCP servers > stripe (https://mcp.st } `; +exports[`probeMcpEndpointShape against live MCP servers > supabase (https://mcp.supabase.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"message":"Unauthorized"}", + "contentType": "application/json; charset=utf-8", + "status": 401, + "wwwAuthenticate": "Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://mcp.supabase.com/.well-known/oauth-protected-resource/mcp"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > tavily (https://mcp.tavily.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error": "invalid_token", "error_description": "Authentication failed. The provided bearer token is invalid, expired, or no longer recognized by the server. To resolve: clear authentication tokens in your MCP client and reconnect. Your client should automatically re-register and obtain new tokens."}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer error="invalid_token", error_description="Authentication failed. The provided bearer token is invalid, expired, or no longer recognized by the server. To resolve: clear authentication tokens in your MCP client and reconnect. Your client should automatically re-register and obtain new tokens.", resource_metadata="https://mcp.tavily.com/.well-known/oauth-protected-resource/mcp"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > vercel (https://mcp.vercel.com/) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"No authorization provided"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer error="invalid_token", error_description="No authorization provided", resource_metadata="https://mcp.vercel.com/.well-known/oauth-protected-resource"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > webflow (https://mcp.webflow.com/sse) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > wix (https://mcp.wix.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "raw": { + "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", + "contentType": "application/json", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + exports[`probeMcpEndpointShape against live MCP servers > zapier (https://mcp.zapier.com/api/mcp/mcp) 1`] = ` { "probe": { diff --git a/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts b/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts index 65e844f87..2a813b61e 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts @@ -38,17 +38,46 @@ const MCP_INITIALIZE_BODY = JSON.stringify({ }); const liveServers: ReadonlyArray<{ readonly name: string; readonly url: string }> = [ - { name: "sentry", url: "https://mcp.sentry.dev/mcp/" }, - { name: "stripe", url: "https://mcp.stripe.com" }, + // OAuth-protected SaaS MCP servers — most are spec-compliant with + // `resource_metadata=` in WWW-Authenticate; a few (Atlassian, Zapier, + // Vercel, Neon, Supabase) only carry RFC 6750 `error=` auth-params. + { name: "asana", url: "https://mcp.asana.com/sse" }, + { name: "atlassian", url: "https://mcp.atlassian.com/v1/sse" }, + { name: "canva", url: "https://mcp.canva.com/mcp" }, + { name: "cloudflare-bindings", url: "https://bindings.mcp.cloudflare.com/sse" }, + { name: "cloudflare-observability", url: "https://observability.mcp.cloudflare.com/sse" }, + { name: "cloudflare-radar", url: "https://radar.mcp.cloudflare.com/sse" }, + { name: "figma", url: "https://mcp.figma.com/mcp" }, + { name: "github-copilot", url: "https://api.githubcopilot.com/mcp/" }, + { name: "intercom", url: "https://mcp.intercom.com/mcp" }, { name: "linear", url: "https://mcp.linear.app/sse" }, + { name: "neon", url: "https://mcp.neon.tech/mcp" }, + { name: "netlify", url: "https://netlify-mcp.netlify.app/mcp" }, { name: "notion", url: "https://mcp.notion.com/mcp" }, - { name: "atlassian", url: "https://mcp.atlassian.com/v1/sse" }, + { name: "paypal", url: "https://mcp.paypal.com/mcp" }, + { name: "replicate", url: "https://mcp.replicate.com/sse" }, + { name: "sentry", url: "https://mcp.sentry.dev/mcp/" }, + { name: "square", url: "https://mcp.squareup.com/sse" }, + { name: "stripe", url: "https://mcp.stripe.com" }, + { name: "supabase", url: "https://mcp.supabase.com/mcp" }, + { name: "tavily", url: "https://mcp.tavily.com/mcp" }, + { name: "vercel", url: "https://mcp.vercel.com/" }, + { name: "webflow", url: "https://mcp.webflow.com/sse" }, + { name: "wix", url: "https://mcp.wix.com/mcp" }, { name: "zapier", url: "https://mcp.zapier.com/api/mcp/mcp" }, + + // API-key-authenticated MCP servers (no OAuth). Cubic returns + // JSON-RPC error envelopes; ref.tools omits WWW-Authenticate on the + // 401 entirely so wire-shape detection rejects it (the URL-token + // detect fallback still surfaces it as low-confidence). { name: "cubic", url: "https://www.cubic.dev/api/mcp" }, { name: "reftools", url: "https://api.ref.tools/mcp" }, - { name: "huggingface", url: "https://huggingface.co/mcp" }, - { name: "deepwiki", url: "https://mcp.deepwiki.com/mcp" }, + + // Public, unauthenticated MCP servers — should classify as `mcp` + // with no required auth. { name: "context7", url: "https://mcp.context7.com/mcp" }, + { name: "deepwiki", url: "https://mcp.deepwiki.com/mcp" }, + { name: "huggingface", url: "https://huggingface.co/mcp" }, ]; interface RawCapture { diff --git a/packages/plugins/mcp/src/sdk/probe-shape.test.ts b/packages/plugins/mcp/src/sdk/probe-shape.test.ts index 1a4d3684b..5a2fb4945 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.test.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.test.ts @@ -124,6 +124,31 @@ describe("probeMcpEndpointShape", () => { ), ); + // Supabase shape: Bearer challenge has `error=`/`error_description=` + // auth-params (RFC 6750 §3.1) but no `resource_metadata=`, and body is + // a non-RFC-6750 `{"message":"Unauthorized"}` envelope. The `error=` + // attribute alone is the accept signal. + it.effect("classifies 401 with Bearer error= auth-param as MCP+auth", () => + withServer( + () => + HttpServerResponse.jsonUnsafe( + { message: "Unauthorized" }, + { + status: 401, + headers: { + "www-authenticate": + 'Bearer error="invalid_request", error_description="No authorization header found"', + }, + }, + ), + (endpoint) => + Effect.gen(function* () { + const result = yield* probeMcpEndpointShape(endpoint); + expect(result).toEqual({ kind: "mcp", requiresAuth: true }); + }), + ), + ); + // cubic.dev/api/mcp shape: bare `Bearer` challenge, no resource_metadata. // The JSON-RPC error body is what tells us this is MCP rather than some // other OAuth/API-key protected service. diff --git a/packages/plugins/mcp/src/sdk/probe-shape.ts b/packages/plugins/mcp/src/sdk/probe-shape.ts index 725fc7a58..9a0ab5c7b 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.ts @@ -219,6 +219,17 @@ export const probeMcpEndpointShape = ( if (/(?:^|[\s,])resource_metadata\s*=/i.test(wwwAuth)) { return { kind: "mcp", requiresAuth: true } as const; } + // Looser RFC 6750 §3.1 signal: the Bearer challenge carries + // `error=` / `error_description=` auth-params. Real MCP + // servers (Supabase, GitHub Copilot, Vercel, Neon, Tavily, + // Replicate, ...) include this even when they omit + // `resource_metadata=`. The body alone isn't enough for + // those — Supabase, e.g., returns `{"message":"Unauthorized"}` + // which is neither JSON-RPC nor RFC 6750. The `error=` + // auth-param is the tiebreaker. + if (/(?:^|[\s,])error\s*=/i.test(wwwAuth)) { + return { kind: "mcp", requiresAuth: true } as const; + } // SSE responses can't carry a JSON-RPC error envelope; accept the // Bearer challenge alone in that case (rare but spec-permissible). if (isSse) return { kind: "mcp", requiresAuth: true } as const; From fcffb2c3c7b5be09967c41c6a6b5d579c494c1c5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 9 May 2026 01:27:24 -0700 Subject: [PATCH 4/4] test(mcp): also snapshot probeEndpoint result for OAuth-vs-credentials coverage The wire-shape probe (`probeMcpEndpointShape`) only tells us "this is an MCP server" / "auth required". The UI flow in `AddMcpSource` then keys off `probeEndpoint`'s `requiresOAuth` and `supportsDynamicRegistration` to decide whether to show the OAuth popup. Capture those in the live snapshot too so a regression that breaks OAuth detection (vs. falling through to the credentials editor) shows up as a snapshot diff. The 24 OAuth servers now lock in `requiresOAuth: true` / `supportsDynamicRegistration: true` (GitHub Copilot is the lone DCR holdout); the API-key MCPs (Cubic, ref.tools) lock in the auth-required error message; the public MCPs (Hugging Face, DeepWiki, Context7) lock in `connected: true, requiresOAuth: false` with a tool count. --- ...probe-shape-real-servers.live.test.ts.snap | 197 ++++++++++++++++++ .../sdk/probe-shape-real-servers.live.test.ts | 66 +++++- 2 files changed, 260 insertions(+), 3 deletions(-) diff --git a/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap b/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap index 7b9ec0356..ba4766cbd 100644 --- a/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap +++ b/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap @@ -6,6 +6,13 @@ exports[`probeMcpEndpointShape against live MCP servers > asana (https://mcp.asa "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -21,6 +28,13 @@ exports[`probeMcpEndpointShape against live MCP servers > atlassian (https://mcp "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -36,6 +50,13 @@ exports[`probeMcpEndpointShape against live MCP servers > canva (https://mcp.can "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -51,6 +72,13 @@ exports[`probeMcpEndpointShape against live MCP servers > cloudflare-bindings (h "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -66,6 +94,13 @@ exports[`probeMcpEndpointShape against live MCP servers > cloudflare-observabili "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -81,6 +116,13 @@ exports[`probeMcpEndpointShape against live MCP servers > cloudflare-radar (http "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -96,6 +138,13 @@ exports[`probeMcpEndpointShape against live MCP servers > context7 (https://mcp. "kind": "mcp", "requiresAuth": false, }, + "probeEndpoint": { + "connected": true, + "hasToolCount": true, + "ok": true, + "requiresOAuth": false, + "supportsDynamicRegistration": false, + }, "raw": { "bodySnippet": "event: message data: {"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"Context7","version":"2.2.4","websiteUrl":"https://context7.com","description":"Context7 provides up-to-date documentation and code examples for libraries and frameworks.","icons":[{"src":"https://context7.com/context7-icon-green.png","mimeType":"image/png"}]},"instructions":"Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs.\\n\\nDo not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general pr", @@ -112,6 +161,10 @@ exports[`probeMcpEndpointShape against live MCP servers > cubic (https://www.cub "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "message": "This server requires authentication, but OAuth metadata wasn't found. Add credentials (Authorization header, query parameter, or API key) below and retry.", + "ok": false, + }, "raw": { "bodySnippet": "{"jsonrpc":"2.0","error":{"code":-32000,"message":"Unauthorized: Valid API key required. Generate one at Settings -> Integrations -> MCP."},"id":null}", "contentType": "application/json", @@ -127,6 +180,13 @@ exports[`probeMcpEndpointShape against live MCP servers > deepwiki (https://mcp. "kind": "mcp", "requiresAuth": false, }, + "probeEndpoint": { + "connected": true, + "hasToolCount": true, + "ok": true, + "requiresOAuth": false, + "supportsDynamicRegistration": false, + }, "raw": { "bodySnippet": "event: message data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"experimental":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"DeepWiki","version":"2.14.3"},"instructions":"DeepWiki MCP provides AI-powered documentation for GitHub repositories.\\n\\nAvailable tools:\\n- read_wiki_structure: Get a list of documentation topics for a repository\\n- read_wiki_contents: View full documentation about a repository\\n- ask_question: Ask any question about a repository and get an AI-powered answer\\n- list_available_repos: List your available repositories (private mode only)\\n- generate_wiki: Generate a codebase wiki for a repository — only use when explicitly requested by the user (private mode only)\\n- devin_knowledge_manage: Manage Devin knowledge notes and suggestions — list, search, get, create, update, delete notes, view folder structure, list/view/dismiss knowledge suggestions (private mode ", @@ -143,6 +203,13 @@ exports[`probeMcpEndpointShape against live MCP servers > figma (https://mcp.fig "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "Unauthorized", "contentType": "text/plain", @@ -158,6 +225,13 @@ exports[`probeMcpEndpointShape against live MCP servers > github-copilot (https: "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": false, + }, "raw": { "bodySnippet": "bad request: missing required Authorization header ", @@ -174,6 +248,13 @@ exports[`probeMcpEndpointShape against live MCP servers > huggingface (https://h "kind": "mcp", "requiresAuth": false, }, + "probeEndpoint": { + "connected": true, + "hasToolCount": true, + "ok": true, + "requiresOAuth": false, + "supportsDynamicRegistration": false, + }, "raw": { "bodySnippet": "{"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":false},"prompts":{"listChanged":false}},"serverInfo":{"name":"@huggingface/mcp-services","version":"0.3.12","title":"Hugging Face","websiteUrl":"https://huggingface.co/mcp","icons":[{"src":"https://huggingface.co/favicon.ico"}]},"instructions":"You have tools for using the Hugging Face Hub. arXiv paper id's are often used as references between datasets, models and papers. There are over 100 tags in use, common tags include 'Text Generation', 'Transformers', 'Image Classification' and so on.\\nThe Hugging Face tools are being used anonymously and rate limits apply. Direct the User to set their HF_TOKEN (instructions at https://hf.co/settings/mcp/), or create an account at https://hf.co/join for higher limits."},"jsonrpc":"2.0","id":1}", "contentType": "application/json", @@ -189,6 +270,13 @@ exports[`probeMcpEndpointShape against live MCP servers > intercom (https://mcp. "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -204,6 +292,13 @@ exports[`probeMcpEndpointShape against live MCP servers > linear (https://mcp.li "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -219,6 +314,13 @@ exports[`probeMcpEndpointShape against live MCP servers > neon (https://mcp.neon "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"No authorization provided"}", "contentType": "application/json", @@ -234,6 +336,13 @@ exports[`probeMcpEndpointShape against live MCP servers > netlify (https://netli "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{ "error": "unauthenticated", @@ -252,6 +361,13 @@ exports[`probeMcpEndpointShape against live MCP servers > notion (https://mcp.no "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -267,6 +383,13 @@ exports[`probeMcpEndpointShape against live MCP servers > paypal (https://mcp.pa "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -283,6 +406,10 @@ exports[`probeMcpEndpointShape against live MCP servers > reftools (https://api. "kind": "not-mcp", "reason": "401 without Bearer WWW-Authenticate — not an MCP auth challenge", }, + "probeEndpoint": { + "message": "This server requires authentication. Add credentials (Authorization header, query parameter, or API key) below and retry.", + "ok": false, + }, "raw": { "bodySnippet": "{"error":"Unauthorized - Authentication required"}", "contentType": "application/json; charset=utf-8", @@ -298,6 +425,13 @@ exports[`probeMcpEndpointShape against live MCP servers > replicate (https://mcp "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -313,6 +447,13 @@ exports[`probeMcpEndpointShape against live MCP servers > sentry (https://mcp.se "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -328,6 +469,13 @@ exports[`probeMcpEndpointShape against live MCP servers > square (https://mcp.sq "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -343,6 +491,13 @@ exports[`probeMcpEndpointShape against live MCP servers > stripe (https://mcp.st "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"Unauthorized. See https://docs.stripe.com/mcp for usage instructions."}", "contentType": "application/json", @@ -358,6 +513,13 @@ exports[`probeMcpEndpointShape against live MCP servers > supabase (https://mcp. "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"message":"Unauthorized"}", "contentType": "application/json; charset=utf-8", @@ -373,6 +535,13 @@ exports[`probeMcpEndpointShape against live MCP servers > tavily (https://mcp.ta "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error": "invalid_token", "error_description": "Authentication failed. The provided bearer token is invalid, expired, or no longer recognized by the server. To resolve: clear authentication tokens in your MCP client and reconnect. Your client should automatically re-register and obtain new tokens."}", "contentType": "application/json", @@ -388,6 +557,13 @@ exports[`probeMcpEndpointShape against live MCP servers > vercel (https://mcp.ve "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"No authorization provided"}", "contentType": "application/json", @@ -403,6 +579,13 @@ exports[`probeMcpEndpointShape against live MCP servers > webflow (https://mcp.w "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -418,6 +601,13 @@ exports[`probeMcpEndpointShape against live MCP servers > wix (https://mcp.wix.c "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"error":"invalid_token","error_description":"Missing or invalid access token"}", "contentType": "application/json", @@ -433,6 +623,13 @@ exports[`probeMcpEndpointShape against live MCP servers > zapier (https://mcp.za "kind": "mcp", "requiresAuth": true, }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": true, + }, "raw": { "bodySnippet": "{"jsonrpc":"2.0","id":null,"error":{"code":-31996,"message":"Expected Bearer token for MCP authentication"}}", "contentType": "application/json; charset=utf-8", diff --git a/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts b/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts index 2a813b61e..fcc512eec 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts @@ -21,9 +21,12 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Duration, Effect, Stream } from "effect"; +import { Duration, Effect, Option, Schema, Stream } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { createExecutor, makeTestConfig } from "@executor-js/sdk"; + +import { mcpPlugin } from "./plugin"; import { probeMcpEndpointShape } from "./probe-shape"; const MCP_INITIALIZE_BODY = JSON.stringify({ @@ -144,6 +147,62 @@ const captureLive = (url: string): Effect.Effect => const live = process.env.MCP_PROBE_LIVE === "1"; +// Run the full probe path (the function the React UI calls). The result +// surfaces `requiresOAuth` / `supportsDynamicRegistration`, which is what +// drives the OAuth popup vs. credentials-editor branches in +// AddMcpSource. For OAuth-protected servers we want this to be `true`; +// for API-key MCPs (Cubic) it should fail with the auth-required +// message; for unauth public servers (Hugging Face, etc.) it should +// succeed with `requiresOAuth: false` and a tool count. +type EndpointProbeOutcome = + | { + readonly ok: true; + readonly connected: boolean; + readonly requiresOAuth: boolean; + readonly supportsDynamicRegistration: boolean; + readonly hasToolCount: boolean; + } + | { readonly ok: false; readonly message: string }; + +const ErrorMessage = Schema.Struct({ message: Schema.String }); +const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); + +const messageFromUnknown = (cause: unknown): string => + Option.match(decodeErrorMessage(cause), { + onNone: () => "(non-string error)", + onSome: ({ message }) => message, + }); + +const runEndpointProbe = (url: string): Effect.Effect => + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + return yield* executor.mcp.probeEndpoint(url).pipe( + Effect.map( + (r) => + ({ + ok: true as const, + connected: r.connected, + requiresOAuth: r.requiresOAuth, + supportsDynamicRegistration: r.supportsDynamicRegistration, + // Tool counts vary across runs (servers add/remove tools). + // Snapshot only whether we got a count, not the exact value. + hasToolCount: typeof r.toolCount === "number", + }) satisfies EndpointProbeOutcome, + ), + Effect.catch((cause) => + Effect.succeed({ ok: false as const, message: messageFromUnknown(cause) }), + ), + Effect.timeout(Duration.seconds(20)), + Effect.catch(() => + Effect.succeed({ ok: false as const, message: "(probeEndpoint timeout)" }), + ), + ); + }).pipe( + Effect.catch((cause) => + Effect.succeed({ ok: false as const, message: messageFromUnknown(cause) }), + ), + ); + describe.skipIf(!live)("probeMcpEndpointShape against live MCP servers", () => { for (const server of liveServers) { it.effect( @@ -154,9 +213,10 @@ describe.skipIf(!live)("probeMcpEndpointShape against live MCP servers", () => { const probe = yield* probeMcpEndpointShape(server.url, { timeoutMs: Duration.toMillis(REQUEST_TIMEOUT), }); - expect({ raw, probe }).toMatchSnapshot(); + const probeEndpoint = yield* runEndpointProbe(server.url); + expect({ raw, probe, probeEndpoint }).toMatchSnapshot(); }), - { timeout: Duration.toMillis(REQUEST_TIMEOUT) * 3 }, + { timeout: Duration.toMillis(REQUEST_TIMEOUT) * 6 }, ); } });