Episode 18 dissects the internals of the MCP SDK: building custom transports with WebSocket and gRPC, JSON-RPC framing, notification batching, protocol logging, and JSON-RPC and ISO-JSON-RPC error codes.

In episode 17 you learned version negotiation between the modern 2026-07-28 era and the legacy 2025-11-25 era, complete with the dual-era strategy. Now we go into the deepest parts of the MCP SDK: no longer just using the built-in transports, but building your own custom transports — WebSocket and gRPC — while also understanding JSON-RPC framing, notification batching, protocol logging, and error codes.
A transport is the abstraction wrapping how JSON-RPC messages move between client and server. The SDK provides three built-in transports: stdio for local processes, Streamable HTTP for remote, and SSE which is now legacy. They all share the same contract — an interface with methods for starting the connection, sending messages, and closing the connection. Because the contract is standard, we can swap in any implementation: WebSocket, gRPC, MQTT, even a message queue.
interface Transport {
onmessage: ((message: JSONRPCMessage) => void) | undefined
onclose: (() => void) | undefined
onerror: ((error: Error) => void) | undefined
start(): Promise<void>
send(message: JSONRPCMessage): Promise<void>
close(): Promise<void>
}The principle is simple: as long as a class implements this contract, the MCP server or client doesn't care whether messages go over TCP, a Unix socket, or another process pipe.
When is WebSocket better than Streamable HTTP? When you need persistent, low-latency bidirectional communication — for example an MCP server riding on a real-time infrastructure. The implementation means creating a class that converts WebSocket events into onmessage calls.
import { WebSocketServer, WebSocket } from 'ws'
class WsServerTransport {
onmessage: ((message: unknown) => void) | undefined
onclose: (() => void) | undefined
constructor(private server: WebSocketServer) {}
start(): Promise<void> {
this.server.on('connection', (socket) => {
socket.on('message', (raw) => {
this.onmessage?.(JSON.parse(raw.toString()))
})
socket.on('close', () => this.onclose?.())
})
return Promise.resolve()
}
async send(message: unknown): Promise<void> {
const payload = JSON.stringify(message)
for (const client of this.server.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload)
}
}
}
close(): Promise<void> {
return new Promise((resolve) => this.server.close(resolve))
}
}Note: this transport doesn't understand the message content — it just wraps JSON into frames and hands parsing to the SDK. A transport is a pure bridge, not business logic. You can test this transport with npx @modelcontextprotocol/inspector through a WebSocket endpoint, then list and call tools as usual.
WebSocket is just one option. In enterprise environments, gRPC is often chosen for bidirectional streaming, protobuf schemas, and HTTP/2. A gRPC transport maps each JSON-RPC message to a streaming method — for example a single Exchange RPC that accepts a message stream and returns a response stream, with a JsonRpcMessage wrapping the JSON inside protobuf.
gRPC's strengths are built-in backpressure and multiplexing; its weakness is that the default MCP ecosystem speaks JSON-RPC, so an adapter is needed on each side. The principle remains the same for other transports: keep the Transport contract, everything else is an adapter pattern.
Every message is JSON-RPC 2.0 in one of three forms: a request using an id, a response matching the request's id, and a notification without an id that needs no answer. Framing means determining the boundaries between messages within a single stream — for text-based protocols like WebSocket, the built-in frames handle it; for stdio, messages are separated by HTTP-style content length headers.
{
"jsonrpc": "2.0",
"id": "req-001",
"method": "tools/call",
"params": {
"name": "fetch_url",
"arguments": {
"url": "https://example.com"
}
}
}The req-001 id lets the client match a response to a specific request even when many requests run concurrently (multiplexing). That's why id must be unique per session — and in the MRTR era, it's reused across a multi-step conversation but must still be distinguishable.
Notifications are JSON-RPC messages without an id — the client and server don't wait for an answer. Examples include notifications/cancelled, notifications/progress, and notifications/resources/updated.
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "prog-7",
"progress": 50,
"total": 100
}
}Batching doesn't mean piling JSON into one array — JSON-RPC is one message per frame. What gets batched is delivery: several notifications that don't wait for a reply are merged into one transport write cycle, for example when progress rises several times in a single second. For requests that wait for answers, reduce their number with MRTR and pagination, not batching.
When something fails, the SDK returns an error object containing code, message, and optional data. Before the 2026-07-28 spec, error codes followed classic JSON-RPC:
| Code | Name | Meaning |
|---|---|---|
| -32700 | Parse error | The JSON could not be parsed |
| -32600 | Invalid Request | The request shape is invalid |
| -32601 | Method not found | The method is unknown to the server |
| -32602 | Invalid params | Parameters failed validation |
| -32603 | Internal error | Unexpected internal error |
| -32000 to -32099 | Server error | Free range for implementations |
The 2026-07-28 spec adopted ISO-JSON-RPC, which institutionalizes the error structure: the envelope stays the same, but data now uses a structured schema consistent across implementations, separating protocol-level errors from application-level ones. The good news: the five classic codes above still apply — your old codes don't break, they just gain additional structure.
{
"jsonrpc": "2.0",
"id": "req-001",
"error": {
"code": -32602,
"message": "Invalid params",
"data": {
"type": "validation_error",
"issues": ["arguments.url harus berupa URL valid"]
}
}
}When debugging, error messages alone aren't enough — you need a record of everything in and out. The easiest pattern is wrapping an existing transport with a decorator that logs every message before forwarding it.
class LoggingTransport {
onmessage: ((message: unknown) => void) | undefined
constructor(
private inner: Transport,
private sink: (direction: string, message: unknown) => void
) {}
async send(message: unknown): Promise<void> {
this.sink('SEND', message)
await this.inner.send(message)
}
async start(): Promise<void> {
this.inner.onmessage = (m) => {
this.sink('RECV', m)
this.onmessage?.(m)
}
return this.inner.start()
}
}Apply it on both sides, send to structured logs, and you have a complete protocol trail — request/response pairs, notifications, and timing. Don't log tokens or sensitive data: just metadata and a brief version of the message, especially in production.
Episode 18 opened up the SDK internals: the Transport contract that can be swapped for WebSocket or gRPC, JSON-RPC framing that determines message boundaries, notification batching to cut round-trips, JSON-RPC and ISO-JSON-RPC error codes, and protocol logging for debugging.
Key takeaways:
Transport contract, MCP doesn't care what it runs on top of.id, responses match the id, and notifications without an id need no answer — this is the basis of multiplexing.In the next episode 19 we hunt for performance: payload optimization, batching, resource caching, connection pooling, and how to scale MCP from a single host to many-to-many. See you there!