Chapter 9

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.

TypeScript
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.

TypeScript
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 project onerror uses console.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.

Links