@gonzih/mcp-substrate
v0.1.0
Published
Production infrastructure for stateful MCP servers — session stores, leader election, stdio safety, per-session auth
Downloads
24
Maintainers
Readme
@gonzih/mcp-substrate
Production infrastructure for stateful MCP servers. Fills the operational gaps that @modelcontextprotocol/sdk maintainers closed as "won't fix":
| Module | Problem solved |
|--------|---------------|
| createMcpApp | Shared-transport footgun (issue #961): one server instance per client session |
| SessionStore / RedisSessionStore | Per-session state that survives pod restarts |
| withSingleton | Background workers that run in exactly one replica |
| defineStdioServer | Orphan process accumulation when parent exits |
| withAuth | Per-session credential injection into tool call context |
Install
npm install @gonzih/mcp-substrate @modelcontextprotocol/sdk zod
# For Redis-backed features:
npm install iorediscreateMcpApp
Multi-client-safe HTTP handler. Creates a fresh McpServer per client session — no shared state.
import express from 'express';
import { createMcpApp } from '@gonzih/mcp-substrate';
const handler = createMcpApp(
(server) => {
server.registerTool(
'echo',
{ description: 'Echoes the input', inputSchema: { message: z.string() } },
async ({ message }) => ({ content: [{ type: 'text', text: message }] }),
);
},
{ name: 'my-server', version: '1.0.0' },
);
const app = express();
app.use(express.json());
app.all('/mcp', handler);
app.listen(3000);Why: The naive pattern of one McpServer + one StreamableHTTPServerTransport for all requests causes session collisions. createMcpApp creates isolated server instances per session and routes by mcp-session-id header.
SessionStore / RedisSessionStore
Portable session state that survives Kubernetes pod restarts.
import Redis from 'ioredis';
import { RedisSessionStore } from '@gonzih/mcp-substrate';
const redis = new Redis(process.env.REDIS_URL!);
const sessions = new RedisSessionStore(redis, {
keyPrefix: 'mcp:session:',
defaultTtlSeconds: 3600,
});
// In your tool handler:
const ctx = await sessions.get(sessionId);
await sessions.set(sessionId, { ...ctx, lastSeen: Date.now() }, 3600);Why: In-memory session maps are lost on pod restart. Redis gives you durable, cross-pod session state with automatic TTL eviction.
For local dev and tests, use MemorySessionStore — same interface, no Redis needed.
withSingleton
Run a background worker in exactly one process across N replicas.
import Redis from 'ioredis';
import { withSingleton } from '@gonzih/mcp-substrate';
const redis = new Redis(process.env.REDIS_URL!);
await withSingleton(redis, 'my-app:queue-poller', async () => {
setInterval(() => pollQueue(), 3_000);
});Why: With replicas: 3 in Kubernetes, a naive background poller runs three times. withSingleton uses Redis SET NX EX leader election so exactly one replica runs the worker. If the leader pod dies, the lock expires (default 30 s) and another replica takes over.
Options:
await withSingleton(redis, 'my-app:scheduler', worker, {
ttlSeconds: 60, // lock expiry (must be > refreshIntervalMs / 1000)
refreshIntervalMs: 20_000, // how often the leader renews the lock
});defineStdioServer
Stdio MCP server with SIGTERM/EPIPE handling that prevents orphan process accumulation.
import { defineStdioServer } from '@gonzih/mcp-substrate';
await defineStdioServer(
(server) => {
server.registerTool('ping', { description: 'Returns pong' }, async () => ({
content: [{ type: 'text', text: 'pong' }],
}));
},
{ name: 'my-stdio-server', version: '1.0.0' },
);Why: When Claude Desktop or an IDE plugin exits, the spawned stdio server's stdout write end breaks. Without EPIPE handling, Node.js throws an unhandled exception; without SIGTERM handling, the process lingers indefinitely. defineStdioServer installs both handlers for a clean exit.
withAuth
Inject per-session credentials into every tool call without threading them through every function signature.
import { withAuth, getActiveCredentials } from '@gonzih/mcp-substrate';
// Call before registering tools
withAuth(server, async (extra) => ({
tenantId: extra.authInfo?.extra?.tenantId as string ?? '',
apiKey: await lookupApiKey(extra.sessionId),
}));
server.registerTool('fetch-data', { description: 'Fetches tenant data' }, async () => {
const creds = getActiveCredentials();
if (!creds?.apiKey) throw new Error('Missing API key');
const data = await fetchTenantData(creds.tenantId, creds.apiKey);
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
});Why: Tool handlers need caller-specific tokens (API keys, tenant IDs, OAuth tokens) when calling downstream services. Passing credentials through every function signature creates coupling. withAuth + getActiveCredentials() uses AsyncLocalStorage to make credentials available anywhere in the call stack without prop drilling.
Call withAuth before registering tools — it patches server.registerTool.
License
MIT
