ONDC-Official

ondc-mcp

Community ONDC-Official
Updated

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

  1. Accept: application/json, text/event-stream — both. The server picksper response whether to answer with JSON or upgrade to a stream; sendingonly 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 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 401 withWWW-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.
  • /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 aone-method interface.

Gotcha worth knowing: the SDK rejects any token whoseAuthInfo.expiresAt is unset — silently, as a plain 401. Both shippedverifiers populate it from the JWT exp claim.

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

  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 andthe API is still settling. @modelcontextprotocol/fastify publishes alatest 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 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-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 isa transitive requirement of the adapter, not a stray dependency.

MCP Server · Populars

MCP Server · New

    pexni

    smails

    Agent-native disposable email — Cloudflare Workers + Durable Objects, with CLI & MCP. https://smails.dev

    Community pexni
    blak0p

    git-courer

    MCP server for Git with local Ollama — zero tokens for git operations

    Community blak0p
    MongLong0214

    Logic Pro MCP Server for Claude, Cursor, and AI Agents

    Local MCP server for stateful, fail-closed Logic Pro control and live project readback.

    Community MongLong0214
    clarilayer

    ClariLayer

    Stop re-explaining your data to your AI every session. The individual-analyst context layer, delivered over MCP (Claude Code / Cursor / Codex).

    Community clarilayer
    desek

    Outlook Local MCP Server

    Local MCP server for Microsoft Outlook — calendars, events, and email via Microsoft Graph API

    Community desek