# MCP client preflight (proposal, v0.1)

Status: proposal, 7 October 2026. Reference middleware: `@crawlcheck/sdk` 1.0.9 (`withPreflight`) and `crawlcheck` 1.0.9 for Python (`crawlcheck.mcp.with_preflight`). Comments: https://github.com/emmanuelorta/crawlcheck-core/issues

## Problem

An MCP client decides to connect to a remote server, and later to call its tools, on nothing but a URL and the server's own description of itself. Tool descriptions are written by the server; a server can change its tools after a person approved them; a lookalike host can serve the same tool names as the real one. Today every safeguard is a library an agent author remembers to call. Nothing in the protocol path asks "should this client talk to this host at all?" before `initialize`, or "is this still the tool set a person approved?" before `tools/call`.

## Proposal

A client-side, optional extension point with two hooks, run by the client before it sends the request:

| Hook | Runs before | Input | Outcome |
|---|---|---|---|
| `beforeConnect` | `initialize` (no bytes sent yet) | server URL | proceed, or refuse with a reason |
| `beforeToolCall` | each `tools/call` | tool name, tool annotations | proceed, or refuse with a reason |

and one optional check after the handshake:

| Hook | Runs after | Input | Outcome |
|---|---|---|---|
| `afterConnect` | `tools/list` | listed tools | proceed, or refuse and close the session |

Rules:

1. A refusal means the request is never sent. The client surfaces the refusal to the agent or the user as an error carrying `stage` (`connect` or `tools/call`) and a human-readable reason.
2. The hooks never alter a request, never read tool arguments, and never call a tool.
3. A decision may require a person: the client asks through its existing confirmation UI (the same one it uses for tool approval) and proceeds only on an explicit yes.
4. A decision is cached for the session only until its own expiry; after that the host is decided again before the next `tools/call`.
5. Local (stdio) servers have no host; clients choose whether they pass (default) or are refused.
6. The decision provider is pluggable. The extension defines the hook points and the refusal shape, not who decides.

## Decision provider used by the reference middleware

The reference middleware decides with CrawlCheck preflight: `POST https://crawlcheck.io/api/v1/preflight {domain, action: "connect", template: "safe_mcp_tool"}` returns a signed decision receipt (`allow`, `warn`, `require_confirmation`, `block`, `unsupported`). The middleware verifies the receipt's Ed25519 signature against the published key directory before acting on it; an unverifiable receipt is a refusal. `warn` proceeds only if the client opts in. `require_confirmation` asks a person. `block` and `unsupported` refuse.

Optional pinning: with an approved MCP tool lockfile (https://crawlcheck.io/spec/mcp-lockfile), `afterConnect` refuses when the listed tools differ (added, removed or changed description, schema or annotations), and `beforeToolCall` refuses any tool outside the lock. With `confirmDestructive`, a tool annotated `destructiveHint: true` (or `readOnlyHint: false` with `openWorldHint: true`) asks a person before each call.

## Wire shape of a refusal

```json
{ "stage": "connect", "why": "nothing vouches for a connection to mcp.example.com", "receipt": { "kind": "crawlcheck-decision", "decision": "unsupported", "sha256": "…", "signature": { "kid": "…" } } }
```

The receipt is the provider's evidence and can be verified offline (`node crawlcheck-verify.mjs receipt.json`).

## Reference middleware

TypeScript, wrapping the official `Client`:

```js
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { CrawlCheck, withPreflight, McpPreflightRefused } from "@crawlcheck/sdk";

const client = withPreflight(new Client({ name: "my-agent", version: "1.0.0" }), new CrawlCheck(), { confirm: askTheUser, lock: approvedLock });
await client.connect(new StreamableHTTPClientTransport(new URL(serverUrl)));   // throws McpPreflightRefused before initialize
await client.callTool({ name: "search", arguments: { q: "…" } });              // throws before tools/call when refused
```

Python, wrapping `ClientSession` (MCP Python SDK):

```python
from crawlcheck import CrawlCheck
from crawlcheck.mcp import McpPreflight, with_preflight
cc = CrawlCheck(); pf = McpPreflight(cc, confirm=ask_the_user)
await pf.before_connect(url)                         # refused before any transport opens
async with streamable_http_client(url) as (read, write):
    async with with_preflight(ClientSession(read, write), cc, server_url=url, preflight=pf) as s:
        await s.initialize()
        await s.call_tool("search", {"q": "…"})
```

Other stacks use the bare hooks: `mcpPreflight(cc, opts)` returns `{ beforeConnect, afterConnect, beforeToolCall }`.

## Why in the protocol path

Checks that live in an SDK the agent author adds are skipped by every author who does not add them. A hook point the client runs by default (with a provider the user or the organisation configures) makes "decide before connecting" the normal path, the same way certificate validation is not something each HTTP caller opts into.

## Open questions

- Should a server be able to advertise a preferred decision provider (for example in its server card), and should clients ever honour it? This proposal says no: the provider is the client's choice.
- Should refusals be reported back to the server (a notification) so an operator sees why clients stop connecting? Not in v0.1.
