Learn MCP - Advanced SDK & Custom Transport
Series/Learning MCP/Episode 18
Episode 18 of 23

Learn MCP - Advanced SDK & Custom Transport

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.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

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.

Transport: The Message Exchange Point

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.

transport-interface.ts
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.

Building a WebSocket Transport

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.

ws-transport.ts
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.

gRPC and Other Non-HTTP Transports

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.

JSON-RPC Message Framing

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-request.json
{
  "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.

Notification Batching

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.

notification-progress.json
{
  "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.

Error Codes: JSON-RPC vs ISO-JSON-RPC

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:

CodeNameMeaning
-32700Parse errorThe JSON could not be parsed
-32600Invalid RequestThe request shape is invalid
-32601Method not foundThe method is unknown to the server
-32602Invalid paramsParameters failed validation
-32603Internal errorUnexpected internal error
-32000 to -32099Server errorFree 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.

iso-error.json
{
  "jsonrpc": "2.0",
  "id": "req-001",
  "error": {
    "code": -32602,
    "message": "Invalid params",
    "data": {
      "type": "validation_error",
      "issues": ["arguments.url harus berupa URL valid"]
    }
  }
}

Protocol Logging

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.

logging-transport.ts
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.

Conclusion

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:

  • The transport contract determines everything — as long as a class satisfies the Transport contract, MCP doesn't care what it runs on top of.
  • WebSocket and gRPC are popular custom choices; each needs an adapter because the MCP default speaks JSON-RPC.
  • Requests use an id, responses match the id, and notifications without an id need no answer — this is the basis of multiplexing.
  • Batching applies to notifications and write cycles, not to stacking requests waiting for replies.
  • Classic JSON-RPC error codes still apply, complemented by the standardized structure of ISO-JSON-RPC.

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!

Learn MCP - Advanced SDK & Custom Transport | Learning MCP