Skip to main content
Glama
ONDC-Official

ondc-mcp

Official
README.md
# 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.

```bash
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.

```ts
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 | The**model** — it reads the failure and retries differently      |
| Auth failure, unknown method                      | JSON-RPC error             | The**client** — 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](#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 request** — `protocolVersion`,
   `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.

```bash
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:

```bash
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.

## Issue reporting

When a run gets stuck — a blocked step, a NACK in either direction, a send that
failed — the server records what happened, strips it of payload content, and
writes it to a local spool directory. If `FEEDBACK_ENDPOINT_URL` is set it also
uploads it. This is on by default; `FEEDBACK_DISABLED=1` turns it off, and the
choice is logged once at boot along with where reports go.

```bash
FEEDBACK_DISABLED=1 npm run dev            # off
FEEDBACK_SPOOL_DIR=/tmp/fb npm run dev     # spool somewhere you can read
```

What leaves is deliberately narrow. Payload leaf values are replaced by type
tokens (`"<string:12>"`), so key names, types and array lengths survive and no
value does; identifiers are HMAC-pseudonymised with a per-install salt; free
text — including the model's own account — is scrubbed for emails, phone
numbers, GPS pairs and the rest. The rule is default-deny: a field nobody
anticipated is redacted by omission rather than leaked by oversight.
`src/test/pii-fixtures.ts` is the canary, and `feedback.redact.ts` is where the
tests live.

Nothing about this depends on the model cooperating. Capture is deterministic
and the report ships with `narration: null` if it is never answered;
`feedback_submit_report` only adds the model's diagnosis on top. To see exactly
what would be sent, call `feedback_list_reports` with `include_body: true`, or
read the spool directory — it is the same JSON.

## 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.ts` —
   `session/` 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.

TDQS

A4.4/5.0

Scored across 21 tools

Disambiguation5/5

Every tool targets a distinct resource and action: flow control (start/proceed/await/restart/status), session management (create/get), catalog discovery (list_builds/list_flows/describe_flow/load_config), receiver lifecycle (start/stop), form handling (fetch/submit), payload validation, record retrieval (payload/data/events), and feedback (submit/list). Even similar-sounding tools like flow_get_status and flow_await have clearly separated purposes: one is pull-based state inspection, the other is push-based waiting with event delivery. No two tools could be confused for each other.

Naming Consistency5/5

All tool names follow a strict noun_verb or noun_verb_object pattern (e.g., flow_start, session_create, catalog_list_builds, form_submit, record_get_payload). The prefix consistently identifies the resource, and the verb indicates the action. There is no mixing of conventions; all names are lowercase with underscores.

Tool Count4/5

At 21 tools, the set is above the typical 3-15 range but not excessive. Each tool serves a distinct purpose in the ONDC mock participant workflow, and the complexity of the domain (sessions, flows, catalogs, forms, records, feedback) justifies the number. It feels slightly heavy but well-scoped, with no redundant tools.

Completeness4/5

The tool surface covers the core lifecycle: session creation, flow start/proceed/await/restart, catalog exploration, form processing, payload validation, and record retrieval. Minor gaps exist, such as no session listing or explicit cancellation flow (though flow_restart covers restarting), and no generic message-sending tool outside of flow_proceed. These are workable gaps, not blockers, for the stated testing purpose.

Maintenance

ActivityActive
ResponsivenessNo issues