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.
| Probe | What a positive answer means |
|---|---|
roots/list | The client hands over filesystem paths unprompted. |
elicitation/create | The client will render a form the server wrote and return what the user types. |
sampling/createMessage | The client will run the server’s prompt through its own model. |
ping | Baseline: 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.