Custom Transports

Purpose and Scope

A custom transport is the SDK extension point for moving MCP JSON-RPC messages over a channel that the built-in transports do not know about. The official guide defines a transport as the component that moves JSONRPCMessage values in both directions, then states that connect() accepts an implementation of the Transport interface the same way it accepts built-in transports. Use this page when you need to run MCP over an in-memory loopback, a proprietary socket protocol, a worker bridge, or another framing layer while keeping the MCP client and server APIs unchanged.

Sources: docs/advanced/custom-transports.md

The important design boundary is that the SDK does not inspect your underlying channel. It calls send for outbound MCP messages and expects your implementation to deliver inbound MCP messages by calling onmessage. That means your transport owns channel setup, byte framing, parse errors, connection teardown, and any mapping from host-specific session or routing metadata into the channel you control. The protocol layer stays above that boundary: Client.connect() and McpServer.connect() install callbacks, start the transport, perform the MCP handshake, and then exchange normal tool, resource, prompt, and notification messages over it.

Sources: docs/advanced/custom-transports.md

Relevant Source Files

  • docs/advanced/custom-transports.md — first-party how-to for implementing Transport, honoring callback rules, connecting loopback peers, and framing messages over byte streams.
  • docs/advanced/custom-methods.md — adjacent advanced guide showing how custom JSON-RPC requests and notifications ride on the same connection once a transport is connected.
  • docs/_meta/CONVENTIONS.md — documentation conventions that explain the code-first guide style used by the transport page and its companion examples.
  • docs/.vitepress/theme/index.ts — v2 VitePress theme entry that loads the shared docs styling and banner used to publish the guide.
  • docs/v1/.vitepress/theme/index.ts — v1 theme entry showing that the shared documentation styling is reused across versioned docs sites.
  • docs/.vitepress/theme/custom.css — shared VitePress styling that widens code columns and styles admonitions for guide pages with TypeScript examples.

Sources: docs/advanced/custom-transports.md, docs/advanced/custom-methods.md, docs/_meta/CONVENTIONS.md, docs/.vitepress/theme/index.ts, docs/v1/.vitepress/theme/index.ts, docs/.vitepress/theme/custom.css

Transport Interface Contract

A Transport implementation has three methods and three callbacks. The methods are start, send, and close. The callbacks are onmessage, onerror, and onclose, and the SDK installs them before it starts the transport. start opens the channel or begins reading from it. send accepts a JSONRPCMessage and writes it to the peer. close shuts down local channel state and then invokes onclose so the protocol layer can tear down its side of the connection. Both @modelcontextprotocol/server and @modelcontextprotocol/client export Transport, TransportSendOptions, and JSONRPCMessage, so the same transport class can serve either side when the channel semantics are symmetric.

Sources: docs/advanced/custom-transports.md

import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server';
 
export class LoopbackTransport implements Transport {
    onclose?: () => void;
    onerror?: (error: Error) => void;
    onmessage?: (message: JSONRPCMessage) => void;
 
    private peer?: LoopbackTransport;
 
    static link(a: LoopbackTransport, b: LoopbackTransport): void {
        a.peer = b;
        b.peer = a;
    }
 
    async start(): Promise<void> {
        // Open your channel here.
    }
 
    async send(message: JSONRPCMessage): Promise<void> {
        const peer = this.peer;
        if (!peer) throw new Error('Loopback peer is gone');
        queueMicrotask(() => peer.onmessage?.(message));
    }
 
    async close(): Promise<void> {
        this.peer = undefined;
        this.onclose?.();
    }
}

The callback rules are as important as the TypeScript shape. Do not call start() yourself before handing a transport to Client or Server; connect() installs the callbacks first and then calls start(). If your channel starts reading too early, inbound messages can arrive before onmessage exists and be lost. Treat onerror as an out-of-band report for conditions such as malformed frames or a dropped socket; it is not necessarily fatal. If the sender must observe a failure, throw from send so the request path rejects at the right point.

Sources: docs/advanced/custom-transports.md

Connect Like a Built-In Transport

Once a class satisfies Transport, the high-level SDK surface does not change. The custom transport guide links two loopback endpoints, passes one endpoint to McpServer.connect(), passes the other to Client.connect(), and calls a registered ping tool. The example demonstrates that the initial MCP handshake and later tool invocation both traverse the custom channel. The returned content array is the same value the server handler produced, which proves that a custom transport can be tested through normal MCP calls rather than through private protocol hooks.

Sources: docs/advanced/custom-transports.md

import { Client } from '@modelcontextprotocol/client';
import { McpServer } from '@modelcontextprotocol/server';
 
const server = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
server.registerTool('ping', { description: 'Reply with pong' }, async () => ({
    content: [{ type: 'text', text: 'pong' }]
}));
 
const client = new Client({ name: 'loopback-client', version: '1.0.0' });
 
const serverEnd = new LoopbackTransport();
const clientEnd = new LoopbackTransport();
LoopbackTransport.link(serverEnd, clientEnd);
 
await server.connect(serverEnd);
await client.connect(clientEnd);
 
const result = await client.callTool({ name: 'ping' });
console.log(result.content);

