Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) |
Expand Down
159 changes: 110 additions & 49 deletions apps/web/src/app/api/mcp/call-tool/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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(
Expand All @@ -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<any> {
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<string, string>,
conn: { protocolVersion?: string },
toolName: string
): Promise<Json> {
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;
}
Loading
Loading