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
Transformyou 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
notifyhook 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
Readablestreams; text streams flow as JSON strings, binary streams are base64-encoded on the fly.
npm i @zjonsson/simple-mcpimport { 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:
Transportextends NodeTransformin object mode. Feed requests in, get responses out, with backpressure. - Negotiated over HTTP:
Content-Type/Acceptselect 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
notifyhook 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.