Use that pattern as the first compatibility test for a new transport. Start with a single in-process or local channel and prove that connection setup, request/response correlation, notification delivery, and closure all work under the public APIs. Then add the real channel underneath. When you later support custom JSON-RPC methods, the same connection carries those vendor-prefixed requests and notifications; the custom methods guide shows setRequestHandler, client.request, ctx.mcpReq.notify, and setNotificationHandler working at the protocol layer after a transport is already connected.

Sources: docs/advanced/custom-transports.md, docs/advanced/custom-methods.md

Framing, Sessions, and Send Options

The loopback example passes parsed objects directly, but byte-oriented channels need framing. The guide points to the same helpers used by the stdio transports: ReadBuffer accumulates chunks and yields one parsed message per newline-delimited line, serializeMessage writes a message, and deserializeMessage parses a line you already hold. Put those helpers at the boundary between your socket, stream, or worker message queue and the SDK callback contract. Your code should deliver only complete JSONRPCMessage values to onmessage, and it should route malformed frames to onerror or a thrown send error depending on who needs to observe the failure.

Sources: docs/advanced/custom-transports.md

Session and version information should be handled as channel metadata unless the SDK API you are using exposes a dedicated option for it. The documented contract visible here is message-oriented: the protocol layer sends JSON-RPC messages, while the transport implementation owns the channel lifecycle and any host-specific routing. If your deployment has per-session sockets, worker IDs, or negotiated protocol versions at the channel level, bind those values before connect() starts reading and keep them consistent for the lifetime of the transport. If send receives TransportSendOptions in your implementation, treat those options as per-message context, not as a replacement for the callback lifecycle.

Sources: docs/advanced/custom-transports.md

Cancellation, Closing, and Error Boundaries

Cancellation-sensitive transports need a clean distinction between request failures, channel failures, and normal shutdown. The source guide states that close() must finish by firing the transport's own onclose, because the protocol layer tears down its side from that callback. Do that for local closes and remote closes. If a socket closes while requests are in flight, stop delivering new messages, release peer references, and call onclose exactly through the same path your normal shutdown uses. That keeps client and server cleanup behavior aligned with built-in transports.

Sources: docs/advanced/custom-transports.md

For sender-visible failures, throw from send. That is the right behavior when the peer is gone, a write fails, or your framing code cannot serialize the message. For out-of-band conditions, call onerror with an Error. That is the right behavior for malformed inbound frames, unexpected bytes, or a dropped channel that your implementation reports before or apart from close. If your underlying runtime supports abort signals or cancellation tokens, connect them to your channel operations, but still preserve the SDK-level contract: inbound messages arrive through onmessage, write failures reject send, and final teardown reaches onclose.

Sources: docs/advanced/custom-transports.md

Compact Reference

Contract itemPublic nameBehavior to implement
Inbound message callbackonmessage?: (message: JSONRPCMessage) => voidCall with one complete parsed MCP JSON-RPC message.
Error callbackonerror?: (error: Error) => voidReport malformed frames or dropped-channel conditions that are not necessarily fatal.
Close callbackonclose?: () => voidCall when your transport has closed so the protocol layer can tear down.
Start methodstart(): Promise<void>Open the channel or begin reading after connect() installs callbacks.
Send methodsend(message: JSONRPCMessage): Promise<void>Write one outbound message or throw when the sender must observe failure.
Close methodclose(): Promise<void>Release local state and invoke onclose.
Shared exported typesTransport, TransportSendOptions, JSONRPCMessageAvailable from both @modelcontextprotocol/server and @modelcontextprotocol/client.
Connection entry pointsMcpServer.connect(transport), Client.connect(transport)Accept custom transports like built-in transports.
Byte-stream helpersReadBuffer, serializeMessage, deserializeMessageBuffer, serialize, and parse newline-delimited JSON-RPC messages for stream channels.

Sources: docs/advanced/custom-transports.md

Testing Signals

Test a custom transport through the highest public API you can. The loopback guide registers a tool, connects a server and client over custom endpoints, calls client.callTool({ name: 'ping' }), and observes [ { type: 'text', text: 'pong' } ]. That proves the transport supports the MCP handshake, bidirectional request/response flow, handler execution, and response delivery. Add negative tests around the same contract: sending without a peer should reject, malformed inbound frames should call onerror, and closing should call onclose so the client or server does not hang.

Sources: docs/advanced/custom-transports.md

The docs infrastructure reinforces that examples are meant to be runnable and visible. The conventions file requires code-first guide pages, real observable output, and snippets tied to companion examples. The shared VitePress theme widens the documentation column so TypeScript snippets remain readable, and both v1 and v2 docs import the same styling. For transport work, follow the same discipline in your own examples: show the transport class, connect it through Client and McpServer, display the real output, and keep protocol behavior observable from public SDK calls.

Sources: docs/_meta/CONVENTIONS.md, docs/.vitepress/theme/index.ts, docs/v1/.vitepress/theme/index.ts, docs/.vitepress/theme/custom.css

Next Steps

After the loopback test passes, replace the in-memory peer with the actual channel and keep the same public assertions. If the channel is a byte stream, add newline-delimited framing with the documented helpers before optimizing anything else. If the channel carries extension traffic, read custom-methods next so vendor-prefixed requests and notifications remain schema-validated above the transport boundary. For production use, also review the serving and session-scaling pages that match your runtime so channel metadata, authorization context, and connection lifetime stay consistent across workers.