Heximon Logo
Capabilities

MCP Server

Derive a Model Context Protocol server from a Contract's route table at runtime with McpServerGenerator — per-route .mcp() opt-in, marked or all selection, in-process tool execution, and stateless Streamable HTTP.

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

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.

src/products/product.api.ts
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:

FieldWhat it controls
nameOverride the tool name (default <namespace>.<routeKey>)
titleOverride the tool title (default: the route's summary)
descriptionOverride the tool description (default: description ?? summary ?? "METHOD /path")
annotationsMerge over the verb-seeded behavioral hints (GETreadOnlyHint, DELETEdestructiveHint, …)
excludeExclude even under 'all' selection
resourceAlso 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.

src/mcp/mcp.module.ts
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:

OptionDefaultWhat it does
serverInfoThe server name + version echoed at initialize
contractsThe contracts whose marked routes become tools
selection'marked''marked' — only .mcp()-marked routes; 'all' — every route except mcp.exclude
converterStandard JSON SchemaCustom schema → JSON Schema converter
toolName<namespace>.<routeKey>Override the name derivation for all tools
annotationsverb-seededOverride behavioral annotations at the generator level
resourceBaseUrl"mcp://resource"Base URI prefix for MCP resources
instructionsUsage instructions surfaced at initialize
Selection '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().

src/mcp/mcp.controller.ts
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.

src/mcp/mcp.module.ts
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 + Route table 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, a McpServerGenerator wired via useFactory, the controller pattern above, and a McpChallengeMiddleware for JWT-based auth at the connection level.
Copyright © 2026