ondc-mcp

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.

Category
Visit Server

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-console is an ESLint error — load-bearing here, not stylistic.
  • src/entrypoints/stdio.test.ts spawns 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:

  1. 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 a 406.
  2. A full _meta envelope on every requestprotocolVersion, clientInfo, clientCapabilities. A missing key is a -32602 naming it.
  3. Mcp-Method, plus Mcp-Name for tools/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 401 with WWW-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.
  • /health and /ready stay 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.expiresAt is unset — silently, as a plain 401. Both shipped verifiers populate it from the JWT exp claim.

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 every flow_proceed and 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. createContainer says so in place.
  • A failed read throws, it does not return undefined. undefined means "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

  1. src/modules/<name>/ with *.schema.ts, *.service.ts, *.tool.tssession/ is the smallest complete example.
  2. Write schemas first — everything else derives from them.
  3. Keep the service free of SDK imports.
  4. 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/fastify publishes a latest tag that lags its siblings, so a caret range silently mixes versions. npm run sdk:check surfaces the GA bump.
  • SDK types are confined to mcp/, entrypoints/, plugins/mcp.ts, lib/define-tool.ts and lib/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-eslint supports <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.mts references Buffer.
  • @modelcontextprotocol/node peer-depends on hono even under Fastify. It is a transitive requirement of the adapter, not a stray dependency.

Recommended Servers

playwright-mcp

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.

Official
Featured
TypeScript
Magic Component Platform (MCP)

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.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

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.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

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.

Official
Featured
TypeScript
Kagi MCP Server

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.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

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.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured