Four MCP transports: stdio, http, sse, websocket — picking the right one NeuroLink built the first MCP server manager to run external tools as subprocesses over stdio, but a deployment at Juspay of a Python-based document analyzer in a Docker container failed because stdio cannot spawn processes across containers. This forced the team to decouple tools from agents, leading to a four-transport architecture (stdio, HTTP, SSE, WebSockets) that addresses lifecycle coupling, network visibility, and debugging issues. We built the first NeuroLink MCP server manager to run external tools as subprocesses, communicating over stdio . It was simple, self-contained, and it worked perfectly for tools that were packaged with the main application. Then we deployed a new tool at Juspay, a Python-based document analyzer running in its own Docker container. The stdio transport failed instantly. The parent NeuroLink process couldn't spawn a process inside a separate container, and even if it could, we had no way to monitor the tool's health, manage its lifecycle, or scale it independently. That failure forced us to decouple the tool from the agent, leading to the four-transport architecture MCP uses today: stdio for simple cases, and HTTP, Server-Sent Events SSE , and WebSockets for robust, networked tool execution. The original MCP transport, stdio , treats a tool like a command-line utility. When you register a server with this transport, the ExternalServerManager spawns the tool's process and attaches to its stdin , stdout , and stderr streams. This is a valid approach for simple, co-located tools. For example, a small Node.js script that lives on the same machine as the NeuroLink agent. js // Example: a local stdio server type MCPServerInfo const localScriptServer: MCPServerInfo = { id: 'local-script-server', name: 'Local Script Server', transport: 'stdio', command: 'node', args: './tools/my-local-script.js', '--mcp' , // ... status, tools, and other fields }; The problem is that this creates a rigid parent-child relationship. Lifecycle Coupling: If the NeuroLink agent restarts, the tool process is killed. If the tool process crashes, the agent might not have a clean way to restart it or understand why it failed. This is a classic cascading failure scenario we try to avoid, as discussed in our post on the MCP Circuit Breaker pattern https://blog.neurolink.ink/posts/mcp-circuit-breaker-pattern// . The ExternalServerManager , located in src/lib/mcp/externalServerManager.ts , has to contain complex logic just to handle unexpected process exits and prevent zombie processes, which is a significant overhead for what should be a simple transport. No Network Visibility: The tool isn't on the network. You can't hit it with curl , you can't put a load balancer in front of it, and you can't easily run it in a separate container managed by Kubernetes or another orchestrator. This also means you can't have multiple agents share a single instance of a resource-intensive tool. Debugging Hell: When something goes wrong, you're limited to parsing text from stderr . There's no structured error format, no HTTP status codes, and no easy way to inspect the state of the tool server. Is the tool hanging, or is it just slow? Is it consuming too much memory? With stdio , you're flying blind. This puts an enormous burden on developers to write tools that are perfectly behaved and produce easily parsable error messages. To solve the containerization problem we saw at Juspay, we introduced the HTTP transport. An MCP server using the http transport is just a standard web server that exposes a specific endpoint for tool calls. NeuroLink's ExternalServerManager doesn't spawn this process. It simply needs to know the URL. js // Example: a remote HTTP server type MCPServerInfo const remoteHttpServer: MCPServerInfo = { id: 'document-analyzer-prod', name: 'Production Document Analyzer', transport: 'http', url: 'https://tools.juspay.in/document-analyzer', // headers / auth are top-level fields see below }; This immediately solves the problems with stdio : The tradeoff is the stateless nature of HTTP. Every executeTool call is a new, independent request. For tools that require a continuous, stateful conversation, the overhead of establishing a new HTTP connection for every message can be inefficient. Moving from a trusted, local stdio process to a networked HTTP or WebSocket server introduces a critical new problem: security. A tool server exposed on the network is a potential vulnerability. It needs to know who is calling it and whether they are authorized. The stdio transport has no concept of authentication; the trust is implicit because the agent process is the parent of the tool process. For networked transports, we explicitly provide configuration for this in MCPServerInfo . The top-level headers field on the server config is the key. It lets you specify headers sent with every request — most commonly an API key or a bearer token. A dedicated auth field handles the common token cases for you. js // Example: an authenticated HTTP server type MCPServerInfo const secureHttpServer: MCPServerInfo = { id: 'secure-internal-tool', name: 'Secure Internal Tool', transport: 'http', url: 'https://tools.internal/secure-tool/execute', headers: { 'Authorization': 'Bearer super-secret-token-from-env', 'X-Request-Source': 'neurolink-mcp' }, // or use the dedicated auth field: auth: { type: 'bearer', token: '...' } }; For WebSockets, authentication is handled similarly during the initial HTTP Upgrade request. The same headers can be passed to verify the client's identity before the protocol switch occurs. This ensures that only trusted NeuroLink agents can connect to and execute your tools, preventing unauthorized access. While HTTP is the workhorse for most tool calls, some tools need a more persistent connection. Server-Sent Events SSE : For when a tool needs to stream updates to the agent. Think of a long-running task like code generation or a data analysis job. The tool can push progress events, logs, or partial results over a single, long-lived connection. The agent listens, but it can't easily talk back. This is a one-way firehose of data from the tool to the agent. WebSockets: For when you need a true two-way conversation. The connection is persistent and full-duplex. This is ideal for highly interactive tools, like a "clarification agent" that asks follow-up questions before executing a task — the kind of elicitation the ElicitationProtocolHandler coordinates it works over any transport, not just WebSockets . In this model, the executeTool can maintain context across multiple message exchanges, which is impossible with stateless HTTP and cumbersome with stdio . The configuration in MCPServerInfo remains simple. You just declare the transport type and the endpoint. js // Example: SSE and WebSocket servers type MCPServerInfo const streamingReportServer: MCPServerInfo = { id: 'streaming-reporter', name: 'Streaming Reporter', transport: 'sse', url: 'https://tools.juspay.in/reports/stream', }; const conversationalAgentServer: MCPServerInfo = { id: 'clarification-agent', name: 'Clarification Agent', transport: 'websocket', url: 'wss://tools.juspay.in/clarify-agent', }; With four different transport types, how does the ExternalServerManager avoid becoming a tangled mess of conditional logic? The answer is a classic software design pattern: the factory. Inside src/lib/mcp/mcpClientFactory.ts , we keep a MCPClientFactory class that exposes a static createClient method. The ExternalServerManager does not know how to speak HTTP, open a WebSocket, or manage stdio pipes. It performs a single action: it hands an MCPServerInfo to the factory, and gets back a connected client with the matching transport already wired up. // src/lib/mcp/mcpClientFactory.ts shape export class MCPClientFactory { static async createClient config: MCPServerInfo, timeout = DEFAULT CLIENT TIMEOUT, : Promise