ondc-mcp
Enables ONDC compliance automation by providing tools for managing sessions, flows, records, and forms. Supports stateless, horizontally scalable MCP over stdio and Streamable HTTP.
README
ondc-mcp
A production MCP server boilerplate: stdio + Streamable HTTP, built on the MCP TypeScript SDK v2 against spec revision 2026-07-28.
The design goal is horizontal scalability. The 2026-07-28 transport is
stateless — no Mcp-Session-Id, no initialize handshake, no long-lived GET
stream — so any replica can answer any request behind a plain round-robin load
balancer. src/app.test.ts proves it: one instance lists the tools, a second
instance 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. Both
entrypoints consume it, so the scaffold has a direct analogue of buildApp():
buildMcpServer(container, ctx)registers every capability and binds no transport. Entrypoints own transports. Tests consume the factory directly.
Keep the factory cheap. Over HTTP it runs once per request — that is
what makes the transport stateless. Anything expensive (connection pools, HTTP
agents, caches) belongs in createContainer, built once at boot and closed
over. Get this wrong and you open a pool per request; the symptom is a capacity
cliff under load, not a failing test. So src/mcp/server.test.ts asserts the
factory performs no I/O across 100 constructions.
Layering
tool → service → repository, one-way, never skipped — the MCP analogue of
routes → 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 business
rule. That is what lets one service back a tool, a resource, and later a REST
route.
MCP-specific conventions
These are the rules with no REST analogue. They are the substance of the scaffold.
1. Every tool declares both schemas
defineTool requires inputSchema and outputSchema — a tool missing
either will not typecheck. outputSchema drives structuredContent, which is
what 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 tool
can 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 learns the call failed; it sees a transport fault and retries the identical call.
Argument validation sits on the tool channel — which is also what the SDK
does for inputSchema violations it catches itself. session.tool.test.ts
pins that behaviour so the two layers stay consistent.
A protocol NACK is emphatically on the tool channel too: the participant rejecting 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 corrupts
the 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 a single non-protocol byte reaches stdout.
This also matches 2026-07-28, which deprecates the MCP logging capability in
favour of stderr (stdio) and OpenTelemetry (HTTP).
4. No module-level mutable state
The point of the stateless revision. Cross-request state goes to the injected
ServerEventBus — in-process by default; pass a Redis-backed implementation to
createMcpHandler({ bus }) when running more than one replica.
This server's own domain state (sessions, transactions, payloads) goes through
the CacheStore port in src/lib/cache/ instead. See
State persistence.
5. Cache hints are free throughput
Cacheable results (tools/list, prompts/list, resources/*,
server/discover) carry ttlMs / cacheScope. The SDK default is
ttlMs: 0, cacheScope: "private" — no caching at all. mcp/server.ts sets
them deliberately, and a test asserts tools/list really carries them. Treat an
unset hint as a decision you skipped.
6. Trace context is propagated
_meta carries W3C traceparent / tracestate / baggage (formalised for
MCP in 2026-07-28). defineTool lifts them into the per-call child logger, so
a tool call correlates with the rest of your traces. There is no separate
observability plugin: request logging is Fastify's own (pointed at the shared
stderr logger in app.ts), and the MCP-level context belongs where the call
is served.
7. Deprecated surface is avoided
roots, sampling, and the logging capability are all deprecated in
2026-07-28. Nothing here builds on them. Server→client requests are expressed
with 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 picks per response whether to answer with JSON or upgrade to a stream; sending only 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 they must agree with the body. These exist so load balancers and rate limiters can 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 sets
legacy: "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 a client 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 a
one-method interface.
Gotcha worth knowing: the SDK rejects any token whose
AuthInfo.expiresAtis unset — silently, as a plain401. Both shipped verifiers populate it from the JWTexpclaim.
env.ts refuses to boot with AUTH_MODE=none when NODE_ENV=production, so
an unauthenticated production deploy cannot happen by configuration alone.
State persistence
Sessions, transactions, stored payloads and business data live behind the
CacheStore 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 development
that has a sharp edge: npm run dev is tsx watch, so every file save
restarts the process and drops the session you were mid-flow in. Pointing at a
local 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 a
REDIS_KEY_PREFIX namespace, so one local Redis can serve several projects —
and setting the prefix empty writes the workbench's literal keys, should you
ever 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 a miss, so it stays in-process — one 330KB transfer per loop iteration, half of them inside the ACK window, would buy nothing.createContainersays so in place. - A failed read throws, it does not return
undefined.undefinedmeans "no such session", and the model answers that by starting a new transaction against 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 real
server 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 cleans
up 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 carries
only protocol bytes; /ready returns 503 when a dependency is down; cache
hints are emitted; DNS-rebinding protection rejects bad Host/Origin; the 401
challenge 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 and the 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 import nothing 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 is a transitive requirement of the adapter, not a stray dependency.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.