Inline Sign in

← ARTICLES ENGINEERING 6 min read

A disposable MCP server for finding out what a client gives away

How to measure what an MCP client actually hands a server it has just met — headers, roots, elicitation, sampling — and the transport details that will make the measurement wrong if you get them subtly right.

The question

You point a desktop application’s connector at an MCP server you control. What does it tell you? Not what the specification says it may tell you — what this build, with this user’s settings, actually sends before anyone types anything.

Specifically: does anything reveal the user’s working directory? That one matters because a server that can learn the working directory can tailor its behaviour per project, and because a server that can learn it without asking is a server the user has told more than they think.

The cheapest way to answer this is a throwaway MCP server whose only behaviour is to write down everything it learns, deployed for an afternoon and then deleted. The interesting part is not the idea; it is the handful of transport details that turn a clean negative result into a wrong one.

What the probe records

On initialize it logs, at WARNING so nothing is lost to a log level: every HTTP header on the request as one JSON line, the protocolVersion, the full clientInfo, the complete capabilities object, and the whole params object in case the client sends something undocumented.

Then, on tools/list, before answering, it turns the direction around and makes four requests of the client, in sequence, each with its own request id and its own two-second budget.

ProbeWhat a positive answer means
roots/listThe client hands over filesystem paths unprompted.
elicitation/createThe client will render a form the server wrote and return what the user types.
sampling/createMessageThe client will run the server’s prompt through its own model.
pingBaseline: server-to-client requests work at all.

The sampling prompt is the blunt one — reply with only your current working directory, nothing else — and the elicitation form asks which client folder the session is for. Each probe logs the request it sent and then either the reply or no reply within timeout, after which tools/list is answered normally, so the client never sees an error.

One tool is exposed, probe_report, which returns everything learned about the current session as text, including a one-line directory verdict naming whichever channel gave up a path. The verdict also scans the recorded headers for any value containing workspace, project, directory, folder or cwd, because an identifier can hide in a header just as well as in a reply.

ping earning a row of its own is the part worth copying. Without it, four silences are ambiguous: a client that ignores roots/list and a client whose server-to-client channel is broken look identical. With it, they do not.

Why there is no authentication

There is none, deliberately. Adding auth would mean the probe measures the auth broker’s behaviour as much as the client’s, and the point is to see what an unadorned connector sends on its own.

That decision has two consequences you are accepting rather than avoiding. Anyone with the URL can connect, list and call, so the log is effectively world-writable — harmless, since nothing here touches your data, but it does mean the log is noise-prone. More seriously, every request header is logged verbatim, including any Authorization or Cookie header a client volunteers. That is the instruction, since an identifier could be hiding in any of them, but it means the log may contain a live token.

Which is the real reason the thing is disposable. Run it, read the logs, delete the service. It is not meant to outlive one afternoon, and a probe left running is a credential log left running.

The transport detail that decides whether the result is true

On Streamable HTTP, the question and the answer arrive on separate HTTP requests. The server asks roots/list on an open stream; the client’s reply comes back as a fresh POST. Sessions live in one container’s memory.

Deploy with more than one instance and an answer can land on a container that never asked the question. The asking container sees nothing, times out, and records no reply within timeout — and you conclude the client ignored a probe it in fact answered. The experiment produces a confident, clean, entirely false negative.

gcloud run deploy mcp-probe \
  --source . \
  --region us-west1 \
  --allow-unauthenticated \
  --max-instances 1 \
  --port 8080

--max-instances 1 is load-bearing. And because the guarantee is operational rather than structural, the probe logs when it is violated:

[probe] session <id> unknown here — rebuilding from scratch
[probe] response with nothing waiting for it: {...}

Two related choices fall out of the same fact.

A missing session is rebuilt, not rejected. Consecutive POSTs on this transport are separate connections, so a session that must be found is a session that will sometimes be missing — and rejecting it tells the client to start over mid-conversation. Rebuilding keeps the conversation alive, and the rebuild line in the log is itself the finding: it means questions and answers landed on different instances.

The wait polls a plain dictionary rather than awaiting an asyncio.Event. An Event belongs to the loop that created it. The two halves of a server-to-client round trip here arrive on different HTTP requests, with no promise of sharing an event loop, so an Event is the wrong primitive for reasons that have nothing to do with performance. A dictionary has no loop affinity. For the same reason each request gets a fresh queue, read once into a local, so a reply already in flight still goes where it was going.

These are not micro-optimisations. Each one is a place where the obvious implementation produces a plausible wrong answer rather than a crash, which is the worst failure mode an instrument can have.

Two tests, and a testing-library trap

Only two properties have to hold for the experiment to mean anything.

First, a client that does answer roots/list has its answer recorded against the right probe, and the path shows up in the report. Second, a client that answers nothing still gets its tools/list back — in roughly eight seconds, four probes at two seconds each. A connector that hangs at this point looks broken to the user, and the operator never reaches the interesting part. Eight seconds is the worst case; against a cooperative client the full round trip for all four probes is under a tenth of a second.

Writing the second test surfaced something worth knowing if you test Streamable HTTP servers: Starlette’s TestClient buffers a streaming response body to completion before returning it. Three events yielded half a second apart all arrive at once. So a test cannot watch the stream for a probe request as it goes out, and a cooperative-client fake cannot trigger off seeing one. The answering thread has to trigger off the server’s own outstanding-probe map instead, and the stream contents get asserted afterwards. Under a real ASGI server the events do arrive live; the buffering is an artefact of the test client alone.

The shape, in four lines

The whole transport reduces to this:

POST /mcp  carrying a request       -> SSE stream; the reply arrives on it
POST /mcp  carrying only responses  -> 202, no body
GET  /mcp  with Mcp-Session-Id      -> standby stream
DELETE /mcp                         -> forget the session, log the report

DELETE is the courtesy a well-behaved client extends on disconnect, and the probe uses it to dump a final per-session report on the way out — which is often the only complete record you get, since a connector that is still attached has not finished telling you things.

Build an instrument before you build a policy. The question of what a client will hand a server it has just met is answerable in an afternoon, and the answer is specific to the client, the build and the user’s settings in ways no specification will tell you.