Guide · MCP GraphQL Integration
MCP server GraphQL subscriptions — real-time data in MCP tool handlers
GraphQL subscriptions let clients receive a stream of events over a persistent WebSocket connection — new orders as they arrive, stock price ticks, user activity notifications. When you build an MCP server that wraps a GraphQL API, you may need to expose this streaming data through MCP tools. The challenge is a fundamental architectural mismatch: GraphQL subscriptions are stateful, long-lived WebSocket connections that push events to the client; MCP tools are request-response operations invoked one at a time by a language model. Bridging this gap requires careful design. This page explains why the mismatch exists, describes three patterns for using subscription data in MCP tools, shows how to implement subscription lifecycle management, and explains what to monitor so you detect silently dropped connections before they cause tool failures.
TL;DR
GraphQL subscriptions and MCP's request-response model don't map cleanly. Use the poll-and-snapshot pattern for most cases: your MCP server maintains a subscription internally and writes events to a cache (Redis or in-memory); MCP tools read from the cache on demand. Clean up subscriptions when MCP sessions close. Monitor subscription health separately from MCP protocol health — a dropped WebSocket is invisible to AliveMCP's protocol probe but causes all subscription-backed tools to return stale data.
The fundamental mismatch: stateful subscription vs stateless tool call
A GraphQL subscription works like this: the client opens a WebSocket connection, sends a subscription operation, and receives a stream of events from the server. The connection is persistent — it stays open for minutes or hours. The client accumulates events over the connection's lifetime.
An MCP tool call works like this: the LLM sends a JSON-RPC request to call a tool, the MCP server executes the tool handler and returns a single response, and the connection is effectively idle until the next tool call. There is no concept of "receiving events while waiting" in the core MCP protocol — tool calls are synchronous request-response operations from the LLM's perspective.
The mismatch creates three specific problems:
- When to start the subscription: If you open a WebSocket subscription per tool call, you pay connection setup overhead on every call and immediately close it after getting one event — losing the entire benefit of a persistent connection. If you open it once on server startup, you must manage it across all sessions independently of any specific tool call.
- Where to buffer events: Subscription events arrive asynchronously. The LLM calls your tool at a point in time. You need somewhere to store events between when they arrive and when the tool is called to read them.
- Cleanup on session end: Subscriptions that are tied to individual MCP sessions must be closed when the session ends, or you accumulate orphaned WebSocket connections that leak memory and exhaust file descriptors.
Pattern 1: Poll-and-snapshot (recommended for most cases)
The simplest and most robust pattern: your MCP server maintains a single subscription internally, writes incoming events to an in-memory store or Redis, and exposes MCP tools that read the latest snapshot on demand. The subscription is server-managed, not session-managed — it starts when the MCP server starts and runs as long as the server is up.
import { createClient, Client as GraphQLWSClient } from "graphql-ws";
import WebSocket from "ws";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
// In-memory event store: latest event per subscription topic
const eventStore = new Map<string, { data: unknown; receivedAt: string }>();
// Start a server-managed subscription on startup
function startOrderSubscription(graphqlWsUrl: string, authToken: string) {
const client: GraphQLWSClient = createClient({
url: graphqlWsUrl,
webSocketImpl: WebSocket,
connectionParams: { Authorization: `Bearer ${authToken}` },
retryAttempts: Infinity, // Auto-reconnect on disconnect
shouldRetry: () => true,
});
const unsubscribe = client.subscribe(
{
query: `subscription OnNewOrder {
orderCreated {
orderId
customerId
status
totalCents
createdAt
items { productId quantity }
}
}`,
},
{
next: ({ data }) => {
if (data?.orderCreated) {
eventStore.set("latest_order", {
data: data.orderCreated,
receivedAt: new Date().toISOString(),
});
// Keep a rolling window of recent orders
const recent = (eventStore.get("recent_orders")?.data as unknown[]) ?? [];
eventStore.set("recent_orders", {
data: [data.orderCreated, ...recent].slice(0, 50),
receivedAt: new Date().toISOString(),
});
}
},
error: (err) => {
console.error("[OrderSubscription] Error:", err);
},
complete: () => {
console.warn("[OrderSubscription] Subscription completed — will retry");
},
}
);
return unsubscribe;
}
// MCP tools that read from the event store
const server = new McpServer({ name: "realtime-mcp", version: "1.0.0" });
server.tool(
"get_latest_order",
"Get the most recently created order from the real-time order feed. Returns null if no orders have been received since server startup.",
{},
async () => {
const entry = eventStore.get("latest_order");
if (!entry) {
return { content: [{ type: "text", text: "No orders received yet" }] };
}
return {
content: [{
type: "text",
text: JSON.stringify({ ...entry.data, receivedAt: entry.receivedAt }, null, 2),
}],
};
}
);
server.tool(
"get_recent_orders",
"Get up to 50 most recent orders from the real-time order feed, newest first. Use limit to request fewer.",
{ limit: z.number().int().min(1).max(50).default(10) },
async ({ limit }) => {
const entry = eventStore.get("recent_orders");
const orders = ((entry?.data as unknown[]) ?? []).slice(0, limit);
return { content: [{ type: "text", text: JSON.stringify(orders, null, 2) }] };
}
);
The poll-and-snapshot pattern keeps subscription management entirely server-side. MCP tool calls are simple reads from the event store. The retryAttempts: Infinity and shouldRetry: () => true configuration on the graphql-ws client handles automatic reconnection — if the WebSocket drops, the client reconnects and resumes the subscription.
Pattern 2: Session-scoped subscription with manage/check/stop tools
When subscription data is user-specific — watching a particular user's notifications, tracking a specific order's status updates — you need per-session subscriptions with explicit lifecycle management. Expose three tools: one to start watching, one to check for new events, one to stop.
// Per-session subscription state
const sessionSubscriptions = new Map<string, {
unsubscribe: () => void;
events: unknown[];
startedAt: string;
}>();
server.tool(
"start_watching_order",
"Start receiving real-time status updates for a specific order. Returns a subscription handle. Call check_order_updates to get accumulated events, and stop_watching_order when done.",
{ orderId: z.string().describe("Order ID to watch") },
async ({ orderId }, { sessionId }) => {
// Clean up existing subscription for this session if any
const existing = sessionSubscriptions.get(sessionId);
if (existing) {
existing.unsubscribe();
sessionSubscriptions.delete(sessionId);
}
const events: unknown[] = [];
const client = createClient({ url: WS_URL, webSocketImpl: WebSocket, retryAttempts: 3 });
const unsubscribe = client.subscribe(
{ query: `subscription WatchOrder($id: ID!) { orderUpdated(orderId: $id) { orderId status updatedAt estimatedDelivery } }`, variables: { id: orderId } },
{
next: ({ data }) => { if (data?.orderUpdated) events.push(data.orderUpdated); },
error: (err) => console.error(`[Session ${sessionId}] Subscription error:`, err),
complete: () => console.log(`[Session ${sessionId}] Subscription complete`),
}
);
sessionSubscriptions.set(sessionId, { unsubscribe, events, startedAt: new Date().toISOString() });
return { content: [{ type: "text", text: `Watching order ${orderId}. Call check_order_updates to get events.` }] };
}
);
server.tool(
"check_order_updates",
"Get all order status updates received since start_watching_order was called. Clears the event buffer after reading.",
{},
async (_, { sessionId }) => {
const sub = sessionSubscriptions.get(sessionId);
if (!sub) {
return { content: [{ type: "text", text: "No active order subscription. Call start_watching_order first." }], isError: true };
}
const events = [...sub.events];
sub.events.length = 0; // Clear buffer
return { content: [{ type: "text", text: JSON.stringify({ events, count: events.length }, null, 2) }] };
}
);
server.tool(
"stop_watching_order",
"Stop receiving real-time updates for the current order. Returns all events received during the watch session.",
{},
async (_, { sessionId }) => {
const sub = sessionSubscriptions.get(sessionId);
if (!sub) {
return { content: [{ type: "text", text: "No active subscription to stop." }] };
}
sub.unsubscribe();
const finalEvents = [...sub.events];
sessionSubscriptions.delete(sessionId);
return { content: [{ type: "text", text: JSON.stringify({ finalEvents, count: finalEvents.length }, null, 2) }] };
}
);
// Critical: clean up on session close to prevent WebSocket leaks
server.onSessionClose((sessionId) => {
const sub = sessionSubscriptions.get(sessionId);
if (sub) {
sub.unsubscribe();
sessionSubscriptions.delete(sessionId);
}
});
Pattern 3: Redis as the subscription event bus
When your MCP server runs as multiple instances (for horizontal scaling or blue-green deploys), an in-memory event store is not shared across instances. A client that connects to instance A will not see events buffered by instance B's subscription. Use Redis Pub/Sub or Redis Streams as the shared event bus:
import { createClient as createRedisClient } from "redis";
const redisPublisher = createRedisClient({ url: process.env.REDIS_URL });
const redisSubscriber = createRedisClient({ url: process.env.REDIS_URL });
await redisPublisher.connect();
await redisSubscriber.connect();
// GraphQL subscription writes events to Redis Stream
const gqlClient = createClient({ url: WS_URL, webSocketImpl: WebSocket, retryAttempts: Infinity });
gqlClient.subscribe(
{ query: `subscription { orderCreated { orderId status totalCents createdAt } }` },
{
next: async ({ data }) => {
if (data?.orderCreated) {
await redisPublisher.xAdd("orders:stream", "*", {
orderId: data.orderCreated.orderId,
status: data.orderCreated.status,
payload: JSON.stringify(data.orderCreated),
});
// Keep stream to last 1,000 entries
await redisPublisher.xTrimByLength("orders:stream", 1000);
}
},
error: console.error,
complete: () => console.warn("GraphQL subscription ended"),
}
);
// MCP tool reads from Redis Stream
server.tool(
"get_recent_orders",
"Get recent orders from the real-time feed, newest first. Count specifies max number of orders.",
{ count: z.number().int().min(1).max(50).default(10) },
async ({ count }) => {
const entries = await redisPublisher.xRevRange("orders:stream", "+", "-", { COUNT: count });
const orders = entries.map((e) => JSON.parse(e.message.payload as string));
return { content: [{ type: "text", text: JSON.stringify(orders, null, 2) }] };
}
);
Redis Streams are a good fit because they persist events across MCP server restarts and work correctly with multiple MCP server instances. See MCP server Redis integration for connection pooling and error handling patterns.
Monitoring subscription health
A dropped GraphQL subscription connection is one of the harder failures to detect in an MCP server. The MCP protocol layer is entirely unaffected — initialize succeeds, tools/list returns your tools — so external protocol monitors see a healthy server. Tool calls succeed too, returning cached data from before the connection dropped. The failure is silent: your subscription-backed tools start returning stale data, and neither the LLM nor external monitoring catches it.
Three monitoring practices for subscription health:
- Track subscription connection state and emit a metric: Emit a structured log event when the subscription connects, disconnects, or errors. Set up an alert when the subscription has been disconnected for more than 5 minutes without successful reconnection.
- Add a staleness check to subscription-backed tools: Check how long ago the last event was received. If no events have arrived in longer than the expected event frequency (e.g., 10 minutes for a stream that normally receives events every 30 seconds), include a staleness warning in the tool response.
- Expose a subscription health endpoint: Add a
/healthz/subscriptionsendpoint that returns the connection state, last event time, and event count for each active subscription. This is separate from the MCP protocol health check and gives you visibility into the subscription layer.
// Subscription health tracking
let subscriptionState: "connected" | "disconnected" | "error" = "disconnected";
let lastEventAt: Date | null = null;
let eventCount = 0;
gqlClient.subscribe(
{ query: ORDER_SUBSCRIPTION },
{
next: ({ data }) => {
subscriptionState = "connected";
lastEventAt = new Date();
eventCount++;
// ... handle event
},
error: (err) => {
subscriptionState = "error";
console.error({ event: "subscription_error", err });
},
complete: () => {
subscriptionState = "disconnected";
console.warn({ event: "subscription_disconnected", lastEventAt });
},
}
);
// Health endpoint (on your HTTP server, not MCP transport)
app.get("/healthz/subscriptions", (req, res) => {
const staleness = lastEventAt
? Math.floor((Date.now() - lastEventAt.getTime()) / 1000)
: null;
res.json({
status: subscriptionState,
lastEventAt: lastEventAt?.toISOString() ?? null,
staleness_seconds: staleness,
eventCount,
stale: staleness !== null && staleness > 600, // Alert if no events in 10 min
});
});
External MCP protocol monitoring with AliveMCP covers the server-level health that subscription monitoring cannot: whether your MCP server itself is reachable and the initialize handshake succeeds. Think of subscription health monitoring as a layer above AliveMCP's protocol probe — AliveMCP tells you the server is reachable; your subscription metrics tell you the data layer is live.
When subscriptions are not the right answer for MCP tools
Before building subscription-backed MCP tools, consider whether the use case actually requires real-time streaming. Most MCP tool interactions are discrete queries — the LLM asks a question, gets an answer, and moves on. A few cases where polling is a better fit than subscriptions:
- Low-frequency events (less than once per minute): A tool that checks "did a new order arrive in the last hour" is simpler as a query than a subscription. The overhead of managing a persistent WebSocket connection is not justified for infrequent data.
- Events that are readable after the fact: If your GraphQL API has a
orders(since: DateTime)query, a polling tool that calls this query is simpler and more reliable than a subscription. The LLM can call the poll tool whenever it needs fresh data. - Stateless MCP servers: If your MCP server does not maintain session state, per-session subscriptions are awkward — you have no session close hook to clean up. The poll-and-snapshot pattern with a server-managed subscription is more appropriate.
Reserve GraphQL subscriptions for cases where the data genuinely changes at high frequency (multiple times per minute), where the LLM needs to react to events as they arrive rather than polling on a schedule, or where the latency of polling would be noticeable in the user experience.
Related questions
Which WebSocket library should I use for GraphQL subscriptions: graphql-ws or subscriptions-transport-ws?
Use graphql-ws (the newer library by the same author). The subscriptions-transport-ws library (the original one) is in maintenance mode and no longer actively developed. graphql-ws implements the newer graphql-transport-ws WebSocket subprotocol, handles reconnection more reliably, and has better TypeScript types. Check that your GraphQL server supports graphql-ws before migrating — some older servers only support subscriptions-transport-ws.
Can I use GraphQL subscriptions with Apollo Client in an MCP server?
Yes, but Apollo Client's subscription support in Node.js requires the @apollo/client/link/ws package and either subscriptions-transport-ws or graphql-ws. The setup is more complex than using graphql-ws directly. Consider using graphql-ws standalone for subscription management and Apollo Client only for queries and mutations — they can coexist in the same MCP server.
What happens to subscriptions when I deploy a new version of my MCP server?
Subscriptions are WebSocket connections that are tied to the process. When you restart the process for a deployment, all active subscriptions are dropped. For server-managed subscriptions (poll-and-snapshot pattern), the graphql-ws client will reconnect automatically with retryAttempts: Infinity — you lose events received during the reconnection window. For session-managed subscriptions, clients must start new subscriptions after reconnecting. Design your event storage to be resilient to these gaps: use Redis Streams with timestamp-based queries so consumers can catch up on missed events after a reconnect.
Further reading
- MCP server with GraphQL — wrapping a GraphQL API as MCP tools
- MCP server with Apollo Client — queries, mutations, and error handling
- MCP server Redis — caching, pub/sub, and shared state across instances
- MCP server WebSockets — real-time transport for MCP
- MCP server session lifecycle — session open, active, and close phases
- MCP server streaming — progressive tool output delivery
- MCP server observability — structured logs and metrics for MCP
- AliveMCP — external MCP protocol monitoring; pair with subscription health metrics for full stack coverage