Skip to content

14 min read

Testing a server under two clients

On this page

The claim made for an MCP server is that any compliant client can connect and use it without you writing anything for that client. That is a property of the server, it is testable, and the test is simply to run the thing under a second client. What makes the exercise worth doing is not the running, which will eventually succeed no matter what is true, but deciding beforehand what observation would count as a failure.

Without that decision the procedure is "make it work twice", which always succeeds, because a sufficiently determined engineer can make anything work twice. The interesting version fixes the claim first: the server's implementation and its configuration do not change between clients. The protocol pays for itself by making the second consumer free, so the observation that matters is whether the second consumer cost anything at all beyond a config entry naming how to reach the process.

The reason a test needs its failure condition fixed in advance is not a matter of rigour for its own sake, and the mechanism is worth spelling out. An experiment tells you something only when at least one possible outcome would have counted against the claim. Run a server under a second client with no such outcome nominated, and every result confirms the claim: it works, so the server is portable; it takes an afternoon and then works, so the server is portable and the afternoon was setup; it needs a wrapper and then works, so the server is portable and the wrapper is configuration. Each of those readings is available after the fact, and which one gets written down depends on who is tired. Naming the failure condition first — a difference in the left-hand column below — removes that freedom, because the observation is then made against a commitment rather than reinterpreted against a preference. The claim gains its ability to be wrong, which is the only thing that makes confirming it worth anything, and the property being claimed — portability across clients you did not write — is one that nothing else in a build pipeline ever checks.

What must be identical, and what is only ceremony

Must be identicalMay differ
the server source, byte for bytewhich file the config entry lives in
the launch command and its argumentsthe config format — JSON, TOML, a CLI add command
the environment the server needsthe key name wrapping the entry
the tool list and schemas servedhow the client presents tools to the user
the results returnedwhether the client is a GUI, an editor or a terminal

The right-hand column is client ceremony and it is not evidence of anything. One application keeps its servers in ~/Library/Application Support/…/config.json under an mcpServers key, another keeps them in a project-local TOML file, a third has you run agent mcp add tickets -- python server.py. Three different ways to write down the same fact. None of them asks you to change what the server is or how it is started, which is the only thing the claim was ever about.

The left-hand column is the test. If the launch command differs, if one client needs an environment variable the other does not, if the tool list is filtered differently on the server side, or if a result is shaped differently depending on who asked, then something client-specific exists and somebody is maintaining it. That the something happens to be six lines of bash rather than 150 lines of TypeScript changes the size of the adapter and not its existence.

Choosing the second client well is most of the value of the exercise. Two clients built on the same SDK, or two applications from the same vendor, will make the same assumptions and forgive the same defects, so a pass tells you almost nothing. The informative second client is one written by people who have never seen your server: a different implementation language, a different config mechanism, ideally a different lifecycle — pair a long-lived desktop application that holds a session open for hours with a command-line agent that spawns a fresh process per invocation, because the second exposes anything you were caching in module scope or leaking between runs.

What a wrapper script hides

The failure this test most often produces is a small shell script, and it is worth reading one closely because every line of it is a fact about the server rather than about the client:

bash
#!/usr/bin/env bash
# run-tickets-for-termagent.sh
cd /Users/shared/tickets || exit 1
export TICKETS_DB=/Users/shared/tickets/tickets.db
export PYTHONPATH=/Users/shared/tickets
source /Users/shared/tickets/.venv/bin/activate
exec python -u server.py "$@"

None of it is about the second client. The cd fixes a server that opens tickets.db by relative path and therefore only works when launched from one directory. The PYTHONPATH fixes an import that only resolves when the process starts in the repository root. The source fixes a dependency set that lives in a virtualenv the server does not know how to find. Each of these is a packaging defect in the server, and the first client tolerated all of them only because it happened to launch processes with a working directory that made them true.

