ondc-mcp
A production MCP server boilerplate: stdio + Streamable HTTP, built on theMCP TypeScript SDK v2 against spec revision 2026-07-28.
The design goal is horizontal scalability. The 2026-07-28 transport isstateless — no Mcp-Session-Id, no initialize handshake, no long-lived GETstream — so any replica can answer any request behind a plain round-robin loadbalancer. src/app.test.ts proves it: one instance lists the tools, a secondinstance with no shared state executes the call.
npm install
cp .env.example .env
npm run dev # HTTP transport on :3000
npm run dev:stdio # stdio transport
npm run inspect # MCP Inspector against the stdio entrypoint
npm run typecheck && npm run lint && npm test
Layout
src/
mcp/server.ts buildMcpServer(container, ctx) — THE factory, no transport
mcp/capabilities.ts one line per module
entrypoints/stdio.ts serveStdio(...) — only stdio binder
entrypoints/http.ts listen() — only file that binds a port
app.ts buildHttpApp(...) — Fastify host, no listen()
container.ts boot-once singletons + dependency health checks
config/env.ts zod env, parsed once, exits non-zero when invalid
plugins/ security · error-handler · auth · mcp
modules/catalog/ config-service client: builds, flows, mock configs
modules/session/ sessions, NP identity, role inversion
modules/flow/ the loop — derived state machine, start/proceed/await
modules/record/ exchanges, payloads and business data
modules/transport/ inbound receiver routes + outbound signed sender
modules/forms/ forms this mock hosts, and forms it has to fill
modules/health/ /health and /ready
lib/ errors · define-tool · logger · cache · mock-engine · events
test/harness.ts in-process client ↔ server (the app.inject() analogue)
The one architectural idea
The SDK's central type is McpServerFactory = (ctx) => McpServer. Bothentrypoints consume it, so the scaffold has a direct analogue of buildApp():
buildMcpServer(container, ctx)registers every capability and binds notransport. Entrypoints own transports. Tests consume the factory directly.
Keep the factory cheap. Over HTTP it runs once per request — that iswhat makes the transport stateless. Anything expensive (connection pools, HTTPagents, caches) belongs in createContainer, built once at boot and closedover. Get this wrong and you open a pool per request; the symptom is a capacitycliff under load, not a failing test. So src/mcp/server.test.ts asserts thefactory performs no I/O across 100 constructions.
Layering
tool → service → repository, one-way, never skipped — the MCP analogue ofroutes → controller → service → repository.
| File | Role |
|---|---|
*.tool.ts |
Protocol edge. Schemas, annotations, result shaping. Knows MCP only. |
*.service.ts |
Business logic.Imports nothing from the SDK. Throws AppError. |
*.repository.ts |
Data access. Interface + in-memory implementation. |
*.schema.ts |
zod schemas;z.infer<> types derive from them. |
A service never returns { content: [...] }; a tool never contains a businessrule. That is what lets one service back a tool, a resource, and later a RESTroute.
MCP-specific conventions
These are the rules with no REST analogue. They are the substance of thescaffold.
1. Every tool declares both schemas
defineTool requires inputSchema and outputSchema — a tool missingeither will not typecheck. outputSchema drives structuredContent, which iswhat makes a tool consumable by code rather than only by a model reading prose.Handlers return typed domain data; the helper builds the envelope, so no toolcan return half of it.
defineTool({
name: "session_get",
title: "Get session",
description: "Fetch a session by id: the participant under test, the role …",
inputSchema: GetSessionInput,
outputSchema: GetSessionOutput,
annotations: { readOnlyHint: true, idempotentHint: true },
render: ({ session }) => renderSession(session),
handler: async ({ session_id }) => ({
session: await service.requireSession(session_id),
}),
});
2. Two error channels — pick by who can fix it
| Failure | Channel | Who acts |
|---|---|---|
| Bad arguments, not found, conflict, upstream down | { isError: true } result |
Themodel — it reads the failure and retries differently |
| Auth failure, unknown method | JSON-RPC error | Theclient — the model cannot mint a token or invent a method |
Report a model-fixable failure as a protocol error and the model never learnsthe call failed; it sees a transport fault and retries the identical call.
Argument validation sits on the tool channel — which is also what the SDKdoes for inputSchema violations it catches itself. session.tool.test.tspins that behaviour so the two layers stay consistent.
A protocol NACK is emphatically on the tool channel too: the participantrejecting a payload is the most informative result a compliance run produces,and the model has to read it.
Each AppError subclass declares its own channel; handleToolError routes it.
3. stdout belongs to the protocol
On stdio, stdout is the JSON-RPC channel. One stray console.log corruptsthe stream and surfaces as an unrelated-looking parse error in the client.
- pino writes to stderr in every mode. There is no config that changes it.
no-consoleis an ESLint error — load-bearing here, not stylistic.src/entrypoints/stdio.test.tsspawns the real entrypoint and fails if asingle non-protocol byte reaches stdout.
This also matches 2026-07-28, which deprecates the MCP logging capability infavour of stderr (stdio) and OpenTelemetry (HTTP).
4. No module-level mutable state
The point of the stateless revision. Cross-request state goes to the injectedServerEventBus — in-process by default; pass a Redis-backed implementation tocreateMcpHandler({ bus }) when running more than one replica.
This server's own domain state (sessions, transactions, payloads) goes throughthe CacheStore port in src/lib/cache/ instead. SeeState persistence.
5. Cache hints are free throughput
Cacheable results (tools/list, prompts/list, resources/*,server/discover) carry ttlMs / cacheScope. The SDK default isttlMs: 0, cacheScope: "private" — no caching at all. mcp/server.ts setsthem deliberately, and a test asserts tools/list really carries them. Treat anunset hint as a decision you skipped.
6. Trace context is propagated
_meta carries W3C traceparent / tracestate / baggage (formalised forMCP in 2026-07-28). defineTool lifts them into the per-call child logger, soa tool call correlates with the rest of your traces. There is no separateobservability plugin: request logging is Fastify's own (pointed at the sharedstderr logger in app.ts), and the MCP-level context belongs where the callis served.
7. Deprecated surface is avoided
roots, sampling, and the logging capability are all deprecated in2026-07-28. Nothing here builds on them. Server→client requests are expressedwith inputRequired(...) instead.
Talking to the HTTP transport
A 2026-07-28 request is stricter than 2025. Three things are mandatory:
Accept: application/json, text/event-stream— both. The server picksper response whether to answer with JSON or upgrade to a stream; sendingonly JSON earns a406.- A full
_metaenvelope on every request —protocolVersion,clientInfo,clientCapabilities. A missing key is a-32602naming it. Mcp-Method, plusMcp-Namefortools/call(SEP-2243) — and theymust agree with the body. These exist so load balancers and rate limiterscan route and meter on the operation without parsing JSON-RPC.
curl -sS localhost:3000/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H 'mcp-method: tools/list' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0"},
"io.modelcontextprotocol/clientCapabilities":{}}}}'
Clients still speaking 2025 keep working: plugins/mcp.ts setslegacy: "stateless", so they are served per-request from the same factory.Set legacy: "reject" to go modern-only.
Authorization
AUTH_MODE=jwt turns /mcp into an OAuth 2.1 Resource Server:
- Unauthenticated calls get
401withWWW-Authenticate: Bearer ... resource_metadata="…". - That URL serves an RFC 9728 document at
/.well-known/oauth-protected-resource/mcp, unauthenticated — it is how aclient discovers which authorization server to use. /healthand/readystay open so orchestrators can probe without a token.
Swap lib/token-verifier.ts for introspection or a vendor SDK; it is aone-method interface.
Gotcha worth knowing: the SDK rejects any token whose
AuthInfo.expiresAtis unset — silently, as a plain401. Both shippedverifiers populate it from the JWTexpclaim.
env.ts refuses to boot with AUTH_MODE=none when NODE_ENV=production, soan unauthenticated production deploy cannot happen by configuration alone.
State persistence
Sessions, transactions, stored payloads and business data live behind theCacheStore port (src/lib/cache/). Two implementations ship:
REDIS_URL |
Store | Behaviour |
|---|---|---|
| unset | InMemoryCacheStore |
Zero infrastructure. A restart wipes everything. |
redis://... |
RedisCacheStore |
State survives restarts and is shared by replicas. |
Unset is the default so the server runs with nothing installed. In developmentthat has a sharp edge: npm run dev is tsx watch, so every file saverestarts the process and drops the session you were mid-flow in. Pointing at alocal Redis fixes that:
docker compose -f docker-compose.dev.yml up -d # redis:8-alpine, loopback only, appendonly
echo 'REDIS_URL=redis://127.0.0.1:6379' >> .env # .env is read by npm run dev
npm run dev
Keys are the workbench's own layout (session::{id}, {txn}::{sub}) under aREDIS_KEY_PREFIX namespace, so one local Redis can serve several projects —and setting the prefix empty writes the workbench's literal keys, should youever want to share a Redis with it.
Two deliberate choices worth knowing:
- The flow catalog never goes to Redis.
FlowService.load()reads a~330KB mock-runner config on everyflow_proceedand every inbound callback.It is derived data, TTL'd at 15 minutes and re-fetched transparently on amiss, so it stays in-process — one 330KB transfer per loop iteration, half ofthem inside the ACK window, would buy nothing.createContainersays so inplace. - A failed read throws, it does not return
undefined.undefinedmeans"no such session", and the model answers that by starting a new transactionagainst a real participant. An outage has to look like an outage.
Testing
| Layer | Mechanism |
|---|---|
| Service | Plain unit test — no MCP involved |
| Tool / resource / prompt | test/harness.ts: a real Client ↔ real McpServer over InMemoryTransport |
| HTTP | app.inject() — full stack, no socket |
| stdio | Real subprocess, asserting stdout purity |
CacheStore |
One shared contract suite (test/cache-store-contract.ts) run against every implementation |
Redis is exercised two ways: a fake client in the default suite, and a realserver behind an opt-in gate — RUN_REDIS_TESTS=1 npm test -- redis-cache-store,which needs docker-compose.dev.yml up. Each run namespaces its keys and cleansup with SCAN, never FLUSHDB, because the Redis it finds may not be its own.
Scaffold-level guarantees under test: the factory does no I/O; stdout carriesonly protocol bytes; /ready returns 503 when a dependency is down; cachehints are emitted; DNS-rebinding protection rejects bad Host/Origin; the 401challenge is discoverable; a second instance serves a call the first never saw.
Adding a module
src/modules/<name>/with*.schema.ts,*.service.ts,*.tool.ts—session/is the smallest complete example.- Write schemas first — everything else derives from them.
- Keep the service free of SDK imports.
- Add one line to
src/mcp/capabilities.ts.
Both transports pick it up automatically.
Notes on versions
- The SDK is pinned to exact
2.0.0-beta.5, deliberately. v2 is in beta andthe API is still settling.@modelcontextprotocol/fastifypublishes alatesttag that lags its siblings, so a caret range silently mixes versions.npm run sdk:checksurfaces the GA bump. - SDK types are confined to
mcp/,entrypoints/,plugins/mcp.ts,lib/define-tool.tsandlib/errors.ts. Services and repositories importnothing from the SDK, so a v2 API change has a small blast radius. - TypeScript is pinned to 6.0.3, not 7.x:
typescript-eslintsupports<6.1.0, and a supported dependency graph beats a forced install.types: ["node"]is required either way — TS 6 no longer auto-includes@types/*and the SDK's.d.mtsreferencesBuffer. @modelcontextprotocol/nodepeer-depends onhonoeven under Fastify. It isa transitive requirement of the adapter, not a stray dependency.