ondc-mcp
OfficialClick on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ondc-mcpstart a new flow for seller onboarding"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 testLayout
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)Related MCP server: mcp-apps-demo-engine
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 |
| Protocol edge. Schemas, annotations, result shaping. Knows MCP only. |
| Business logic.Imports nothing from the SDK. Throws |
| Data access. Interface + in-memory implementation. |
| zod schemas; |
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 |
| 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:
| Store | Behaviour |
unset |
| Zero infrastructure. A restart wipes everything. |
|
| 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 devKeys 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 |
|
HTTP |
|
stdio | Real subprocess, asserting stdout purity |
| One shared contract suite ( |
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.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/ONDC-Official/automation-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server