Each line in that script names a thing the server reads from its environment without declaring that it does, and the pattern is the same three steps every time. The server resolves something by an implicit reference — a relative path, a module search path, an interpreter — rather than by an explicit one. An implicit reference is resolved against state the parent process supplies, since that is what "implicit" means here: the value is taken from the surroundings instead of from the server. A stdio client is an arbitrary parent process that supplies whatever surroundings it happens to have, and no two applications happen to have the same ones. So the server works under exactly those parents whose surroundings match the ones it was developed in, and the wrapper is a way of manufacturing those surroundings for a parent that would not otherwise provide them. Written out that way, the script is not a bridge between two clients at all. It is a list of the server's undeclared inputs, discovered by the first parent that did not happen to satisfy them.

That is worth stating plainly because it changes what you should fix. The second client did not reveal an incompatibility; it revealed that the first integration worked by accident. The server was never launchable from an arbitrary directory by an arbitrary parent process, which is precisely the situation every stdio client puts it in, and one client's convenient default had been standing in for a property the server did not have.

The repair is to make the server independent of how it was started, so that the same command works everywhere:

python
import os, sys
from pathlib import Path

DB_PATH = Path(os.environ.get("TICKETS_DB", Path.home() / ".tickets" / "tickets.db"))
if not DB_PATH.exists():
    sys.exit(f"TICKETS_DB not found at {DB_PATH}; set TICKETS_DB or run tickets-init")

Then ship it as an installable entry point — a command name that the package declares and the packaging tool puts on the path — rather than a path to a file in somebody's checkout. The environment contract becomes one documented variable with a sensible default, and the failure when it is unmet is a message naming the path rather than a traceback from inside sqlite3.

Client-specific branches inside the server

Wrapper scripts are the obvious failure and they are easy to spot in a code review. The subtle one lives inside the server, and the temptation is genuine because the underlying observation is true: clients really do render results differently. One desktop application shows a tool result as a formatted table when it is structured content and as a wall of JSON when it is text. A terminal agent shows the text and ignores structure entirely. A third truncates anything over 4,000 characters. So the rows that read beautifully in one place read badly in another, and initialize handed you the client's name and version, sitting right there in the session context.

python
# Do not do this.
if ctx.client_info.name == "partner-desk":
    return "\n".join(f"#{r['id']} {r['title']} [{r['status']}]" for r in rows)
return rows

Six weeks after that lands there are three names in the condition, one of them matching on a version prefix because a client changed its rendering in a minor release, and a bug where a fourth client falls through to the default and gets output nobody has looked at. The branch is an adapter that you now maintain per client, in the one place the whole design was trying to keep client-free, and it is worse than the per-consumer adapters it was meant to replace, because those at least lived in the consuming team's codebase where their own tests covered them.

Why it grows a case per consumer rather than settling down is a property of what the branch is conditioned on. The condition reads a client's name, and a name is not a capability: it does not say whether the client renders tables, it says which product is asking. So the branch is correct only for as long as the mapping from names to rendering behaviour holds, and that mapping is maintained by other people who never agreed to maintain it. A client changes its renderer in a point release and the branch is now wrong for a name it already matches, which is where the version prefix comes from. A client that has never been seen falls through to a default nobody chose deliberately. And the branch cannot be tested, because testing it means running the server under every client whose name it mentions, which is the situation the whole design was avoiding. A conditional on identity acquires a maintenance obligation proportional to the size of a population you do not control, which is the same obligation the per-consumer adapters had, relocated into your repository.

The protocol's answer is to return every representation once and let each client take what it can use. A tool result carries a list of content blocks alongside optional structured output, so the same response can hold a human-readable rendering and the machine-readable rows:

python
return ToolResult(
    content=[TextContent(type="text", text=format_rows(rows))],
    structuredContent={"tickets": rows},
)

Every client receives both. The one with a table renderer reads structuredContent, the terminal agent reads the text, and neither of them appears anywhere in your source. The cost is a few hundred extra bytes per response and one formatting function, which is a small price for deleting a branch that would otherwise grow a case per consumer forever.

