Tiny, streaming-first, stateless Model Context Protocol server for Node.js. No typescript, fully typed with JsDoc.
Early preview (v0.0.1). APIs may change.
- SSE coupling complicates multi-node deployments: The standard SDK maintains an in-memory map to match POSTs to an SSE stream, which breaks without sticky sessions.
- Server-centric client state: Tool lists and other client-specific settings live on the server instance, making per-client variation awkward unless you run one server per connection.
- Base64 responses require full buffering: Returning base64 forces building the entire payload in memory before responding.
- Unnecessarily large surface area: Too much code and too many moving parts when all you need is a tight streaming pipeline.
- Streaming-first: A single
Transform
you can write JSON-RPC requests into and read responses out of. Works with stdio and HTTP. - Stateless by design: No transport map. Each request is self-contained. Push notifications are delivered via SSE when negotiated or handed to a
notify
hook you can back with pub/sub. - Codec-swappable: NDJSON, JSON, and SSE encoders/decoders without changing server logic.
- No large buffers: Responses can embed Node
Readable
streams; text streams flow as JSON strings, binary streams are base64-encoded on the fly.
npm i @zjonsson/simple-mcp
import { SimpleMCP, StdioTransport } from '@zjonsson/simple-mcp'
const server = new SimpleMCP({
tools: [{
name: 'echo',
description: 'Echo text',
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
fn: async ({ text }) => ({ content: [{ type: 'text', text }] })
}]
});
await new StdioTransport(server).connect();
HTTP works too. If the client requests SSE, the response stays open for server-initiated notifications; otherwise, you get a notify
hook to publish out-of-band.
import express from 'express'
import { SimpleMCP, HttpTransport } from '@zjonsson/simple-mcp'
const app = express()
// Important: do not attach JSON body parsers to the MCP route.
// The transport reads the raw request stream.
const server = new SimpleMCP({
tools: [{ name: 'ping', description: 'Ping', inputSchema: { type: 'object', properties: {} }, fn: async () => ({}) }]
})
// Regular MCP endpoint (request/response)
app.post('/mcp', (req, res) => {
const transport = new HttpTransport(server)
transport.connect(req, res)
})
app.listen(3000)
SSE stream (client includes mcp-session-id
to bind to a session). The default notify
emits on HttpTransport.events
using the sessionId
as the event name. This route subscribes to that channel and writes SSE frames to the client. For distributed systems, override notify
to publish to your central pub/sub, and have subscribers re-emit to HttpTransport.events
using the sessionId
as the event name. (The server will also set an mcp-session-id
response header upon initialize.)
app.get('/mcp/sse', (req, res) => {
const sessionId = String(req.headers['mcp-session-id'] || '')
if (!sessionId) return res.status(400).send('Missing mcp-session-id header')
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
})
let id = 0
const listener = (message) => {
res.write(`id: ${id++}\nevent: message\ndata: ${JSON.stringify(message)}\n\n`)
}
HttpTransport.events.on(sessionId, listener)
req.once('close', () => HttpTransport.events.removeListener(sessionId, listener))
})
- One abstraction:
Transport
extends NodeTransform
in object mode. Feed requests in, get responses out, with backpressure. - Negotiated over HTTP:
Content-Type
/Accept
select JSON, NDJSON, or SSE. Stdio defaults to NDJSON. - Notifications:
- Incoming
notification/*
from clients don’t produce a body. - Outgoing notifications go to SSE when available, or to your
notify
hook for pub/sub fanout.
- Incoming
- You want a small, composable MCP server that scales horizontally without sticky sessions.
- You need to stream big outputs without buffering the whole response.
- You prefer standard Node streams and minimal surface area.