diff --git a/README.md b/README.md index ad664c8..59b56aa 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,19 @@ See [LICENSE](LICENSE) for the full license text. ## Features ### Compliance Testing -- **MCP Compliance** — validate MCP server implementations against the official specification. The scanner negotiates the latest revision (`2025-06-18`) and flags servers that downgrade to **deprecated revisions** (`2024-11-05`, `2025-03-26`) that predate the OAuth Resource Server model and Resource Indicators (RFC 8707). Checks protocol version, capabilities, required methods, and echoes the `MCP-Protocol-Version` header on subsequent requests. +- **MCP Compliance** — validate MCP server implementations against the official specification, currently [`2026-07-28`](https://modelcontextprotocol.io/specification/2026-07-28). That revision turned MCP into a **stateless request/response protocol**, so the scanner is dual-era: it probes `server/discover` at the current revision first and falls back to the `initialize` handshake only for servers on `2025-11-25` and earlier, then grades each server against the rules for its own era. + + | Era | What is checked | + |-----|-----------------| + | Stateless (`2026-07-28`) | `server/discover` is implemented (a MUST) and advertises `supportedVersions`; server identity in each result's `_meta`; required `resultType` on results; `ttlMs` + `cacheScope` cache hints on `tools/list`; server-side validation of the mirrored `Mcp-Method` / `Mcp-Name` headers (expects `400` + `HeaderMismatch` `-32020`); statelessness (no `Mcp-Session-Id` minted, `GET` not served); deprecated Roots / Sampling / Logging capabilities | + | Handshake (`≤ 2025-11-25`) | `initialize` and `notifications/initialized`, `ping`, capabilities, server info — plus a warning that the revision is superseded, and whether the server also answers `server/discover` (dual-era) | + + Both eras are checked for **deprecated revisions** (`2024-11-05`, `2025-03-26`) that predate the OAuth Resource Server model and Resource Indicators (RFC 8707). - **A2A Compliance** — verify A2A agent cards against the current spec (`0.3.0`). Resolves the canonical `/.well-known/agent-card.json` (with legacy `/.well-known/agent.json` fallback) and validates required fields, `protocolVersion`, skills, input/output modes, capabilities, **transport declarations** (`preferredTransport`/`additionalInterfaces`: JSONRPC, GRPC, HTTP+JSON), modern **`securitySchemes`/`security`** (with legacy `authentication` fallback), and **JWS card signatures** (`AgentCardSignature`). - **UCP Compliance** — 18 compliance rules validating UCP business profiles against the [published specification](https://ucp.dev/latest/specification/overview) (profile structure, services, capabilities, transport bindings, signing keys, vendor namespaces) ### Interactive Testing -- **MCP Tool Explorer** — list all available tools on an MCP server and call any tool with custom arguments +- **MCP Tool Explorer** — list all available tools on an MCP server and call any tool with custom arguments. Calls are made in the server's own era: against a stateless server the explorer mirrors `Mcp-Method`, `Mcp-Name` and any `x-mcp-header`-annotated parameters into HTTP headers, rejects tools whose annotations violate the spec, and surfaces a multi round-trip `input_required` result (with its `inputRequests`) instead of treating it as tool output - **A2A Skill Browser** — fetch agent cards, browse declared skills with tags/examples, and send tasks to agents ### Security Scanning @@ -42,8 +49,14 @@ See [LICENSE](LICENSE) for the full license text. | Check | What is evaluated | |-------|-------------------| - | Protocol version | Negotiated revision is current; deprecated revisions (no Resource Indicators / OAuth Resource Server model) are flagged | + | Protocol version | Negotiated revision is current; deprecated revisions (no Resource Indicators / OAuth Resource Server model) are flagged, as is a superseded handshake-era revision and a stateless server that does not answer `server/discover` | | OAuth 2.1 metadata | Probes `/.well-known/oauth-protected-resource` (RFC 9728) and inspects the `WWW-Authenticate` challenge for a `resource_metadata` pointer; flags openly-accessible servers | + | Authorization server | Fetches the authorization server's metadata and flags missing **RFC 9207** issuer identification (`authorization_response_iss_parameter_supported`, the mix-up defence) and reliance on **deprecated Dynamic Client Registration** where **Client ID Metadata Documents** (`client_id_metadata_document_supported`) are not offered (2026-07-28) | + | Statelessness | Servers that still mint an `Mcp-Session-Id`, or still serve a standalone SSE stream on `GET`, after protocol-level sessions and the GET endpoint were removed (2026-07-28) | + | Deprecated features | Roots, Sampling and Logging capabilities, Deprecated as of 2026-07-28 with a twelve-month removal window | + | Header mirroring | `x-mcp-header` annotations that violate the spec's constraints (empty, non-token, CR/LF, duplicate, non-primitive, not statically reachable — such tools MUST be rejected by clients), and parameters mirrored into credential-shaped headers (`Authorization`, `Cookie`, API keys, forwarding headers) where the value becomes visible to every proxy on the path (2026-07-28) | + | Cache hints | `cacheScope: "public"` on an authenticated server — which lets a shared gateway serve one caller's response to another — and missing/invalid `ttlMs` + `cacheScope` on cacheable results (2026-07-28) | + | Schema supply chain | Network (`http(s)://`) `$ref` targets in tool `inputSchema`/`outputSchema`, which implementations MUST NOT dereference automatically (2026-07-28) | | Tool annotations | State-changing tools missing `ToolAnnotations`, and misleading `readOnlyHint` on mutating tools (2025-06-18+) | | Resources & prompts | `resources/list` and `prompts/list` are scanned for `file://` exposure, sensitive locations, and hidden-instruction / intent-subversion poisoning | | Lethal trifecta | Servers that co-locate untrusted-input ingestion, private-data access, and outbound communication (prompt-injection exfiltration risk) | diff --git a/apps/web/src/app/api/mcp/call-tool/route.ts b/apps/web/src/app/api/mcp/call-tool/route.ts index 6fdead0..18519d8 100644 --- a/apps/web/src/app/api/mcp/call-tool/route.ts +++ b/apps/web/src/app/api/mcp/call-tool/route.ts @@ -1,61 +1,125 @@ import { NextRequest, NextResponse } from 'next/server'; +import { + MCP_LATEST_VERSION, + buildBaseHeaders, + buildParamHeaders, + collectXMcpHeaderBindings, + connectMCP, + isInputRequired, + legacyCall, + modernCall, + modernHeaders, + resultTypeOf, + withRequestMeta, + type Json, +} from '@/lib/mcp'; /** - * Call a specific tool on an MCP server via JSON-RPC tools/call method + * Call a tool on an MCP server. + * + * On the stateless transport a conforming client mirrors `Mcp-Method`, + * `Mcp-Name` and any `x-mcp-header`-annotated parameters into HTTP headers, so + * the tool's `inputSchema` is fetched first to find those annotations. Tools + * with invalid annotations are rejected rather than called, as the spec + * requires. A server may also answer with a multi round-trip `input_required` + * result instead of content; that is surfaced rather than treated as output. */ export async function POST(request: NextRequest) { try { - const { serverUrl, authType, authValue, authHeader, customHeaders, toolName, arguments: toolArgs } = await request.json(); + const { + serverUrl, + authType, + authValue, + authHeader, + customHeaders, + toolName, + arguments: toolArgs, + } = await request.json(); if (!serverUrl || !toolName) { return NextResponse.json({ error: 'Server URL and tool name required' }, { status: 400 }); } - const headers: Record = { - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', - }; + const baseHeaders = buildBaseHeaders({ authType, authValue, authHeader, customHeaders }); + const conn = await connectMCP(serverUrl, baseHeaders); - if (authType && authType !== 'none' && authValue) { - if (authType === 'api_key') { - headers[authHeader || 'Authorization'] = authValue; - } else if (authType === 'bearer') { - headers[authHeader || 'Authorization'] = `Bearer ${authValue}`; - } else if (authType === 'basic') { - headers[authHeader || 'Authorization'] = `Basic ${Buffer.from(authValue).toString('base64')}`; - } + if (conn.era === 'unknown') { + return NextResponse.json( + { error: conn.error || 'Could not connect to the MCP server' }, + { status: 502 } + ); } - // Apply custom headers - if (Array.isArray(customHeaders)) { - for (const h of customHeaders) { - if (h.key && h.value) headers[h.key] = h.value; + const args = toolArgs || {}; + const params = { name: toolName, arguments: args }; + + let call; + let mirroredHeaders: string[] = []; + + if (conn.era === 'modern') { + const version = conn.protocolVersion || MCP_LATEST_VERSION; + const schema = await fetchToolSchema(serverUrl, baseHeaders, conn, toolName); + const bindings = collectXMcpHeaderBindings(schema); + const invalid = bindings.filter((b) => b.violations.length > 0); + + if (invalid.length > 0) { + return NextResponse.json( + { + error: `Tool "${toolName}" has invalid x-mcp-header annotations and must be rejected by conforming clients.`, + violations: invalid.map((b) => ({ + property: b.path.join('.'), + header: b.header, + reasons: b.violations, + })), + }, + { status: 422 } + ); } + + const withMeta = withRequestMeta(params, version); + const paramHeaders = buildParamHeaders(bindings, args); + mirroredHeaders = Object.keys(paramHeaders); + const headers = { + ...modernHeaders(baseHeaders, version, 'tools/call', withMeta), + ...paramHeaders, + }; + call = await modernCall(serverUrl, baseHeaders, version, 'tools/call', params, headers); + } else { + const headers = { ...baseHeaders }; + if (conn.protocolVersion) headers['MCP-Protocol-Version'] = conn.protocolVersion; + if (conn.sessionId) headers['Mcp-Session-Id'] = conn.sessionId; + call = await legacyCall(serverUrl, headers, 'tools/call', params); } - // Call the tool - const response = await fetch(serverUrl, { - method: 'POST', - headers, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: toolName, - arguments: toolArgs || {}, - }, - }), - }); + if (call.transportError) { + return NextResponse.json({ error: call.transportError }, { status: 502 }); + } + + const result = call.result; - const json = await parseResponse(response); - const result = json?.result || json; + if (isInputRequired(result)) { + return NextResponse.json({ + toolName, + protocolEra: conn.era, + resultType: 'input_required', + inputRequests: result?.inputRequests || [], + requestState: result?.requestState ?? null, + result: null, + isError: false, + raw: call.raw, + note: 'The server needs more input before it can complete this call (multi round-trip request). Answer the inputRequests and retry the original call with inputResponses.', + }); + } return NextResponse.json({ toolName, + protocolEra: conn.era, + resultType: resultTypeOf(result) || null, + mirroredHeaders, result: result?.content || result, + structuredContent: result?.structuredContent ?? null, isError: result?.isError || false, - raw: json, + raw: call.raw, }); } catch (error) { return NextResponse.json( @@ -65,18 +129,15 @@ export async function POST(request: NextRequest) { } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function parseResponse(response: Response): Promise { - const contentType = response.headers.get('content-type') || ''; - - if (contentType.includes('text/event-stream')) { - const text = await response.text(); - const dataLine = text.split('\n').find(l => l.startsWith('data:')); - if (dataLine) { - return JSON.parse(dataLine.replace(/^data:\s*/, '')); - } - return { _rawSSE: text }; - } - - return response.json(); +/** Fetches the named tool's `inputSchema` so its header annotations can be honoured. */ +async function fetchToolSchema( + serverUrl: string, + baseHeaders: Record, + conn: { protocolVersion?: string }, + toolName: string +): Promise { + const version = conn.protocolVersion || MCP_LATEST_VERSION; + const listed = await modernCall(serverUrl, baseHeaders, version, 'tools/list'); + const tools: Json[] = listed.result?.tools || []; + return tools.find((t) => t?.name === toolName)?.inputSchema ?? null; } diff --git a/apps/web/src/app/api/mcp/security/route.ts b/apps/web/src/app/api/mcp/security/route.ts index 8142a8e..0ff5bd6 100644 --- a/apps/web/src/app/api/mcp/security/route.ts +++ b/apps/web/src/app/api/mcp/security/route.ts @@ -1,12 +1,32 @@ import { NextRequest, NextResponse } from 'next/server'; +import { + MCP_DEPRECATED_VERSIONS, + MCP_KNOWN_VERSIONS, + MCP_LATEST_VERSION, + MCP_LEGACY_VERSIONS, + MCP_SPEC_URLS, + buildBaseHeaders, + cacheHintsOf, + collectXMcpHeaderBindings, + connectMCP, + mcpCall, + parseMCPResponse, + resultTypeOf, + type Json, + type MCPConnection, +} from '@/lib/mcp'; /** * MCP Security Scanner — OWASP MCP Top 10 vulnerability analysis - * + * * Implements checks inspired by: * - OWASP MCP Top 10 (https://owasp.org/www-project-mcp-top-10/) * - mcp-shield patterns (https://github.com/riseandignite/mcp-shield) - * + * + * Version-aware: the scanner speaks MCP 2026-07-28 (stateless, per-request + * metadata) and falls back to the handshake era, then audits the + * security-relevant surface each revision introduced. + * * All analysis is stateless — nothing is stored. */ @@ -193,18 +213,25 @@ const ARG_INJECTION_PARAM_NAMES = [ // Indicators (RFC 8707) + Protected Resource Metadata (RFC 9728) // REQUIRED, dedicated security best-practices page, structured // tool output, tool annotations, elicitation -// 2025-11-25 — latest revision, further auth + transport hardening - -const MCP_LATEST_VERSION = '2025-11-25'; -const MCP_PREFERRED_VERSION = '2025-06-18'; - -const MCP_KNOWN_VERSIONS = ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']; - -// Versions that predate the hardened OAuth Resource Server / Resource Indicator model -const MCP_DEPRECATED_VERSIONS: Record = { - '2024-11-05': 'No standardized authorization framework — predates OAuth 2.1 support entirely.', - '2025-03-26': 'OAuth 2.1 present but predates Resource Indicators (RFC 8707) and the Protected Resource Metadata model, leaving tokens vulnerable to passthrough / confused-deputy attacks.', -}; +// 2025-11-25 — further auth + transport hardening; last handshake-era revision +// 2026-07-28 — stateless protocol: no initialize handshake and no +// Mcp-Session-Id, mandatory server/discover, mirrored +// Mcp-Method/Mcp-Name headers with server-side validation +// (HeaderMismatch -32020), required ttlMs/cacheScope hints, +// RFC 9207 issuer validation, Client ID Metadata Documents +// superseding Dynamic Client Registration, and Roots/Sampling/ +// Logging deprecated +// +// Version constants live in @/lib/mcp so every MCP route grades against the +// same revision list. + +/** Sensitive header names that must never be fed from tool arguments. */ +const SENSITIVE_HEADER_NAMES = [ + 'authorization', 'cookie', 'set-cookie', 'proxy-authorization', + 'x-api-key', 'apikey', 'api-key', 'x-auth-token', 'auth-token', + 'x-access-token', 'access-token', 'x-csrf-token', 'x-forwarded-for', + 'x-forwarded-host', 'host', +]; type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info'; @@ -1009,8 +1036,9 @@ function analyzeToolForArgumentInjection(tool: MCPTool): Finding[] { * (RFC 8707) and Protected Resource Metadata (RFC 9728) that mitigate token * passthrough and confused-deputy attacks. */ -function analyzeProtocolVersion(negotiatedVersion: string | undefined): Finding[] { +function analyzeProtocolVersion(conn: MCPConnection): Finding[] { const findings: Finding[] = []; + const negotiatedVersion = conn.protocolVersion; if (!negotiatedVersion) return findings; if (!MCP_KNOWN_VERSIONS.includes(negotiatedVersion)) { @@ -1036,9 +1064,302 @@ function analyzeProtocolVersion(negotiatedVersion: string | undefined): Finding[ severity: 'medium', category: 'Protocol Version', title: `Server uses a deprecated MCP protocol version (${negotiatedVersion})`, - description: `${deprecationReason} Upgrade to ${MCP_PREFERRED_VERSION} or later, which classifies MCP servers as OAuth Resource Servers and mandates Resource Indicators and Protected Resource Metadata.`, + description: `${deprecationReason} Upgrade to ${MCP_LATEST_VERSION}, which classifies MCP servers as OAuth Resource Servers, mandates Resource Indicators and Protected Resource Metadata, and adds RFC 9207 issuer validation.`, evidence: `Negotiated ${negotiatedVersion}; latest is ${MCP_LATEST_VERSION}`, }); + } else if (MCP_LEGACY_VERSIONS.includes(negotiatedVersion)) { + findings.push({ + owaspId: 'MCP04', + owaspTitle: OWASP_MCP_TOP_10.MCP04.title, + owaspUrl: OWASP_MCP_TOP_10.MCP04.url, + severity: 'low', + category: 'Protocol Version', + title: `Server implements a superseded revision (${negotiatedVersion})`, + description: `The server speaks the handshake era (initialize / Mcp-Session-Id). MCP ${MCP_LATEST_VERSION} replaced it with a stateless model whose security-relevant additions this server does not have: server-side validation of the mirrored Mcp-Method / Mcp-Name headers (so gateways and WAFs cannot be routed one way while the server executes another), RFC 9207 issuer validation on authorization responses, and Client ID Metadata Documents in place of Dynamic Client Registration.`, + evidence: `Negotiated ${negotiatedVersion}; latest is ${MCP_LATEST_VERSION}`, + }); + } + + if (conn.era === 'modern' && !conn.probe.discoverImplemented) { + findings.push({ + owaspId: 'MCP09', + owaspTitle: OWASP_MCP_TOP_10.MCP09.title, + owaspUrl: OWASP_MCP_TOP_10.MCP09.url, + severity: 'low', + category: 'Protocol Version', + title: 'Server does not implement server/discover', + description: `The server accepts stateless requests but does not answer "server/discover", which ${MCP_LATEST_VERSION} makes a MUST. Clients and inventory tooling rely on it to enumerate supported versions, capabilities and server identity; without it a server is harder to discover and govern.`, + evidence: conn.probe.discoverErrorCode + ? `server/discover returned JSON-RPC ${conn.probe.discoverErrorCode}` + : 'server/discover returned no result', + }); + } + + return findings; +} + +// ─── Deprecated features (2026-07-28 feature lifecycle) ────────────────────── + +const DEPRECATED_CAPABILITIES: Record = { + roots: 'Pass directories or files via tool parameters, resource URIs, or server configuration instead.', + sampling: 'Integrate directly with an LLM provider API instead of asking the client to sample.', + logging: 'Log to stderr (stdio) or use OpenTelemetry instead.', +}; + +/** + * Roots, Sampling and Logging entered the Deprecated state in 2026-07-28 with a + * minimum twelve-month removal window. Sampling in particular hands the server + * influence over an LLM call the user did not initiate, so continued reliance on + * it is worth surfacing. + */ +function analyzeDeprecatedFeatures(capabilities: Json): Finding[] { + const findings: Finding[] = []; + if (!capabilities || typeof capabilities !== 'object') return findings; + + const declared = Object.keys(DEPRECATED_CAPABILITIES).filter( + (name) => capabilities[name] !== undefined + ); + if (declared.length === 0) return findings; + + findings.push({ + owaspId: 'MCP02', + owaspTitle: OWASP_MCP_TOP_10.MCP02.title, + owaspUrl: OWASP_MCP_TOP_10.MCP02.url, + severity: 'low', + category: 'Deprecated Features', + title: `Server depends on deprecated MCP features: ${declared.join(', ')}`, + description: `These capabilities are Deprecated as of ${MCP_LATEST_VERSION} and are eligible for removal after a twelve-month window. ${declared + .map((d) => `${d}: ${DEPRECATED_CAPABILITIES[d]}`) + .join(' ')}`, + evidence: declared.join(', '), + }); + + return findings; +} + +// ─── Statelessness / transport hardening (2026-07-28) ──────────────────────── + +/** + * 2026-07-28 removed protocol-level sessions. A server that still mints an + * `Mcp-Session-Id`, or still serves the standalone SSE GET endpoint, is keeping + * per-connection state that requests can inherit without re-presenting their own + * authorization context. + */ +async function analyzeStatelessTransport( + serverUrl: string, + baseHeaders: Record, + conn: MCPConnection +): Promise { + const findings: Finding[] = []; + + if (conn.sessionId) { + findings.push({ + owaspId: 'MCP10', + owaspTitle: OWASP_MCP_TOP_10.MCP10.title, + owaspUrl: OWASP_MCP_TOP_10.MCP10.url, + severity: conn.era === 'modern' ? 'medium' : 'low', + category: 'Statelessness', + title: 'Server mints a protocol-level session (Mcp-Session-Id)', + description: `The server returned an Mcp-Session-Id header. Protocol-level sessions were removed in ${MCP_LATEST_VERSION}: state carried on the connection rather than in each request means a later request can inherit context — capabilities, identity, resource subscriptions — it never presented credentials for, and it makes session-fixation and cross-tenant bleed possible behind a load balancer.`, + evidence: `Mcp-Session-Id: ${conn.sessionId}`, + }); + } + + // The standalone SSE GET endpoint was replaced by subscriptions/listen. + try { + const res = await fetch(serverUrl, { + method: 'GET', + headers: { ...baseHeaders, Accept: 'text/event-stream' }, + signal: AbortSignal.timeout(5000), + }); + const contentType = res.headers.get('content-type') || ''; + if (res.ok && contentType.includes('text/event-stream')) { + findings.push({ + owaspId: 'MCP08', + owaspTitle: OWASP_MCP_TOP_10.MCP08.title, + owaspUrl: OWASP_MCP_TOP_10.MCP08.url, + severity: 'low', + category: 'Statelessness', + title: 'Server still serves a standalone SSE stream on GET', + description: `A GET to the MCP endpoint opened an event stream. ${MCP_LATEST_VERSION} replaced the GET endpoint (and resources/subscribe) with subscriptions/listen, and removed stream resumability; servers on the current revision answer 405 Method Not Allowed. A long-lived server-push channel outside the request/response model is a channel for server-initiated messages that no single request authorized.`, + evidence: `GET ${new URL(serverUrl).pathname} → ${res.status} ${contentType}`, + }); + } + await parseMCPResponse(res).catch(() => undefined); + } catch { + /* endpoint not reachable by GET — expected on a current server */ + } + + return findings; +} + +// ─── Header mirroring (x-mcp-header, 2026-07-28) ───────────────────────────── + +/** + * `x-mcp-header` mirrors tool arguments into `Mcp-Param-*` HTTP headers so + * intermediaries can route on them. That makes the annotation a data-egress + * decision: an annotated value leaves the encrypted body and becomes visible to + * (and loggable by) every proxy on the path. + */ +function analyzeToolHeaderMirroring(tool: MCPTool): Finding[] { + const findings: Finding[] = []; + const bindings = collectXMcpHeaderBindings(tool.inputSchema); + if (bindings.length === 0) return findings; + + const invalid = bindings.filter((b) => b.violations.length > 0); + if (invalid.length > 0) { + findings.push({ + owaspId: 'MCP03', + owaspTitle: OWASP_MCP_TOP_10.MCP03.title, + owaspUrl: OWASP_MCP_TOP_10.MCP03.url, + severity: 'medium', + category: 'Header Mirroring', + title: 'Tool declares invalid x-mcp-header annotations', + description: `The tool "${tool.name}" annotates parameters with x-mcp-header values that violate the ${MCP_LATEST_VERSION} constraints. Conforming clients MUST exclude the whole tool from tools/list; a client that does not will emit malformed headers, and CR/LF or non-token values are a header-injection vector against intermediaries.`, + toolName: tool.name, + evidence: invalid + .map((b) => `${b.path.join('.')} → "${b.header}": ${b.violations.join('; ')}`) + .join(' | ') + .slice(0, 300), + }); + } + + const sensitive = bindings.filter((b) => + SENSITIVE_HEADER_NAMES.includes(b.header.toLowerCase()) + ); + if (sensitive.length > 0) { + findings.push({ + owaspId: 'MCP01', + owaspTitle: OWASP_MCP_TOP_10.MCP01.title, + owaspUrl: OWASP_MCP_TOP_10.MCP01.url, + severity: 'high', + category: 'Header Mirroring', + title: 'Tool parameters are mirrored into security-sensitive headers', + description: `The tool "${tool.name}" asks clients to copy argument values into headers named ${sensitive + .map((b) => `Mcp-Param-${b.header}`) + .join(', ')}. Names in this family (Authorization, Cookie, API keys, forwarding headers) let a tool argument impersonate a credential or spoof an origin at any proxy that inspects them, and put the value into request logs along the whole path.`, + toolName: tool.name, + evidence: sensitive.map((b) => `${b.path.join('.')} → Mcp-Param-${b.header}`).join(', '), + }); + } + + const credentialish = bindings.filter( + (b) => + !SENSITIVE_HEADER_NAMES.includes(b.header.toLowerCase()) && + /token|secret|password|passwd|credential|session|bearer|signature/i.test( + `${b.header} ${b.path.join('.')}` + ) + ); + if (credentialish.length > 0) { + findings.push({ + owaspId: 'MCP01', + owaspTitle: OWASP_MCP_TOP_10.MCP01.title, + owaspUrl: OWASP_MCP_TOP_10.MCP01.url, + severity: 'medium', + category: 'Header Mirroring', + title: 'Credential-shaped parameter mirrored into an HTTP header', + description: `The tool "${tool.name}" mirrors a parameter whose name suggests a secret into an Mcp-Param-* header. Mirrored values are readable by every intermediary on the path and routinely land in access logs; mirror only routing keys such as region or tenant.`, + toolName: tool.name, + evidence: credentialish.map((b) => `${b.path.join('.')} → Mcp-Param-${b.header}`).join(', '), + }); + } + + return findings; +} + +// ─── Remote $ref in tool schemas (2026-07-28 JSON Schema rules) ────────────── + +function collectRemoteRefs(schema: Json, refs: string[] = [], depth = 0): string[] { + if (!schema || typeof schema !== 'object' || depth > 12) return refs; + + if (typeof schema.$ref === 'string' && /^https?:\/\//i.test(schema.$ref)) { + refs.push(schema.$ref); + } + for (const value of Object.values(schema)) { + if (Array.isArray(value)) { + for (const item of value) collectRemoteRefs(item, refs, depth + 1); + } else if (value && typeof value === 'object') { + collectRemoteRefs(value, refs, depth + 1); + } + } + return refs; +} + +/** + * JSON Schema 2020-12 lets `$ref` point at a network URI. 2026-07-28 forbids + * implementations from dereferencing those automatically — a server that ships + * them is asking clients to fetch third-party schema content, which is both an + * SSRF trigger and a supply-chain hook into argument validation. + */ +function analyzeSchemaRemoteRefs(tool: MCPTool): Finding[] { + const findings: Finding[] = []; + const refs = [ + ...collectRemoteRefs(tool.inputSchema), + ...collectRemoteRefs(tool.outputSchema), + ]; + if (refs.length === 0) return findings; + + findings.push({ + owaspId: 'MCP04', + owaspTitle: OWASP_MCP_TOP_10.MCP04.title, + owaspUrl: OWASP_MCP_TOP_10.MCP04.url, + msssId: 'MCP-NET-01', + msssLevel: 'L1', + severity: 'medium', + category: 'Schema Supply Chain', + title: 'Tool schema contains network $ref targets', + description: `The schema for "${tool.name}" references remote URIs. Implementations MUST NOT dereference network $refs automatically (${MCP_LATEST_VERSION}); a client that does gives the schema host control over argument validation and can be pointed at internal addresses. Inline the definitions or use local $defs.`, + toolName: tool.name, + evidence: [...new Set(refs)].slice(0, 3).join(', '), + }); + + return findings; +} + +// ─── Cache hints (2026-07-28 CacheableResult) ──────────────────────────────── + +/** + * `cacheScope: "public"` tells shared gateways they may serve a stored response + * to any caller. On a server that requires authorization, that is a cross-tenant + * disclosure primitive; per-user results must be `"private"`. + */ +function analyzeCacheHints( + operation: string, + result: Json, + requiresAuth: boolean, + era: MCPConnection['era'] +): Finding[] { + const findings: Finding[] = []; + if (!result || typeof result !== 'object') return findings; + + const hints = cacheHintsOf(result); + + if (hints.cacheScope === 'public' && requiresAuth) { + findings.push({ + owaspId: 'MCP10', + owaspTitle: OWASP_MCP_TOP_10.MCP10.title, + owaspUrl: OWASP_MCP_TOP_10.MCP10.url, + severity: operation === 'resources/read' ? 'high' : 'medium', + category: 'Caching', + title: `${operation} is marked cacheScope: "public" on an authenticated server`, + description: `The server answered an authorized ${operation} with cacheScope "public", which permits any shared gateway or caching proxy to store the response and serve it to a different caller. Results that vary per authorization context MUST be "private" so caches are never shared across contexts.`, + evidence: `${operation} → cacheScope: public${hints.hasTtl ? `, ttlMs: ${hints.ttlMs}` : ''}`, + }); + } + + if (era === 'modern' && !hints.valid && resultTypeOf(result) !== 'input_required') { + findings.push({ + owaspId: 'MCP08', + owaspTitle: OWASP_MCP_TOP_10.MCP08.title, + owaspUrl: OWASP_MCP_TOP_10.MCP08.url, + severity: 'info', + category: 'Caching', + title: `${operation} omits required cache hints`, + description: `${MCP_LATEST_VERSION} requires ttlMs (>= 0) and cacheScope ("public" or "private") on complete results from ${operation}. Without an explicit scope, intermediaries have no instruction about whether the response may be shared between callers.`, + evidence: `ttlMs: ${hints.hasTtl ? hints.ttlMs : 'absent'}, cacheScope: ${ + hints.hasScope ? hints.cacheScope : 'absent' + }`, + }); } return findings; @@ -1055,10 +1376,8 @@ function analyzeProtocolVersion(negotiatedVersion: string | undefined): Finding[ */ async function analyzeAuthorizationMetadata( serverUrl: string, - reqHeaders: Record, authType: string | undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - unauthInitResult: any, + connected: boolean, wwwAuthenticate: string | null ): Promise { const findings: Finding[] = []; @@ -1067,6 +1386,7 @@ async function analyzeAuthorizationMetadata( const isLocal = serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1'); let prmFound = false; + let prmAuthorizationServers: string[] = []; try { const origin = new URL(serverUrl).origin; const prmRes = await fetch(`${origin}/.well-known/oauth-protected-resource`, { @@ -1078,15 +1398,20 @@ async function analyzeAuthorizationMetadata( const prm = await prmRes.json().catch(() => null); if (prm && (prm.authorization_servers || prm.resource)) { prmFound = true; + if (Array.isArray(prm.authorization_servers)) { + prmAuthorizationServers = prm.authorization_servers.filter( + (s: unknown): s is string => typeof s === 'string' + ); + } } } } catch { /* metadata endpoint not reachable */ } - // If the server answered the unauthenticated initialize successfully AND exposes + // If the server answered an unauthenticated request successfully AND exposes // no authorization metadata, it is effectively open. - if ((!authType || authType === 'none') && unauthInitResult && !prmFound && !isLocal) { + if ((!authType || authType === 'none') && connected && !prmFound && !isLocal) { findings.push({ owaspId: 'MCP07', owaspTitle: OWASP_MCP_TOP_10.MCP07.title, @@ -1096,8 +1421,8 @@ async function analyzeAuthorizationMetadata( severity: 'high', category: 'Authorization', title: 'No OAuth 2.1 authorization — server is openly accessible', - description: 'The server accepted an unauthenticated initialize and exposes no Protected Resource Metadata (RFC 9728) at /.well-known/oauth-protected-resource. Per MCP 2025-06-18, remote servers should act as OAuth 2.1 Resource Servers and advertise their authorization server so clients can obtain audience-bound tokens.', - evidence: 'No /.well-known/oauth-protected-resource and unauthenticated initialize succeeded', + description: 'The server answered an unauthenticated request and exposes no Protected Resource Metadata (RFC 9728) at /.well-known/oauth-protected-resource. Since MCP 2025-06-18, remote servers should act as OAuth 2.1 Resource Servers and advertise their authorization server so clients can obtain audience-bound tokens.', + evidence: 'No /.well-known/oauth-protected-resource and unauthenticated request succeeded', }); } @@ -1115,9 +1440,124 @@ async function analyzeAuthorizationMetadata( }); } + findings.push(...(await analyzeAuthorizationServer(serverUrl, prmAuthorizationServers))); + + return findings; +} + +// ─── Authorization server metadata (RFC 9207 / CIMD, 2026-07-28) ───────────── + +/** + * Fetches the authorization server's metadata and checks the two registration + * and issuer-validation requirements 2026-07-28 added: + * + * - `authorization_response_iss_parameter_supported` (RFC 9207). Without the + * `iss` parameter a client cannot tell which authorization server produced a + * code, which is what makes mix-up attacks work. + * - `client_id_metadata_document_supported` (CIMD). Dynamic Client + * Registration is deprecated in favour of CIMD; a DCR-only server accepts + * unauthenticated registrations from anyone who can reach the endpoint. + */ +async function analyzeAuthorizationServer( + serverUrl: string, + authorizationServers: string[] +): Promise { + const findings: Finding[] = []; + + const candidates = authorizationServers.length + ? authorizationServers + : [new URL(serverUrl).origin]; + + for (const issuer of candidates.slice(0, 2)) { + const metadata = await fetchAuthorizationServerMetadata(issuer); + if (!metadata) continue; + + if (metadata.authorization_response_iss_parameter_supported !== true) { + findings.push({ + owaspId: 'MCP07', + owaspTitle: OWASP_MCP_TOP_10.MCP07.title, + owaspUrl: OWASP_MCP_TOP_10.MCP07.url, + severity: 'medium', + category: 'Authorization', + title: 'Authorization server does not advertise RFC 9207 issuer identification', + description: `The metadata for ${issuer} does not set authorization_response_iss_parameter_supported. MCP ${MCP_LATEST_VERSION} expects authorization servers to return the iss parameter in authorization responses and requires clients to validate it against the recorded issuer before redeeming a code. Without it, a client talking to several authorization servers cannot detect a code issued by a different one — the mix-up attack.`, + evidence: `${issuer} — authorization_response_iss_parameter_supported: ${ + metadata.authorization_response_iss_parameter_supported ?? 'absent' + }`, + }); + } + + const cimd = metadata.client_id_metadata_document_supported === true; + const dcr = typeof metadata.registration_endpoint === 'string'; + + if (!cimd && dcr) { + findings.push({ + owaspId: 'MCP07', + owaspTitle: OWASP_MCP_TOP_10.MCP07.title, + owaspUrl: OWASP_MCP_TOP_10.MCP07.url, + severity: 'low', + category: 'Authorization', + title: 'Authorization server offers only Dynamic Client Registration', + description: `${issuer} exposes a registration_endpoint but does not advertise client_id_metadata_document_supported. OAuth 2.0 Dynamic Client Registration (RFC 7591) is deprecated as an MCP registration mechanism as of ${MCP_LATEST_VERSION} in favour of Client ID Metadata Documents, where the client identifier is an HTTPS URL the authorization server fetches and validates. An open DCR endpoint mints client records for any caller that can reach it.`, + evidence: `${issuer} — registration_endpoint present, client_id_metadata_document_supported absent`, + }); + } + + if (!cimd && !dcr) { + findings.push({ + owaspId: 'MCP07', + owaspTitle: OWASP_MCP_TOP_10.MCP07.title, + owaspUrl: OWASP_MCP_TOP_10.MCP07.url, + severity: 'info', + category: 'Authorization', + title: 'Authorization server supports no automatic client registration', + description: `${issuer} advertises neither Client ID Metadata Documents nor a registration endpoint, so clients with no prior relationship must be registered out of band. That is a valid choice; it is listed here because ${MCP_LATEST_VERSION} expects CIMD to be the default path for clients and servers that have never met.`, + evidence: issuer, + }); + } + } + return findings; } +async function fetchAuthorizationServerMetadata(issuer: string): Promise { + let origin: string; + let pathPart: string; + try { + const url = new URL(issuer); + origin = url.origin; + pathPart = url.pathname.replace(/\/$/, ''); + } catch { + return null; + } + + // RFC 8414 inserts the well-known segment between host and issuer path; + // OpenID Discovery appends it. Try both, plus the bare origin. + const candidates = [ + `${origin}/.well-known/oauth-authorization-server${pathPart}`, + `${origin}${pathPart}/.well-known/openid-configuration`, + `${origin}/.well-known/openid-configuration`, + ]; + + for (const url of [...new Set(candidates)]) { + try { + const res = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) continue; + const json = await res.json().catch(() => null); + if (json && (json.issuer || json.authorization_endpoint || json.token_endpoint)) { + return json; + } + } catch { + /* try the next candidate */ + } + } + + return null; +} + // ─── Tool Annotations (2025-06-18+) ────────────────────────────────────────── const MUTATING_TOOL_HINTS = [ @@ -1390,64 +1830,19 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Server URL required' }, { status: 400 }); } - const reqHeaders: Record = { - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', - }; - - if (authType && authType !== 'none' && authValue) { - if (authType === 'api_key') { - reqHeaders[authHeader || 'Authorization'] = authValue; - } else if (authType === 'bearer') { - reqHeaders[authHeader || 'Authorization'] = `Bearer ${authValue}`; - } else if (authType === 'basic') { - reqHeaders[authHeader || 'Authorization'] = `Basic ${Buffer.from(authValue).toString('base64')}`; - } - } - - // Apply custom headers - if (Array.isArray(customHeaders)) { - for (const h of customHeaders) { - if (h.key && h.value) reqHeaders[h.key] = h.value; - } - } + const reqHeaders = buildBaseHeaders({ authType, authValue, authHeader, customHeaders }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let initResult: any = null; let tools: MCPTool[] = []; let resources: MCPResource[] = []; let prompts: MCPPrompt[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let rawInit: any = null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let rawTools: any = null; - let responseHeaders: Headers | null = null; - let wwwAuthenticate: string | null = null; - // Step 1: Initialize — negotiate the latest spec revision - try { - const initResponse = await fetch(serverUrl, { - method: 'POST', - headers: reqHeaders, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: MCP_PREFERRED_VERSION, - capabilities: {}, - clientInfo: { name: 'protocol-guard-scanner', version: '1.0.0' }, - }, - }), - }); + // Step 1: Connect — probe the stateless revision first, fall back to the + // initialize handshake, and remember which era the server speaks. + const conn = await connectMCP(serverUrl, reqHeaders); - responseHeaders = initResponse.headers; - wwwAuthenticate = initResponse.headers.get('www-authenticate'); - rawInit = await parseResponse(initResponse); - initResult = rawInit?.result || rawInit; - } catch (err) { + if (conn.era === 'unknown') { return NextResponse.json({ - error: `Failed to connect: ${err instanceof Error ? err.message : 'Unknown error'}`, + error: `Failed to connect: ${conn.error || 'Unknown error'}`, findings: [{ owaspId: 'MCP07', owaspTitle: OWASP_MCP_TOP_10.MCP07.title, @@ -1455,98 +1850,74 @@ export async function POST(request: NextRequest) { severity: 'high', category: 'Connection', title: 'Cannot connect to MCP server', - description: 'The scanner was unable to establish a connection to the MCP server. The server may be down, unreachable, or rejecting connections.', + description: 'The scanner was unable to establish a connection to the MCP server. It answered neither "server/discover" (MCP 2026-07-28) nor "initialize" (handshake era). The server may be down, unreachable, or rejecting connections.', evidence: serverUrl, }], summary: { critical: 0, high: 1, medium: 0, low: 0, info: 0, total: 1 }, }); } - // Per MCP 2025-06-18, clients echo the negotiated version on later HTTP requests. - const negotiatedVersion: string | undefined = initResult?.protocolVersion; - if (negotiatedVersion) { - reqHeaders['MCP-Protocol-Version'] = negotiatedVersion; - } - - // Send initialized notification - try { - await fetch(serverUrl, { - method: 'POST', - headers: reqHeaders, - body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }), - }); - } catch { /* ignore */ } + // Normalized handshake view: a stateless server reports its identity in the + // result's `_meta`, a handshake server in `initialize.serverInfo`. + const handshakeResult: Json = { + ...(conn.discover || conn.initialize || {}), + serverInfo: conn.serverInfo, + instructions: conn.instructions, + }; + const responseHeaders: Headers | null = conn.headers; + const wwwAuthenticate: string | null = conn.wwwAuthenticate; + const rawInit: Json = conn.raw; + let rawTools: Json = null; - // Step 2: List tools - try { - const toolsResponse = await fetch(serverUrl, { - method: 'POST', - headers: reqHeaders, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - params: {}, - }), - }); + const negotiatedVersion: string | undefined = conn.protocolVersion; + const requiresAuth = !!(authType && authType !== 'none'); - rawTools = await parseResponse(toolsResponse); - const toolsResult = rawTools?.result || rawTools; - tools = toolsResult?.tools || []; - } catch { - // tools/list not supported - } + // Step 2: Enumerate tools, resources and prompts in the server's own era. + const toolsCall = await mcpCall(conn, serverUrl, reqHeaders, 'tools/list'); + rawTools = toolsCall.raw; + tools = toolsCall.result?.tools || []; - // Step 2b: List resources (best-effort — only if capability declared or simply try) - try { - const resResponse = await fetch(serverUrl, { - method: 'POST', - headers: reqHeaders, - body: JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'resources/list', params: {} }), - }); - const parsed = await parseResponse(resResponse); - resources = (parsed?.result || parsed)?.resources || []; - } catch { - // resources/list not supported - } + const resourcesCall = await mcpCall(conn, serverUrl, reqHeaders, 'resources/list'); + resources = resourcesCall.result?.resources || []; - // Step 2c: List prompts (best-effort) - try { - const promptResponse = await fetch(serverUrl, { - method: 'POST', - headers: reqHeaders, - body: JSON.stringify({ jsonrpc: '2.0', id: 4, method: 'prompts/list', params: {} }), - }); - const parsed = await parseResponse(promptResponse); - prompts = (parsed?.result || parsed)?.prompts || []; - } catch { - // prompts/list not supported - } + const promptsCall = await mcpCall(conn, serverUrl, reqHeaders, 'prompts/list'); + prompts = promptsCall.result?.prompts || []; // Step 3: Run analysis const findings: Finding[] = []; // Protocol version / spec revision checks - findings.push(...analyzeProtocolVersion(negotiatedVersion)); + findings.push(...analyzeProtocolVersion(conn)); + + // Deprecated features (Roots / Sampling / Logging) + findings.push(...analyzeDeprecatedFeatures(conn.capabilities)); + + // Statelessness and transport hardening (2026-07-28) + findings.push(...await analyzeStatelessTransport(serverUrl, reqHeaders, conn)); + + // Cache hints on cacheable results (2026-07-28) + findings.push(...analyzeCacheHints('tools/list', toolsCall.result, requiresAuth, conn.era)); + findings.push(...analyzeCacheHints('resources/list', resourcesCall.result, requiresAuth, conn.era)); + findings.push(...analyzeCacheHints('prompts/list', promptsCall.result, requiresAuth, conn.era)); // Server-level checks findings.push(...analyzeServerCapabilities( - initResult?.capabilities, + conn.capabilities, tools, serverUrl )); - // Authentication posture - findings.push(...analyzeAuthenticationPosture(authType, !!initResult)); + // Authentication posture — reaching this point means the server answered. + findings.push(...analyzeAuthenticationPosture(authType, true)); - // OAuth 2.1 authorization metadata (RFC 9728 / RFC 8707) - findings.push(...await analyzeAuthorizationMetadata(serverUrl, reqHeaders, authType, initResult, wwwAuthenticate)); + // OAuth 2.1 authorization metadata (RFC 9728 / RFC 8707 / RFC 9207 / CIMD) + findings.push(...await analyzeAuthorizationMetadata(serverUrl, authType, true, wwwAuthenticate)); // Instructions field analysis - findings.push(...analyzeInstructionsField(initResult)); + findings.push(...analyzeInstructionsField(handshakeResult)); // Information leakage (version, framework fingerprinting) - findings.push(...analyzeInformationLeakage(initResult, tools)); + findings.push(...analyzeInformationLeakage(handshakeResult, tools)); // HTTP response header analysis if (responseHeaders) { @@ -1570,6 +1941,9 @@ export async function POST(request: NextRequest) { findings.push(...analyzeToolForArgumentInjection(tool)); // MCP-EXEC-03 // 2025-06-18+ spec checks findings.push(...analyzeToolAnnotations(tool)); + // 2026-07-28 spec checks + findings.push(...analyzeToolHeaderMirroring(tool)); + findings.push(...analyzeSchemaRemoteRefs(tool)); } // Resources, prompts and interactive-UI (MCP Apps) checks @@ -1614,9 +1988,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ timestamp: new Date().toISOString(), serverUrl, - serverInfo: initResult?.serverInfo || null, + serverInfo: conn.serverInfo, protocolVersion: negotiatedVersion || null, + protocolEra: conn.era, + supportedVersions: conn.supportedVersions || null, latestProtocolVersion: MCP_LATEST_VERSION, + specUrl: MCP_SPEC_URLS.spec, toolsCount: tools.length, resourcesCount: resources.length, promptsCount: prompts.length, @@ -1633,15 +2010,3 @@ export async function POST(request: NextRequest) { ); } } - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function parseResponse(response: Response): Promise { - const contentType = response.headers.get('content-type') || ''; - if (contentType.includes('text/event-stream')) { - const text = await response.text(); - const dataLine = text.split('\n').find(l => l.startsWith('data:')); - if (dataLine) return JSON.parse(dataLine.replace(/^data:\s*/, '')); - return { _rawSSE: text }; - } - return response.json(); -} diff --git a/apps/web/src/app/api/mcp/test/route.ts b/apps/web/src/app/api/mcp/test/route.ts index b71b30a..1000c95 100644 --- a/apps/web/src/app/api/mcp/test/route.ts +++ b/apps/web/src/app/api/mcp/test/route.ts @@ -1,69 +1,52 @@ import { NextRequest, NextResponse } from 'next/server'; - -// MCP spec URLs and versions for reference -const MCP_SPEC = { - // Version the scanner negotiates with the server (latest stable revision) - version: '2025-06-18', - latestVersion: '2025-11-25', - urls: { - base: 'https://modelcontextprotocol.io', - initialize: 'https://modelcontextprotocol.io/specification/2025-06-18/basic', - authentication: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization', - capabilities: 'https://modelcontextprotocol.io/specification/2025-06-18/architecture', - security: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices', - }, -}; - -// Published MCP protocol revisions and which ones predate the hardened auth model -const MCP_KNOWN_VERSIONS = ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']; -const MCP_DEPRECATED_VERSIONS: Record = { - '2024-11-05': 'no standardized authorization framework', - '2025-03-26': 'OAuth 2.1 without Resource Indicators (RFC 8707)', -}; - -// MCP compliance rules -const MCP_COMPLIANCE_RULES = [ - { id: 'protocol-version', name: 'Protocol Version', severity: 'critical' }, - { id: 'server-info', name: 'Server Info', severity: 'critical' }, - { id: 'capabilities-object', name: 'Capabilities Object', severity: 'warning' }, - { id: 'tools-capability', name: 'Tools Capability', severity: 'info' }, - { id: 'resources-capability', name: 'Resources Capability', severity: 'info' }, - { id: 'initialize-method', name: 'Initialize Method', severity: 'critical' }, - { id: 'ping-method', name: 'Ping Method', severity: 'info' }, - { id: 'authentication', name: 'Authentication', severity: 'critical' }, -]; - -interface MCPInitializeResponse { - protocolVersion?: string; - serverInfo?: { - name: string; - version: string; - }; - capabilities?: { - tools?: Record; - resources?: Record; - prompts?: Record; - }; +import { + MCP_DEPRECATED_VERSIONS, + MCP_ERROR_CODES, + MCP_KNOWN_VERSIONS, + MCP_LATEST_VERSION, + MCP_LEGACY_VERSIONS, + MCP_META_KEYS, + MCP_MODERN_VERSIONS, + MCP_SPEC_URLS, + buildBaseHeaders, + cacheHintsOf, + connectMCP, + mcpCall, + modernCall, + modernHeaders, + parseMCPResponse, + resultTypeOf, + withRequestMeta, + type Json, + type MCPConnection, +} from '@/lib/mcp'; + +/** + * MCP compliance tester. + * + * The scanner speaks the latest revision (2026-07-28) first and falls back to + * the handshake era only when the server turns out to be legacy, so the rules + * below are selected per era: a stateless server is graded on `server/discover`, + * `resultType`, cache hints and mirrored headers, while a handshake server is + * still graded on `initialize` / `ping` and told which era it is in. + */ + +type Severity = 'critical' | 'warning' | 'info'; + +interface RuleResult { + passed: boolean; + ruleId: string; + message: string; + severity: Severity; + docUrl?: string; + note?: string; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function extractMCPResult(json: any): { parsed: MCPInitializeResponse | null; raw: any } { - // MCP servers return JSON-RPC: { jsonrpc: "2.0", id: 1, result: { ... } } - // We need to extract the result for compliance checks but keep raw for display - const raw = json; - - if (json?.jsonrpc && json?.result && typeof json.result === 'object') { - // Standard JSON-RPC response — extract the result payload - return { parsed: json.result as MCPInitializeResponse, raw }; - } - - if (json?.jsonrpc && json?.error) { - // JSON-RPC error response - return { parsed: null, raw }; - } - - // Some servers return the payload directly without JSON-RPC wrapper - return { parsed: json as MCPInitializeResponse, raw }; +interface ProbeResults { + toolsList: { ran: boolean; ok: boolean; result: Json; resultType?: string }; + headerValidation: { ran: boolean; status: number; errorCode?: number; accepted: boolean }; + getMethod: { ran: boolean; status: number }; + ping: { ran: boolean; ok: boolean; message: string }; } export async function POST(request: NextRequest) { @@ -74,274 +57,50 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Server URL required' }, { status: 400 }); } - // Build headers - MCP servers require accepting both JSON and SSE - const headers: Record = { - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', - }; + const baseHeaders = buildBaseHeaders({ authType, authValue, authHeader, customHeaders }); - if (authType && authType !== 'none' && authValue) { - if (authType === 'api_key') { - headers[authHeader || 'Authorization'] = authValue; - } else if (authType === 'bearer') { - headers[authHeader || 'Authorization'] = `Bearer ${authValue}`; - } else if (authType === 'basic') { - headers[authHeader || 'Authorization'] = `Basic ${Buffer.from(authValue).toString('base64')}`; - } - } + const conn = await connectMCP(serverUrl, baseHeaders); - // Apply custom headers - if (Array.isArray(customHeaders)) { - for (const h of customHeaders) { - if (h.key && h.value) headers[h.key] = h.value; - } - } + const probes: ProbeResults = { + toolsList: { ran: false, ok: false, result: null }, + headerValidation: { ran: false, status: 0, accepted: false }, + getMethod: { ran: false, status: 0 }, + ping: { ran: false, ok: false, message: '' }, + }; - const jsonRpcBody = JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: MCP_SPEC.version, - capabilities: {}, - clientInfo: { name: 'protocol-guard', version: '1.0.0' }, - }, - }); + if (conn.era !== 'unknown') { + const tools = await mcpCall(conn, serverUrl, baseHeaders, 'tools/list'); + probes.toolsList = { + ran: true, + ok: !tools.error && tools.raw?.result !== undefined, + result: tools.result, + resultType: resultTypeOf(tools.result), + }; + } - // Attempt to connect to MCP server - let serverResponse: MCPInitializeResponse | null = null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let rawServerResponse: any = null; - let authTested = false; - let authPassed = false; - let connectionError = ''; - let pingPassed = false; - let pingMessage = ''; - - try { - const response = await fetch(serverUrl, { - method: 'POST', - headers, - body: jsonRpcBody, - }); - - if (response.status === 401 || response.status === 403) { - authTested = true; - authPassed = false; - rawServerResponse = { _httpStatus: response.status, _statusText: response.statusText }; - // Try to read body anyway for raw display - try { rawServerResponse._body = await response.json(); } catch { /* ignore */ } - serverResponse = null; - } else { - // Handle SSE responses: if content-type is text/event-stream, parse the first data line - const contentType = response.headers.get('content-type') || ''; - let json; - - if (contentType.includes('text/event-stream')) { - const text = await response.text(); - // SSE format: lines like "data: {...}\n\n" - const dataLine = text.split('\n').find(l => l.startsWith('data:')); - if (dataLine) { - json = JSON.parse(dataLine.replace(/^data:\s*/, '')); - } else { - json = { _rawSSE: text }; - } - } else { - json = await response.json(); - } - - const { parsed, raw } = extractMCPResult(json); - serverResponse = parsed; - rawServerResponse = raw; - - if (authType && authType !== 'none') { - authTested = true; - authPassed = !!serverResponse?.serverInfo; - } - } - } catch (err: unknown) { - const error = err as Error & { cause?: { code?: string } }; - connectionError = error.message || 'Connection failed'; - - // Try without auth as fallback - try { - const fallbackResponse = await fetch(serverUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', - }, - body: jsonRpcBody, - }); - - const contentType = fallbackResponse.headers.get('content-type') || ''; - let json; - - if (contentType.includes('text/event-stream')) { - const text = await fallbackResponse.text(); - const dataLine = text.split('\n').find(l => l.startsWith('data:')); - json = dataLine ? JSON.parse(dataLine.replace(/^data:\s*/, '')) : { _rawSSE: text }; - } else { - json = await fallbackResponse.json(); - } - - const { parsed, raw } = extractMCPResult(json); - serverResponse = parsed; - rawServerResponse = raw; - } catch { - // Both attempts failed — set raw response to show the error - rawServerResponse = { _error: connectionError, _hint: 'Could not connect to MCP server. Verify the URL and that the server accepts JSON-RPC POST requests.' }; - } + if (conn.era === 'modern') { + await probeHeaderValidation(serverUrl, baseHeaders, conn, probes); + await probeGetRejection(serverUrl, baseHeaders, probes); } - // Send a real JSON-RPC ping request to verify ping method support - if (serverResponse) { - try { - const pingBody = JSON.stringify({ - jsonrpc: '2.0', - id: 99, - method: 'ping', - }); - const pingResponse = await fetch(serverUrl, { - method: 'POST', - headers, - body: pingBody, - signal: AbortSignal.timeout(5000), - }); - - const pingContentType = pingResponse.headers.get('content-type') || ''; - let pingJson; - - if (pingContentType.includes('text/event-stream')) { - const text = await pingResponse.text(); - const dataLine = text.split('\n').find(l => l.startsWith('data:')); - pingJson = dataLine ? JSON.parse(dataLine.replace(/^data:\s*/, '')) : null; - } else { - pingJson = await pingResponse.json(); - } - - // A valid ping response is a JSON-RPC result (can be empty object) with no error - if (pingJson?.jsonrpc === '2.0' && pingJson?.id === 99 && !pingJson?.error) { - pingPassed = true; - pingMessage = 'Server responds to JSON-RPC "ping" method'; - } else if (pingJson?.error) { - pingPassed = false; - pingMessage = `Server returned error for ping: ${pingJson.error.message || 'method not found'}`; - } else { - pingPassed = false; - pingMessage = 'Unexpected response format for ping method'; - } - } catch { - pingPassed = false; - pingMessage = 'Ping request failed or timed out (server may not implement ping)'; - } + if (conn.era === 'legacy') { + const ping = await mcpCall(conn, serverUrl, baseHeaders, 'ping'); + probes.ping = { + ran: true, + ok: !ping.error && ping.raw?.result !== undefined, + message: ping.error + ? `Server returned error for ping: ${ping.error.message || 'method not found'}` + : ping.raw?.result !== undefined + ? 'Server responds to JSON-RPC "ping" method' + : ping.transportError || 'Unexpected response format for ping method', + }; } - // Run compliance checks with specific, actionable messages - const results = MCP_COMPLIANCE_RULES.map((rule) => { - let passed = false; - let message = ''; - let docUrl = ''; - let note = ''; - - switch (rule.id) { - case 'protocol-version': { - const negotiated = serverResponse?.protocolVersion; - passed = !!negotiated; - if (passed) { - const deprecation = negotiated ? MCP_DEPRECATED_VERSIONS[negotiated] : undefined; - const unknown = negotiated ? !MCP_KNOWN_VERSIONS.includes(negotiated) : false; - if (deprecation) { - message = `Protocol version "${negotiated}" is DEPRECATED (${deprecation}). Upgrade to ${MCP_SPEC.version} or later for the hardened OAuth Resource Server model.`; - docUrl = MCP_SPEC.urls.security; - note = `The server negotiated an older revision. Latest is ${MCP_SPEC.latestVersion}. Pre-2025-06-18 revisions lack Resource Indicators (RFC 8707) and Protected Resource Metadata (RFC 9728).`; - } else if (unknown) { - message = `Protocol version present but unrecognized: "${negotiated}".`; - docUrl = MCP_SPEC.urls.initialize; - } else { - message = `Protocol version present: "${negotiated}"`; - } - } else { - message = `MISSING: Server must return "protocolVersion" in initialize response.`; - docUrl = MCP_SPEC.urls.initialize; - } - break; - } - case 'server-info': - passed = !!(serverResponse?.serverInfo?.name && serverResponse?.serverInfo?.version); - if (passed) { - message = `Server info present: "${serverResponse?.serverInfo?.name}" v${serverResponse?.serverInfo?.version}`; - } else { - message = `MISSING: Server must return "serverInfo" with "name" and "version".`; - docUrl = MCP_SPEC.urls.initialize; - } - break; - case 'capabilities-object': - passed = typeof serverResponse?.capabilities === 'object'; - if (passed) { - message = `Capabilities object present`; - } else { - message = `MISSING: Server must return a "capabilities" object.`; - docUrl = MCP_SPEC.urls.capabilities; - } - break; - case 'tools-capability': - passed = true; - if (serverResponse?.capabilities?.tools) { - message = `Tools capability declared`; - } else { - message = `Tools not declared (optional)`; - docUrl = MCP_SPEC.urls.capabilities; - } - break; - case 'resources-capability': - passed = true; - if (serverResponse?.capabilities?.resources) { - message = `Resources capability declared`; - } else { - message = `Resources not declared (optional)`; - docUrl = MCP_SPEC.urls.capabilities; - } - break; - case 'initialize-method': - passed = !!serverResponse?.serverInfo; - if (passed) { - message = `Initialize request handled correctly`; - } else { - message = `FAILED: Server did not handle the "initialize" JSON-RPC request.`; - docUrl = MCP_SPEC.urls.initialize; - } - break; - case 'ping-method': - passed = pingPassed; - message = pingMessage || 'Ping not tested (initialize failed)'; - if (!passed) { - docUrl = MCP_SPEC.urls.initialize; - } - break; - case 'authentication': - if (!authType || authType === 'none') { - passed = true; - message = `No authentication configured`; - docUrl = MCP_SPEC.urls.authentication; - note = `The MCP spec does not mandate authentication but strongly recommends it for any non-local deployment. Running without auth means any client can connect and invoke all tools. Standards guidance: OWASP MCP Top 10 — MCP07 (Insufficient Authentication & Authorization) flags unauthenticated servers as a high-risk finding. MSSS MCP-AUTHZ-01 (L3) requires OAuth 2.1 delegation for production and internet-facing servers. Consider adding Bearer token auth at minimum for team/internal use, or OAuth 2.1 for production.`; - } else if (authTested) { - passed = authPassed; - if (authPassed) { - message = `Authentication (${authType}) accepted`; - } else { - message = `AUTH FAILED: Server returned 401/403.`; - docUrl = MCP_SPEC.urls.authentication; - } - } else { - passed = true; - message = `Authentication (${authType}) configured but couldn't verify`; - } - break; - } - - return { passed, ruleId: rule.id, message, severity: rule.severity, docUrl: docUrl || undefined, note: note || undefined }; - }); + const authOutcome = evaluateAuth(conn, authType); + const results = + conn.era === 'legacy' + ? legacyRules(conn, probes, authOutcome) + : modernRules(conn, probes, authOutcome); const passedCount = results.filter((r) => r.passed).length; const failedCount = results.filter((r) => !r.passed && r.severity === 'critical').length; @@ -349,16 +108,18 @@ export async function POST(request: NextRequest) { return NextResponse.json({ timestamp: new Date().toISOString(), - protocolVersion: serverResponse?.protocolVersion || MCP_SPEC.version, - requestedVersion: MCP_SPEC.version, - latestVersion: MCP_SPEC.latestVersion, - specUrl: MCP_SPEC.urls.base, - serverName: serverResponse?.serverInfo?.name || 'Unknown', + protocolVersion: conn.protocolVersion || MCP_LATEST_VERSION, + requestedVersion: MCP_LATEST_VERSION, + latestVersion: MCP_LATEST_VERSION, + protocolEra: conn.era, + supportedVersions: conn.supportedVersions || null, + specUrl: MCP_SPEC_URLS.spec, + serverName: conn.serverInfo?.name || 'Unknown', results, passedCount, failedCount, warningCount, - serverResponse: rawServerResponse || null, + serverResponse: conn.raw || (conn.error ? { _error: conn.error } : null), }); } catch (error) { return NextResponse.json( @@ -367,3 +128,414 @@ export async function POST(request: NextRequest) { ); } } + +// ─── Live probes (modern era only) ────────────────────────────────────────── + +/** + * `Mcp-Method` must mirror the body's `method`. A conforming server rejects a + * mismatch with `400 Bad Request` and a `HeaderMismatch` (-32020) error. + */ +async function probeHeaderValidation( + serverUrl: string, + baseHeaders: Record, + conn: MCPConnection, + probes: ProbeResults +): Promise { + const version = conn.protocolVersion || MCP_LATEST_VERSION; + const params = withRequestMeta({}, version); + const headers = modernHeaders(baseHeaders, version, 'tools/list', params); + headers['Mcp-Method'] = 'resources/list'; // deliberately disagrees with the body + + const res = await modernCall(serverUrl, baseHeaders, version, 'tools/list', {}, headers); + probes.headerValidation = { + ran: true, + status: res.status, + errorCode: res.error?.code, + // The server processed a request whose headers contradicted its body. + accepted: !res.error && res.raw?.result !== undefined, + }; +} + +/** Streamable HTTP no longer has a GET endpoint; a modern server answers 405. */ +async function probeGetRejection( + serverUrl: string, + baseHeaders: Record, + probes: ProbeResults +): Promise { + try { + const res = await fetch(serverUrl, { + method: 'GET', + headers: { ...baseHeaders, Accept: 'text/event-stream' }, + signal: AbortSignal.timeout(5000), + }); + probes.getMethod = { ran: true, status: res.status }; + // Drain so an accidentally-opened SSE stream does not stay pending. + await parseMCPResponse(res).catch(() => undefined); + } catch { + probes.getMethod = { ran: true, status: 0 }; + } +} + +// ─── Shared rule builders ──────────────────────────────────────────────────── + +interface AuthOutcome { + passed: boolean; + message: string; + note?: string; +} + +function evaluateAuth(conn: MCPConnection, authType: string | undefined): AuthOutcome { + if (!authType || authType === 'none') { + return { + passed: true, + message: 'No authentication configured', + note: `The MCP spec does not mandate authentication but strongly recommends it for any non-local deployment. Running without auth means any client can connect and invoke all tools. Standards guidance: OWASP MCP Top 10 — MCP07 (Insufficient Authentication & Authorization) flags unauthenticated servers as a high-risk finding. MSSS MCP-AUTHZ-01 (L3) requires OAuth 2.1 delegation for production and internet-facing servers. Consider adding Bearer token auth at minimum for team/internal use, or OAuth 2.1 for production.`, + }; + } + + if (conn.status === 401 || conn.status === 403) { + return { passed: false, message: `AUTH FAILED: Server returned ${conn.status}.` }; + } + + if (conn.era === 'unknown') { + return { passed: true, message: `Authentication (${authType}) configured but couldn't verify` }; + } + + return { passed: true, message: `Authentication (${authType}) accepted` }; +} + +function versionRule(conn: MCPConnection): RuleResult { + const negotiated = conn.protocolVersion; + + if (!negotiated) { + return { + passed: false, + ruleId: 'protocol-version', + message: 'MISSING: Could not determine the protocol version the server speaks.', + severity: 'critical', + docUrl: MCP_SPEC_URLS.versioning, + }; + } + + const deprecation = MCP_DEPRECATED_VERSIONS[negotiated]; + const unknown = !MCP_KNOWN_VERSIONS.includes(negotiated); + + if (deprecation) { + return { + passed: false, + ruleId: 'protocol-version', + message: `Protocol version "${negotiated}" is DEPRECATED — ${deprecation} Upgrade to ${MCP_LATEST_VERSION}.`, + severity: 'critical', + docUrl: MCP_SPEC_URLS.versioning, + note: `Latest is ${MCP_LATEST_VERSION}. Pre-2025-06-18 revisions lack Resource Indicators (RFC 8707) and Protected Resource Metadata (RFC 9728).`, + }; + } + + if (unknown) { + return { + passed: false, + ruleId: 'protocol-version', + message: `Protocol version present but unrecognized: "${negotiated}".`, + severity: 'critical', + docUrl: MCP_SPEC_URLS.versioning, + }; + } + + if (MCP_LEGACY_VERSIONS.includes(negotiated)) { + return { + passed: true, + ruleId: 'protocol-version', + message: `Protocol version present: "${negotiated}" (superseded by ${MCP_LATEST_VERSION})`, + severity: 'critical', + docUrl: MCP_SPEC_URLS.versioning, + note: `${negotiated} is a published revision but predates the stateless model introduced in ${MCP_LATEST_VERSION}.`, + }; + } + + return { + passed: true, + ruleId: 'protocol-version', + message: `Protocol version present: "${negotiated}"${ + conn.supportedVersions ? ` (server supports ${conn.supportedVersions.join(', ')})` : '' + }`, + severity: 'critical', + }; +} + +function deprecatedFeatureRule(conn: MCPConnection): RuleResult { + const caps = conn.capabilities || {}; + const declared = ['roots', 'sampling', 'logging'].filter((f) => caps[f] !== undefined); + + return { + passed: declared.length === 0, + ruleId: 'deprecated-features', + message: + declared.length === 0 + ? 'No deprecated features declared' + : `Server declares deprecated capabilities: ${declared.join(', ')}`, + severity: 'info', + docUrl: declared.length ? MCP_SPEC_URLS.deprecated : undefined, + note: declared.length + ? 'Roots, Sampling and Logging are Deprecated as of 2026-07-28 with a minimum twelve-month removal window. Suggested migrations: pass directories/files as tool parameters or resource URIs instead of Roots, integrate with an LLM provider API directly instead of Sampling, and log to stderr or OpenTelemetry instead of Logging.' + : undefined, + }; +} + +function capabilityRules(conn: MCPConnection): RuleResult[] { + const caps = conn.capabilities; + const hasCaps = caps !== null && typeof caps === 'object'; + + return [ + { + passed: hasCaps, + ruleId: 'capabilities-object', + message: hasCaps ? 'Capabilities object present' : 'MISSING: Server must return a "capabilities" object.', + severity: 'warning', + docUrl: hasCaps ? undefined : MCP_SPEC_URLS.basic, + }, + { + passed: true, + ruleId: 'tools-capability', + message: caps?.tools ? 'Tools capability declared' : 'Tools not declared (optional)', + severity: 'info', + }, + { + passed: true, + ruleId: 'resources-capability', + message: caps?.resources ? 'Resources capability declared' : 'Resources not declared (optional)', + severity: 'info', + }, + ]; +} + +// ─── Modern (2026-07-28) rules ─────────────────────────────────────────────── + +function modernRules( + conn: MCPConnection, + probes: ProbeResults, + auth: AuthOutcome +): RuleResult[] { + const results: RuleResult[] = []; + + results.push({ + passed: conn.era === 'modern', + ruleId: 'protocol-era', + message: + conn.era === 'modern' + ? `Server speaks the stateless protocol (${MCP_MODERN_VERSIONS.join(', ')})` + : `FAILED: Server answered neither "server/discover" nor "initialize"${ + conn.error ? ` — ${conn.error}` : '' + }.`, + severity: 'critical', + docUrl: MCP_SPEC_URLS.versioning, + note: + conn.era === 'modern' + ? undefined + : 'The scanner probes server/discover at 2026-07-28 and falls back to the initialize handshake. Verify the URL accepts JSON-RPC POST requests.', + }); + + results.push(versionRule(conn)); + + results.push({ + passed: conn.probe.discoverImplemented, + ruleId: 'server-discover', + message: conn.probe.discoverImplemented + ? 'server/discover implemented' + : 'FAILED: Servers MUST implement "server/discover" to advertise supported versions, capabilities and identity.', + severity: 'critical', + docUrl: MCP_SPEC_URLS.discover, + }); + + const supported = conn.supportedVersions; + results.push({ + passed: Array.isArray(supported) && supported.length > 0, + ruleId: 'supported-versions', + message: Array.isArray(supported) && supported.length > 0 + ? `Server advertises supported versions: ${supported.join(', ')}` + : 'MISSING: server/discover must return "supportedVersions".', + severity: 'warning', + docUrl: MCP_SPEC_URLS.discover, + }); + + const hasIdentity = !!(conn.serverInfo?.name && conn.serverInfo?.version); + results.push({ + passed: hasIdentity, + ruleId: 'server-info', + message: hasIdentity + ? `Server info present: "${conn.serverInfo?.name}" v${conn.serverInfo?.version}` + : `MISSING: Servers SHOULD identify themselves via "${MCP_META_KEYS.serverInfo}" in each result's _meta.`, + severity: 'warning', + docUrl: MCP_SPEC_URLS.basic, + }); + + results.push(...capabilityRules(conn)); + + // resultType is required on every result from 2026-07-28. + const discoverType = resultTypeOf(conn.discover); + const toolsType = probes.toolsList.resultType; + const observed = [discoverType, toolsType].filter(Boolean) as string[]; + const missing = (conn.discover && !discoverType) || (probes.toolsList.ok && !toolsType); + results.push({ + passed: observed.length > 0 && !missing, + ruleId: 'result-type', + message: missing + ? 'MISSING: Results must carry a "resultType" field ("complete" or "input_required").' + : observed.length > 0 + ? `Results carry resultType: ${[...new Set(observed)].join(', ')}` + : 'Could not observe a result to check "resultType".', + severity: 'critical', + docUrl: MCP_SPEC_URLS.basic, + }); + + // tools/list is a CacheableResult: ttlMs + cacheScope are required. + const hints = cacheHintsOf(probes.toolsList.result); + results.push({ + passed: !probes.toolsList.ok || hints.valid, + ruleId: 'cacheable-results', + message: !probes.toolsList.ok + ? 'tools/list not available — cache hints not checked' + : hints.valid + ? `Cache hints present on tools/list (ttlMs: ${hints.ttlMs}, cacheScope: ${hints.cacheScope})` + : `MISSING/INVALID cache hints on tools/list (ttlMs: ${ + hints.hasTtl ? hints.ttlMs : 'absent' + }, cacheScope: ${hints.hasScope ? hints.cacheScope : 'absent'}).`, + severity: 'warning', + docUrl: MCP_SPEC_URLS.caching, + note: hints.valid + ? undefined + : 'Servers MUST include ttlMs (>= 0) and cacheScope ("public" or "private") on complete results from server/discover, tools/list, prompts/list, resources/list, resources/templates/list and resources/read.', + }); + + const hv = probes.headerValidation; + results.push({ + passed: hv.ran && !hv.accepted, + ruleId: 'header-validation', + message: !hv.ran + ? 'Header validation not tested' + : hv.accepted + ? 'FAILED: Server processed a request whose Mcp-Method header contradicted the body.' + : `Server rejected a mismatched Mcp-Method header (HTTP ${hv.status}${ + hv.errorCode ? `, JSON-RPC ${hv.errorCode}` : '' + })`, + severity: 'critical', + docUrl: MCP_SPEC_URLS.transport, + note: + hv.ran && !hv.accepted && hv.errorCode !== MCP_ERROR_CODES.headerMismatch + ? `Rejected, but not with HeaderMismatch (${MCP_ERROR_CODES.headerMismatch}). The spec requires 400 Bad Request with that code so intermediaries can distinguish it from other failures.` + : hv.accepted + ? 'Mcp-Method/Mcp-Name mirror body fields so gateways can route without parsing the body. A server that does not validate them lets an intermediary be routed one way while the server executes another.' + : undefined, + }); + + const sessionMinted = !!conn.sessionId; + const getStatus = probes.getMethod.status; + const getRejected = !probes.getMethod.ran || getStatus === 405 || getStatus === 404 || getStatus === 0; + results.push({ + passed: !sessionMinted && getRejected, + ruleId: 'stateless-transport', + message: sessionMinted + ? `FAILED: Server minted an Mcp-Session-Id ("${conn.sessionId}"); protocol-level sessions were removed in 2026-07-28.` + : !getRejected + ? `FAILED: GET on the MCP endpoint returned ${getStatus}; the standalone SSE endpoint was replaced by subscriptions/listen and GET should return 405.` + : 'Transport is stateless (no session ID minted, GET not served)', + severity: 'warning', + docUrl: MCP_SPEC_URLS.transport, + note: + sessionMinted || !getRejected + ? 'State that spans requests must be referenced by an explicit identifier passed as an ordinary parameter, not inferred from the connection.' + : undefined, + }); + + results.push(deprecatedFeatureRule(conn)); + + results.push({ + passed: auth.passed, + ruleId: 'authentication', + message: auth.message, + severity: 'critical', + docUrl: MCP_SPEC_URLS.authorization, + note: auth.note, + }); + + return results; +} + +// ─── Legacy (handshake-era) rules ──────────────────────────────────────────── + +function legacyRules(conn: MCPConnection, probes: ProbeResults, auth: AuthOutcome): RuleResult[] { + const results: RuleResult[] = []; + + results.push({ + passed: false, + ruleId: 'protocol-era', + message: `Server is handshake-based (initialize/${conn.protocolVersion}); the current revision ${MCP_LATEST_VERSION} is stateless.`, + severity: 'warning', + docUrl: MCP_SPEC_URLS.versioning, + note: `MCP ${MCP_LATEST_VERSION} removed the initialize/notifications/initialized handshake and the Mcp-Session-Id header. Every request now carries its protocol version, client identity and capabilities in params._meta, and servers must implement server/discover. Supporting both eras on the same endpoint ("dual-era") is allowed while clients migrate.`, + }); + + results.push(versionRule(conn)); + + const hasIdentity = !!(conn.serverInfo?.name && conn.serverInfo?.version); + results.push({ + passed: hasIdentity, + ruleId: 'server-info', + message: hasIdentity + ? `Server info present: "${conn.serverInfo?.name}" v${conn.serverInfo?.version}` + : 'MISSING: Server must return "serverInfo" with "name" and "version".', + severity: 'critical', + docUrl: MCP_SPEC_URLS.basic, + }); + + results.push(...capabilityRules(conn)); + + results.push({ + passed: !!conn.initialize?.serverInfo, + ruleId: 'initialize-method', + message: conn.initialize?.serverInfo + ? 'Initialize request handled correctly' + : 'FAILED: Server did not handle the "initialize" JSON-RPC request.', + severity: 'critical', + docUrl: MCP_SPEC_URLS.versioning, + }); + + results.push({ + passed: conn.probe.discoverImplemented, + ruleId: 'server-discover', + message: conn.probe.discoverImplemented + ? 'server/discover implemented alongside the handshake (dual-era server)' + : 'Not implemented: server/discover is required by 2026-07-28 and is how modern clients detect this server.', + severity: 'warning', + docUrl: MCP_SPEC_URLS.discover, + }); + + results.push({ + passed: probes.ping.ok, + ruleId: 'ping-method', + message: probes.ping.ran ? probes.ping.message : 'Ping not tested', + severity: 'info', + note: 'ping was removed in 2026-07-28 — a stateless server has no handshake to keep alive. It remains valid on handshake-era revisions.', + }); + + results.push({ + passed: probes.toolsList.ok, + ruleId: 'tools-list', + message: probes.toolsList.ok + ? `tools/list returned ${probes.toolsList.result?.tools?.length ?? 0} tool(s)` + : 'tools/list did not return a result (server may not expose tools)', + severity: 'info', + }); + + results.push(deprecatedFeatureRule(conn)); + + results.push({ + passed: auth.passed, + ruleId: 'authentication', + message: auth.message, + severity: 'critical', + docUrl: MCP_SPEC_URLS.authorization, + note: auth.note, + }); + + return results; +} diff --git a/apps/web/src/app/api/mcp/tools/route.ts b/apps/web/src/app/api/mcp/tools/route.ts index f4788b3..c471576 100644 --- a/apps/web/src/app/api/mcp/tools/route.ts +++ b/apps/web/src/app/api/mcp/tools/route.ts @@ -1,7 +1,17 @@ import { NextRequest, NextResponse } from 'next/server'; +import { + buildBaseHeaders, + cacheHintsOf, + connectMCP, + mcpCall, + resultTypeOf, +} from '@/lib/mcp'; /** - * List tools from an MCP server via JSON-RPC tools/list method + * List tools from an MCP server. + * + * Works against both eras: stateless servers (2026-07-28) are reached via + * `server/discover` + per-request `_meta`, handshake servers via `initialize`. */ export async function POST(request: NextRequest) { try { @@ -11,83 +21,30 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Server URL required' }, { status: 400 }); } - const headers: Record = { - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', - }; + const baseHeaders = buildBaseHeaders({ authType, authValue, authHeader, customHeaders }); + const conn = await connectMCP(serverUrl, baseHeaders); - if (authType && authType !== 'none' && authValue) { - if (authType === 'api_key') { - headers[authHeader || 'Authorization'] = authValue; - } else if (authType === 'bearer') { - headers[authHeader || 'Authorization'] = `Bearer ${authValue}`; - } else if (authType === 'basic') { - headers[authHeader || 'Authorization'] = `Basic ${Buffer.from(authValue).toString('base64')}`; - } + if (conn.era === 'unknown') { + return NextResponse.json( + { error: conn.error || 'Could not connect to the MCP server' }, + { status: 502 } + ); } - // Apply custom headers - if (Array.isArray(customHeaders)) { - for (const h of customHeaders) { - if (h.key && h.value) headers[h.key] = h.value; - } - } - - // First, initialize the server - const initResponse = await fetch(serverUrl, { - method: 'POST', - headers, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'protocol-guard', version: '1.0.0' }, - }, - }), - }); - - const initJson = await parseResponse(initResponse); - const initResult = initJson?.result || initJson; - - // Send initialized notification - try { - await fetch(serverUrl, { - method: 'POST', - headers, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'notifications/initialized', - params: {}, - }), - }); - } catch { - // Some servers don't support notifications, that's ok - } - - // Now list tools - const toolsResponse = await fetch(serverUrl, { - method: 'POST', - headers, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - params: {}, - }), - }); - - const toolsJson = await parseResponse(toolsResponse); - const toolsResult = toolsJson?.result || toolsJson; + const tools = await mcpCall(conn, serverUrl, baseHeaders, 'tools/list'); + const hints = cacheHintsOf(tools.result); return NextResponse.json({ - serverInfo: initResult?.serverInfo || null, - protocolVersion: initResult?.protocolVersion || null, - capabilities: initResult?.capabilities || null, - tools: toolsResult?.tools || [], - raw: { init: initJson, tools: toolsJson }, + serverInfo: conn.serverInfo, + protocolVersion: conn.protocolVersion || null, + protocolEra: conn.era, + supportedVersions: conn.supportedVersions || null, + capabilities: conn.capabilities, + instructions: conn.instructions || null, + resultType: resultTypeOf(tools.result) || null, + cache: hints.hasTtl || hints.hasScope ? { ttlMs: hints.ttlMs, cacheScope: hints.cacheScope } : null, + tools: tools.result?.tools || [], + raw: { handshake: conn.raw, tools: tools.raw }, }); } catch (error) { return NextResponse.json( @@ -96,19 +53,3 @@ export async function POST(request: NextRequest) { ); } } - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function parseResponse(response: Response): Promise { - const contentType = response.headers.get('content-type') || ''; - - if (contentType.includes('text/event-stream')) { - const text = await response.text(); - const dataLine = text.split('\n').find(l => l.startsWith('data:')); - if (dataLine) { - return JSON.parse(dataLine.replace(/^data:\s*/, '')); - } - return { _rawSSE: text }; - } - - return response.json(); -} diff --git a/apps/web/src/app/dashboard/mcp/page.tsx b/apps/web/src/app/dashboard/mcp/page.tsx index c26f246..c323331 100644 --- a/apps/web/src/app/dashboard/mcp/page.tsx +++ b/apps/web/src/app/dashboard/mcp/page.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import Link from 'next/link'; import { Navbar } from '@/components/Navbar'; +import { MCP_LATEST_VERSION } from '@/lib/mcp'; import { AcknowledgmentGate } from '@/components/AcknowledgmentGate'; import { ChevronDown, ChevronUp, ExternalLink, Server, CheckCircle, XCircle, @@ -26,6 +27,10 @@ interface ComplianceResult { interface ComplianceReport { timestamp: string; protocolVersion: string; + requestedVersion?: string; + latestVersion?: string; + protocolEra?: 'modern' | 'legacy' | 'unknown'; + supportedVersions?: string[] | null; specUrl: string; serverName: string; results: ComplianceResult[]; @@ -304,7 +309,7 @@ export default function MCPPage() {

- Spec version: 2024-11-05 + Spec version: {MCP_LATEST_VERSION} · @@ -465,7 +470,11 @@ export default function MCPPage() { {activeTab === 'compliance' && (
- Testing against MCP 2024-11-05 + Testing against MCP {MCP_LATEST_VERSION}. + The scanner probes server/discover at the stateless + revision first and falls back to the initialize{' '} + handshake for servers on {'\u2264'} 2025-11-25, then grades each server against the + rules for its own era.