There is a boundary case worth naming honestly, because a rigid reading of the rule gets it wrong. If one client speaks only stdio and your server is deployed over HTTP, something has to bridge them, and there are generic proxies that do exactly that. Using an off-the-shelf bridge identically for every client in that situation is a transport adaptation, not a client adaptation, and it does not falsify anything — the server is unchanged and so is every config entry that points through the bridge. Writing your own bridge for one client, with that client's quirks baked into it, is the adapter again wearing a different hat. The distinction is whether the thing in the middle knows which client is on the other side.

That test is the useful one because it is the same test as everything else in this chapter, stated at a different layer. A component that does not know which client is calling cannot have behaviour that varies by client, so its cost is paid once no matter how many clients appear — which is the addition the protocol was bought for. A component that does know acquires a case per client by the argument above, whether it lives in the server, in a wrapper script or in a proxy, because knowing is what makes a case possible — and a single such case falsifies the claim regardless of where it was written down. Applying it to the two artefacts already seen: the six-line bash script names a client in its filename and its config entry, so it fails; a generic stdio-to-HTTP bridge, configured identically for every client that needs it, has nothing in it that could name one, so it passes. The question to ask of anything sitting between a client and a server is not how large it is or where it lives, but whether it could be given a second client tomorrow without being edited.

The scope of a passing run

A passing run tells you something real and bounded: the server is launchable by a parent process that makes no assumptions you depend on, its declarations are complete enough that a client which has never seen it can call it correctly, and no part of its behaviour is conditioned on who is asking. That is exactly the property the topic opened with, and it is the one that makes the fifth consumer free.

It does not tell you the server is correct, since both clients can be handed the same wrong rows and neither will notice. It does not tell you the server is safe to expose, because two clients on your own machine exercise none of the authentication and authorisation that a shared deployment needs. It does not tell you the server scales, since stdio gives each session its own process and the concurrency question never arises. And two clients is a small sample: it catches the errors that come from having tested against exactly one implementation, which is most of them, and it will not catch a client that paginates differently or applies its own schema validation until that client shows up.

There is also a limit on what any amount of testing can establish here. Compliance is not certification — there is a specification and there are SDKs, and there is no conformance suite that a client either passes or fails, so "compliant client" is a statement about intent rather than a guarantee. The population of clients is large and varies in maturity, which is why the second client in your test should be one you did not write and ideally one your team does not control.

The scope of what two clients can settle is worth stating as a proportion rather than as a list, because it is the reason the exercise is worth an afternoon and not a fortnight. The errors this catches are the ones caused by having generalised from a single observation, and a single observation is the situation every server is in until the moment a second client connects. Going from one to two does not sample the population; it does something cruder and more useful, which is to distinguish a property of the server from a property of the one client it grew up next to. That distinction cannot be made at all with one client and is made almost entirely by the second, which is why the third and fourth clients find much less. What they do find is different in kind — a client that paginates, a client that validates schemas strictly — and those are not errors of over-generalisation, they are areas of the specification your server has never been asked about. The first kind is what a second client establishes; the second kind is only ever sampled, however many clients you run.

Portability is therefore a property you can lose without noticing and can only confirm by trying, and the confirmation is worth exactly as much as the failure condition you were willing to state. A server that runs under two clients with one source tree, one launch command and one environment contract has the property. A server that runs under two clients because a file exists somewhere translating one of them does not, and the file is easy to miss precisely because it is small, checked in, and named after the client it serves.

Having established that the same server does run in two places unchanged, the remaining question is what that cost. Every call in both of those runs travelled through the four-step exchange and the serialisation that comes with it, and nothing so far has separated the price of the boundary from the price of the work.

What MCP is and why it exists — read the current version at https://daviddominguez.dev/learn/en/what-mcp-is-and-why-it-exists/testing-a-server-under-two-clients

Back to top