MCP Server
The Model Context Protocol (MCP) is the open standard that lets AI agents — Claude, an IDE assistant, any
MCP-speaking client — discover and call your API's operations as typed tools over HTTP, instead of you hand-writing
a bespoke integration per agent. @heximon/mcp turns your existing contract's routes into exactly those tools.
Your backend already describes its API once, as a Contract. @heximon/mcp reads that same route
table at runtime and derives an MCP tool manifest from it — no compiler, no extra config. Mark each route
you want to expose with .mcp({}), point a McpServerGenerator at the contract, and every .mcp()-marked
route becomes a typed MCP tool.
Because tool execution dispatches in-process through the same route chain the HTTP surface uses — handlers, middleware, validation, error filters, and auth — the MCP surface and the REST surface can't drift. One route declaration drives both.
Install the package
pnpm add @heximon/mcp
npm install @heximon/mcp
yarn add @heximon/mcp
bun add @heximon/mcp
Opt routes in with .mcp()
The route builder's .mcp({ ... }) method marks a route for MCP exposure. The generator's default
selection is 'marked' — only routes that carry this marker become tools, so you curate your AI surface
route by route with no risk of accidentally exposing an internal endpoint.
import { Contract, Route } from "@heximon/contract";
import "@heximon/mcp"; // side-effect: augments Route descriptor with .mcp() typing
import { createProduct, product, productCount, productList, productParams } from "./product.schema";
export class ProductsApi extends Contract({
prefix: "/api/products",
routes: {
// Marked .mcp({}) — exposed as the `products.list` tool.
list: Route.get("/").summary("List products").mcp({}).responses({ 200: productList }),
// Marked with resource: true — also a readable MCP resource (mcp://resource/api/products/:sku).
find: Route.get("/:sku")
.summary("Get a product by SKU")
.pathParams(productParams)
.mcp({ resource: true })
.responses({ 200: product }),
// Marked with annotation overrides.
create: Route.post("/")
.summary("Create a product")
.body(createProduct)
.scopes("products.write")
.mcp({ annotations: { destructiveHint: false } })
.responses({ 201: product }),
// No .mcp() — plain HTTP only, never a tool.
count: Route.get("/count").summary("Count products").responses({ 200: productCount }),
},
}) {}
The .mcp({ ... }) marker accepts several optional overrides:
| Field | What it controls |
|---|---|
name | Override the tool name (default <namespace>.<routeKey>) |
title | Override the tool title (default: the route's summary) |
description | Override the tool description (default: description ?? summary ?? "METHOD /path") |
annotations | Merge over the verb-seeded behavioral hints (GET→readOnlyHint, DELETE→destructiveHint, …) |
exclude | Exclude even under 'all' selection |
resource | Also expose as an MCP resource — true uses defaults; { name?, mimeType? } overrides |
Create the generator
McpServerGenerator is the runtime counterpart of OpenApiGenerator — it builds the tool manifest once
and memoizes it. Construct it with serverInfo and the contracts to expose, then wire it as a DI
provider so the controller receives it by constructor injection.
import { Module } from "@heximon/runtime";
import { McpServerGenerator } from "@heximon/mcp";
import { ProductsApi } from "../products/product.api";
import { McpController } from "./mcp.controller";
export class McpModule extends Module({
providers: [
{
provide: McpServerGenerator,
useFactory: () =>
new McpServerGenerator({
serverInfo: { name: "products-mcp", version: "1.0.0", title: "Products MCP" },
contracts: [ProductsApi],
// selection defaults to 'marked' — only .mcp()-marked routes become tools
instructions: "Tools for listing, fetching, and creating products.",
}),
},
],
http: { controllers: [McpController] },
}) {}
The generator options:
| Option | Default | What it does |
|---|---|---|
serverInfo | — | The server name + version echoed at initialize |
contracts | — | The contracts whose marked routes become tools |
selection | 'marked' | 'marked' — only .mcp()-marked routes; 'all' — every route except mcp.exclude |
converter | Standard JSON Schema | Custom schema → JSON Schema converter |
toolName | <namespace>.<routeKey> | Override the name derivation for all tools |
annotations | verb-seeded | Override behavioral annotations at the generator level |
resourceBaseUrl | "mcp://resource" | Base URI prefix for MCP resources |
instructions | — | Usage instructions surfaced at initialize |
'all' is for a deliberately-curated contract. When you set selection: 'all', every route
in the supplied contracts becomes a tool (except those marked mcp.exclude). Only use it with a contract
you have already scoped to AI-facing routes — the safer default is 'marked', which requires you to opt each
route in individually.Write the controller
@heximon/mcp ships no controller. You write a thin one — the same pattern as DocsController for
OpenAPI. The controller receives the McpServerGenerator and the host-supplied InternalClient by
constructor injection, constructs an McpServer per request, and calls handle().
import type { Controller, Get, InternalClient, Post } from "@heximon/http";
import {
JsonRpcErrorCode,
type McpHttpReply,
McpServer,
type McpServerGenerator,
McpToolDispatcher,
} from "@heximon/mcp";
export class McpController implements Controller {
public constructor(
private readonly generator: McpServerGenerator,
private readonly internalClient: InternalClient,
) {}
/** POST /mcp — handle one JSON-RPC message (initialize / tools/list / tools/call / notifications). */
public async rpc(action: Post<"/mcp">): Promise<Response> {
let message: unknown;
try {
message = await action.request.readBody();
} catch {
return McpController.send({
status: 400,
body: {
jsonrpc: "2.0",
id: null,
error: { code: JsonRpcErrorCode.ParseError, message: "Parse error: invalid JSON." },
},
});
}
const server = new McpServer(this.generator, new McpToolDispatcher(this.internalClient));
return McpController.send(await server.handle(message, action.request));
}
/** GET /mcp — the stateless tier has no server-initiated stream; a GET is 405. */
public async probe(_action: Get<"/mcp">): Promise<Response> {
return McpController.send({
status: 405,
headers: { allow: "POST" },
body: {
jsonrpc: "2.0",
id: null,
error: {
code: JsonRpcErrorCode.InvalidRequest,
message: "The MCP endpoint uses POST (stateless Streamable HTTP).",
},
},
});
}
private static send(reply: McpHttpReply): Response {
const headers = new Headers(reply.headers);
if (reply.body === undefined) {
return new Response(null, { status: reply.status, headers });
}
headers.set("content-type", "application/json");
return new Response(JSON.stringify(reply.body), { status: reply.status, headers });
}
}
McpToolDispatcher dispatches tools/call in-process through the InternalClient — the same request
chain the HTTP surface uses, with middleware, validation, and auth. The host supplies InternalClient
automatically; you declare it as a constructor parameter and the DI graph resolves it.
How tools are named
The default name is <namespace>.<routeKey>. The namespace is the last path segment of the contract's
prefix (cleaned to MCP-safe characters): a contract with prefix: "/api/products" yields namespace
products, so the list route becomes products.list.
Override the name per route with mcp({ name: "my_tool" }), globally with the toolName generator option,
or both (per-route wins).
Schema conversion and exclusions
Tool arguments and output schemas are derived from the route's Standard Schema values via
~standard.jsonSchema. A route whose declared input schema cannot be converted to JSON Schema is excluded
from the manifest — the model would have no arguments to call it with, so shipping it argument-blind would
be unsafe. The generator records an McpGeneratorDiagnostic for each excluded route; call
generator.diagnostics() at boot to surface them.
const generator = new McpServerGenerator({ ... });
// Log exclusions at startup so a misconfigured schema surfaces loudly.
for (const diagnostic of generator.diagnostics()) {
console.warn(diagnostic.message);
}
To use a different validator library, supply a custom converter via the converter option (for example,
Zod v4's zod.toJSONSchema()).
Expose resources
A GET route marked with mcp({ resource: true }) is also exposed as an MCP resource (readable by
URI). A GET with no path params becomes a fixed Resource; one with path params (e.g. /:sku) becomes a
ResourceTemplate (RFC 6570 URI template). Resources are read-only snapshots — subscriptions are not
supported in the stateless tier.
// A fixed resource: mcp://resource/api/products
list: Route.get("/").mcp({ resource: true }).responses({ 200: productList }),
// A resource template: mcp://resource/api/products/{sku}
find: Route.get("/:sku").pathParams(productParams).mcp({ resource: true }).responses({ 200: product }),
Protocol details
The server speaks stateless Streamable HTTP (MCP 2025-11-25): every JSON-RPC message is a single POST
body, the response is synchronous JSON, and notifications are acknowledged with a 202 empty body. JSON-RPC
batching is not supported (removed in that spec revision).
The MCP-Protocol-Version request header is
validated; a mismatched version returns a 400. Set allowedOrigins on McpServerOptions for
browser-facing deployments (DNS-rebinding guard):
const server = new McpServer(generator, dispatcher, {
allowedOrigins: ["https://claude.ai", "https://app.example.com"],
});
See also
- Contracts — author the
Contract+Routetable the tool manifest is generated from, the single source of truth shared by the HTTP server and the MCP surface. - OpenAPI — generate an OpenAPI 3.1 document from the same contract at runtime; the contract-only sibling of MCP generation.
- Controllers — the controller pattern the thin MCP controller follows.
- OpenAPI + MCP — a products API
with
.mcp()-marked routes, aMcpServerGeneratorwired viauseFactory, the controller pattern above, and aMcpChallengeMiddlewarefor JWT-based auth at the connection level.
OpenAPI
Generate an OpenAPI 3.1 document from your Contract route tables at runtime with OpenApiGenerator, register security schemes manually with the SecurityScheme factory, and serve Scalar or Swagger UI inline.
Schema DTOs
Author request and response shapes with SchemaObject and SchemaArray — one class that is both the validated TypeScript type and a Standard Schema value, with partial, pick, omit, and extend derivations.