For Local MCP, Use stdio With Discipline
Locally, the host launches the server as a child process and talks to it over stdio. The subtlety is that stdin and stdout belong entirely to the JSON-RPC protocol frames - so one stray console.log can break the connection by mixing garbage into the message stream. All diagnostic output goes to stderr, and that's not a matter of style but a condition of working at all.
The server is started by a helper that can serve both modern and legacy peers, and writes errors precisely to stderr.
export function startStdioServer(): void {
serveStdio(() => createDeveloperMcpServer(), {
legacy: "serve",
onerror: (error) => console.error("MCP server error:", error)
});
}
if (import.meta.url === `file://${process.argv[1]}`) {
startStdioServer();
}The host, in turn, brings up the child process and opens a transport to it.
export async function runMcpDemo(): Promise<McpDemoResult> {
const serverScript = fileURLToPath(new URL("./server.ts", import.meta.url));
const transport = new StdioClientTransport({
command: process.execPath,
args: ["--import", "tsx", serverScript],
cwd: process.cwd(),
stderr: "pipe"
});
const client = new Client(
{ name: "developer-release-client", version: "1.0.0" },
{
versionNegotiation: { mode: "auto" },
enforceStrictCapabilities: true,
defaultCacheTtlMs: 5_000
}
);This transport isn't good everywhere, and the limits of applicability are worth drawing at once. stdio fits an IDE plugin, a desktop host, a local CLI - when the server is installed next to the host and works with a local workspace. It doesn't fit when there are many clients over the network, when you need independent scaling, browser-origin, or centralized OAuth authorization - that's where the remote transport begins, which gets its own chapter.
For the local scenario, meanwhile, there are a few operational rules whose violation costs the most.
- On stdout - protocol messages only. For logs - stderr.
- Pass secrets through the process environment only from a trusted launcher and with a minimal set.
- Close the child process on disconnect, timeout, and host shutdown.
- Don't accept an arbitrary command string from the model: the executable and the args are set by trusted configuration.
- In the integration test actually spawn the process - otherwise you're not testing framing and shutdown.
The classic breakage.console.log("Server started")on stdout looks to the client like garbage instead of JSON-RPC. In the companion projectonerrorusesconsole.error, that is stderr - and the connection stays clean.
We launch the server - now let's write a client that finds and calls it. And the key word here is discovery.