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..ba4766cbd --- /dev/null +++ b/packages/plugins/mcp/src/sdk/__snapshots__/probe-shape-real-servers.live.test.ts.snap @@ -0,0 +1,640 @@ +// 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, + }, + "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", + "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": { + "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", + "status": 401, + "wwwAuthenticate": "Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > canva (https://mcp.canva.com/mcp) 1`] = ` +{ + "probe": { + "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", + "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, + }, + "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", + "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, + }, + "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", + "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, + }, + "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", + "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": { + "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", + "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, + }, + "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", + "status": 401, + "wwwAuthenticate": "Bearer", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > deepwiki (https://mcp.deepwiki.com/mcp) 1`] = ` +{ + "probe": { + "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 ", + "contentType": "text/event-stream", + "status": 200, + "wwwAuthenticate": null, + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > figma (https://mcp.figma.com/mcp) 1`] = ` +{ + "probe": { + "kind": "mcp", + "requiresAuth": true, + }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": 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, + }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": false, + }, + "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": { + "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", + "status": 200, + "wwwAuthenticate": null, + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > intercom (https://mcp.intercom.com/mcp) 1`] = ` +{ + "probe": { + "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", + "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": { + "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", + "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 > neon (https://mcp.neon.tech/mcp) 1`] = ` +{ + "probe": { + "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", + "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, + }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": 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": { + "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", + "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 > paypal (https://mcp.paypal.com/mcp) 1`] = ` +{ + "probe": { + "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", + "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": { + "category": "auth-required", + "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", + "status": 401, + "wwwAuthenticate": null, + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > replicate (https://mcp.replicate.com/sse) 1`] = ` +{ + "probe": { + "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", + "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": { + "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", + "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 > square (https://mcp.squareup.com/sse) 1`] = ` +{ + "probe": { + "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", + "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": { + "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", + "status": 401, + "wwwAuthenticate": "Bearer resource_metadata=https://mcp.stripe.com/.well-known/oauth-protected-resource", + }, +} +`; + +exports[`probeMcpEndpointShape against live MCP servers > supabase (https://mcp.supabase.com/mcp) 1`] = ` +{ + "probe": { + "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", + "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, + }, + "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", + "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, + }, + "probeEndpoint": { + "connected": false, + "hasToolCount": false, + "ok": true, + "requiresOAuth": true, + "supportsDynamicRegistration": 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, + }, + "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", + "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, + }, + "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", + "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": { + "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", + "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..fcc512eec --- /dev/null +++ b/packages/plugins/mcp/src/sdk/probe-shape-real-servers.live.test.ts @@ -0,0 +1,222 @@ +// --------------------------------------------------------------------------- +// 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, 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({ + 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 }> = [ + // 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: "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" }, + + // 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 { + 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"; + +// 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( + `${server.name} (${server.url})`, + () => + Effect.gen(function* () { + const raw = yield* captureLive(server.url); + const probe = yield* probeMcpEndpointShape(server.url, { + timeoutMs: Duration.toMillis(REQUEST_TIMEOUT), + }); + const probeEndpoint = yield* runEndpointProbe(server.url); + expect({ raw, probe, probeEndpoint }).toMatchSnapshot(); + }), + { timeout: Duration.toMillis(REQUEST_TIMEOUT) * 6 }, + ); + } +}); diff --git a/packages/plugins/mcp/src/sdk/probe-shape.test.ts b/packages/plugins/mcp/src/sdk/probe-shape.test.ts index fd4d9abd4..5a2fb4945 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.test.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.test.ts @@ -95,6 +95,60 @@ 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 }); + }), + ), + ); + + // 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 51888cd84..9a0ab5c7b 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); @@ -191,15 +209,47 @@ 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; + } + // 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; + // 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 but body is not a JSON-RPC envelope", + reason: + "401 + Bearer without resource_metadata, JSON-RPC body, or OAuth error body", } as const; } return { kind: "mcp", requiresAuth: true } as const;