mcp-backplane
v0.1.0
Published
Redis backplane for MCP TypeScript servers: a ServerEventBus that fans subscriptions/listen notifications out across nodes, and an EventStore for resumable sessionful transports.
Maintainers
Readme
mcp-backplane
Redis backplane for MCP TypeScript servers that run on more than one process.
The official SDK ships in-memory implementations for the two places where server state has to cross node boundaries, and its docs tell you to bring your own for real deployments. This package is that implementation:
RedisServerEventBusimplements the v2 SDK'sServerEventBusover Redis pub/sub. Without it,subscriptions/listenbreaks behind a load balancer:handler.notify.toolsChanged()on one node never reaches a subscriber whose stream another node holds.RedisEventStoreimplementsEventStoreover Redis Streams, for sessionful Streamable HTTP deployments (the v1 SDK, or v2's legacy transport). Dropped SSE streams resume from any node viaLast-Event-ID, and survive restarts.
No runtime dependencies. Bring your own Redis client — both node-redis (v4+) and ioredis (v5+) work and are detected automatically.
Install
npm install mcp-backplaneFan notifications out across nodes (v2 SDK)
import { createMcpHandler } from "@modelcontextprotocol/server";
import { RedisServerEventBus } from "mcp-backplane";
import { createClient } from "redis";
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
const bus = new RedisServerEventBus({ pubClient, subClient });
const handler = createMcpHandler(buildServer, { bus });Give every node the same construction and handler.notify.resourceUpdated(uri)
on any node reaches the open subscriptions/listen streams on all of them.
Two clients because Redis dedicates a subscribed connection to pub/sub;
duplicate() in either library gives you the second one. Both libraries
re-subscribe on their own after a reconnect.
Options
| option | default | |
| ----------- | ------------------- | -------------------------------------------------------- |
| pubClient | required | client used for PUBLISH |
| subClient | required | client used for SUBSCRIBE (separate connection) |
| channel | mcp:server-events | must match on every node |
| onError | console.error | publish failures, bad payloads, throwing listeners |
| library | auto-detected | 'node-redis' | 'ioredis' if detection guesses wrong |
bus.ready() resolves once the subscription is confirmed — worth awaiting
during startup, since events published elsewhere before that can be missed.
bus.close() unsubscribes; closing the clients stays your job.
Events with kinds this package doesn't know about pass through unchanged, so
SDK releases that add new ServerEvent variants don't need a lockstep upgrade
here.
Resumable sessions (v1 SDK / v2 legacy transport)
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { RedisEventStore } from "mcp-backplane";
import { randomUUID } from "node:crypto";
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore: new RedisEventStore({ client, ttlSeconds: 60 * 60 }),
});Same option on v2's NodeStreamableHTTPServerTransport for 2025-era
sessionful serving. When a client reconnects with Last-Event-ID, any node
that shares the Redis instance can replay what it missed — the event ids
encode the stream they belong to.
Options
| option | default | |
| -------------------- | ------------- | ------------------------------------------------ |
| client | required | node-redis or ioredis client |
| keyPrefix | mcp:stream: | Redis key is keyPrefix + streamId |
| maxEventsPerStream | 1024 | approximate XADD MAXLEN ~ cap; null for none |
| ttlSeconds | off | refresh EXPIRE on every write |
| replayBatchSize | 100 | events per XRANGE during replay |
| library | auto-detected | as above |
Size maxEventsPerStream to your notification volume — a client further
behind than the cap loses the trimmed messages.
An InMemoryEventStore with identical behavior ships too, for tests and
single-process dev.
Important behavior
- Redis pub/sub is fire-and-forget. If a node is disconnected when an event is
published, that node misses it — same trade-off
@socket.io/redis-adaptermakes. List-changed notifications are hints by design, so a missed one costs a client a stale cache until the next change, not correctness. publish()never throws and never blocks on Redis: local listeners are invoked synchronously, thePUBLISHis fire-and-forget, and failures go toonError.storeEventpropagates Redis errors; the SDK transport handles a rejected write by tearing down the stream rather than silently dropping resumability.
Roadmap
- Postgres backends (
LISTEN/NOTIFYbus, single-table event store) - NATS bus
- A durable task store, once the SDK's tasks-extension runtime lands (typescript-sdk#2189)
License
MIT
