# DaloyJS MCP server

DaloyJS can host a dedicated [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro) server for AI clients that need tools, resources, and prompts. The core helper implements MCP Streamable HTTP with JSON-RPC 2.0, so a company that already runs a DaloyJS REST API can run a second DaloyJS service at`/mcp` with a different auth policy and a smaller, agent-safe surface area.

Keep the REST API and the MCP server separate when the callers, permissions, or rate limits differ. MCP tools are model-callable operations, so they deserve the same care as any production API route, plus tighter descriptions and schemas because the caller may be an AI client acting on a user's behalf.

**Diagram: Dedicated MCP boundary**

- **AI client** - Claude, Cursor, VS Code
- **DaloyJS MCP app** - POST /mcp JSON-RPC
- **Tools and context** - tools, resources, prompts
- **Existing systems** - database, REST API, queues

Run MCP as its own DaloyJS service when it has a different trust boundary than your REST API. The app still gets body limits, request timeouts, and problem+json errors, plus the same rate-limit and auth middleware you register on REST.

## Protocol versions and the stateless core

MCP `2026-07-28` removed the `initialize` / `notifications/initialized` handshake and the `Mcp-Session-Id` header. The protocol is now stateless: every request carries its own protocol version, client identity, and client capabilities in `params._meta`, so any request can land on any instance. That is exactly the shape serverless and edge deployments want, and it is the shape DaloyJS was already built for.

`createMcpHandler()` serves **both eras** on one endpoint. A request is handled as *modern* when its `_meta` protocol version (or the `MCP-Protocol-Version` header) is `2026-07-28` or later. Everything else takes the unchanged legacy path. You do not configure this, and older clients keep working.

Because the era follows the version the *client* declares, a naive dual-era server would hand attackers a free bypass: declare `2025-11-25`, keep whatever `Mcp-Method` or `Mcp-Param-*` header satisfies the gateway in front, and send a body that does something else. DaloyJS closes that. [Header/body agreement](#request-headers) is validated in **both** eras. Legacy requests are not *required* to carry the standard headers (those headers postdate them), but any they do carry must match the body, or the request is refused with `-32020`. A genuine legacy client, which sends none of them, is unaffected.

Once your clients have migrated you can go further and refuse the older revisions entirely, so no request reaches a tool without the full modern contract:

```ts
const mcp = createMcpHandler({
  serverInfo: { name: "inventory-mcp", version: "1.0.0" },
  // Refuse every pre-2026 revision. A client that asks for an older version
  // gets -32022 with the supported list instead of legacy semantics, so the
  // Mcp-Method / Mcp-Name header contract holds for every request that reaches
  // a tool. Only do this once your clients have migrated.
  protocolVersions: ["2026-07-28"],
  tools: [/* ... */],
});
```

| Concern | Legacy (2024-11-05 … 2025-11-25) | Modern (2026-07-28) |
| --- | --- | --- |
| Handshake | `initialize` + `ping` | none (optional `server/discover`) |
| Version and capabilities | negotiated once per session | per request, in `_meta` |
| Sessions | `Mcp-Session-Id` | none (use explicit handles) |
| Required headers | `MCP-Protocol-Version` | `MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name` |
| Result envelope | method-specific | `resultType` + `_meta.serverInfo` |
| Server asks the user something | server-initiated request over SSE | `input_required` + client retry |
| Resource not found | `-32002` | `-32602` |

## Install

```bash
# MCP support ships in @daloyjs/core.
# No @modelcontextprotocol/sdk dependency is required.
pnpm add @daloyjs/core
```

## Create an MCP server

Use `createMcpHandler()` for the MCP protocol layer and `mcpRoutes()` to mount `POST`, `GET`, and `OPTIONS` on a DaloyJS app. The `POST` route is the actual MCP transport. `GET` returns a JSON hint instead of opening a server-initiated SSE stream, and `OPTIONS` supports browser-based clients when CORS middleware is installed.

```ts
import {
  App,
  McpToolError,
  bearerAuth,
  createMcpHandler,
  mcpRoutes,
  rateLimit,
  timingSafeEqual,
} from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";

const mcp = createMcpHandler({
  serverInfo: {
    name: "inventory-mcp",
    title: "Inventory MCP",
    version: "1.0.0",
  },
  instructions:
    "Use this server to inspect inventory and prepare stock reports.",
  tools: [
    {
      name: "inventory_lookup",
      title: "Inventory lookup",
      description: "Look up available inventory units by SKU.",
      inputSchema: {
        type: "object",
        properties: { sku: { type: "string", minLength: 1 } },
        required: ["sku"],
        additionalProperties: false,
      },
      handler: async (args) => {
        const sku = typeof args.sku === "string" ? args.sku : "";
        if (!sku) {
          throw new McpToolError("sku is required.");
        }

        const units = await inventory.countAvailable(sku);
        return {
          content: [{ type: "text", text: `${sku}: ${units} units` }],
          structuredContent: { sku, units },
        };
      },
    },
  ],
  resources: [
    {
      uri: "daloy://schemas/inventory",
      name: "inventory_schema",
      title: "Inventory schema",
      mimeType: "application/json",
      read: () => ({
        uri: "daloy://schemas/inventory",
        mimeType: "application/json",
        text: JSON.stringify({
          sku: "string",
          units: "number",
          warehouseId: "string",
        }),
      }),
    },
  ],
  prompts: [
    {
      name: "stock_report",
      title: "Stock report",
      description: "Draft a stock report for one SKU.",
      arguments: [{ name: "sku", required: true }],
      get: (args) => ({
        messages: [
          {
            role: "user",
            content: {
              type: "text",
              text: `Prepare a stock report for SKU ${String(args.sku)}.`,
            },
          },
        ],
      }),
    },
  ],
});

const app = new App({
  bodyLimitBytes: 64 * 1024,
  requestTimeoutMs: 10_000,
});

app.use(rateLimit({ windowMs: 60_000, max: 120 }));
app.use(
  bearerAuth({
    realm: "inventory-mcp",
    validate: (token) => timingSafeEqual(token, process.env.MCP_TOKEN!),
  })
);

for (const route of mcpRoutes("/mcp", mcp)) {
  app.route(route);
}

serve(app, { port: 3001 });
```

## Client config

Point an MCP-compatible client at the deployed endpoint. The exact config file differs by client, but remote Streamable HTTP servers use a URL and whatever headers your auth middleware requires.

```json
{
  "mcpServers": {
    "inventory": {
      "url": "https://mcp.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${MCP_TOKEN}"
      }
    }
  }
}
```

## Testing in Scalar

Scalar is best for testing normal REST endpoints. If your app exposes a regular docs search route and an MCP route, use `POST /search` in Scalar for the normal API request. Do not paste the search body into `POST /mcp`. MCP uses JSON-RPC envelopes, not plain REST request bodies.

```json
{
  "query": "How do I enable OpenAPI docs and Scalar UI in DaloyJS?",
  "limit": 2
}
```

The REST endpoint should return `200 OK` with a response like this:

```json
{
  "results": [
    {
      "slug": "docs/openapi",
      "title": "OpenAPI generation",
      "heading": "Scalar UI",
      "url": "https://daloyjs.dev/docs/openapi",
      "text": "Enable OpenAPI generation and Scalar UI from your DaloyJS app.",
      "score": 0.82
    }
  ]
}
```

Use `POST /mcp` only with an MCP-compatible client or with a JSON-RPC request. If you see `202 Accepted` with an empty body while testing `/mcp`, that means the MCP request did not ask for a JSON-RPC response. Add an `id` and call the tool through `tools/call`:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search_docs",
    "arguments": {
      "query": "How do I enable OpenAPI docs and Scalar UI in DaloyJS?",
      "limit": 2
    }
  }
}
```

Short version: test normal APIs on `/search` in Scalar, and reserve `/mcp` for MCP clients or explicit JSON-RPC requests.

## What core supports

- **MCP 2026-07-28 (stateless):** `server/discover`, `tools/list`, `tools/call`, `resources/list`, `resources/templates/list`, `resources/read` (including template-matched URIs), `prompts/list`, and `prompts/get`. Every result carries `resultType` and `_meta.serverInfo`; cacheable ones also carry `ttlMs` / `cacheScope`. Per-request `_meta` validation, standard-header validation (`-32020`), multi round-trip results, and `x-mcp-header` mirroring are all enforced.
- **MCP 2025-11-25 and earlier (legacy):** `initialize` and `ping` on the same endpoint, unchanged, so existing clients keep working while the ecosystem migrates.
- Protocol-version negotiation with `UnsupportedProtocolVersion` (`-32022`) responses that name the versions this server does speak (headerless legacy requests assume `2025-03-26` per the spec), JSON-RPC parse errors, accepted notifications, unknown-pagination-cursor rejection, and bounded request bodies parsed with the framework's `safeJsonParse` so `__proto__` / `constructor` / `prototype` keys are stripped, matching the REST body parsers.
- **Server-side `tools/call` argument validation** against each tool's `inputSchema` before the handler runs (see below).
- Built-in `Origin` validation against DNS rebinding, with an `allowedOrigins` allowlist for browser-based clients.
- MCP 2025-11-25 metadata: server `description`, `websiteUrl`, and `icons`. Tool `outputSchema`, `annotations` (read-only, destructive, idempotent, open-world hints), and `icons`. Icons on resources, templates, and prompts. Tool results that return only `structuredContent` get a serialized text block backfilled for older clients.
- Dependency-free TypeScript types for tools, resources, resource templates, prompts, JSON schemas, content blocks, structured tool output, and handler context.

## Discovery (`server/discover`)

A modern server **must** implement `server/discover`. It is the one call that tells a client which protocol versions, capabilities, and identity a server has, without probing `tools/list`, `prompts/list`, and `resources/list` separately. DaloyJS answers it from the same `serverInfo`, `instructions`, and capability set you already configured, so there is nothing extra to wire up.

```http
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: server/discover

{
  "jsonrpc": "2.0",
  "id": "discover-1",
  "method": "server/discover",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}
```

```json
{
  "jsonrpc": "2.0",
  "id": "discover-1",
  "result": {
    "resultType": "complete",
    "supportedVersions": ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"],
    "capabilities": { "tools": {}, "resources": {}, "prompts": {} },
    "instructions": "Use this server to inspect inventory and prepare stock reports.",
    "ttlMs": 0,
    "cacheScope": "private",
    "_meta": {
      "io.modelcontextprotocol/serverInfo": { "name": "inventory-mcp", "version": "1.0.0" }
    }
  }
}
```

Legacy clients that call `server/discover` get `-32601`, and modern clients that call `initialize` or `ping` get `-32601` with HTTP `404`, the status the spec reserves for “modern server, unknown method” so a client can tell it apart from a legacy endpoint.

## Required request headers

Streamable HTTP mirrors selected body fields into HTTP headers so load balancers, gateways, and WAFs can route and inspect requests without parsing JSON. On a modern request all of these are **required**: `MCP-Protocol-Version` (must equal `_meta["io.modelcontextprotocol/protocolVersion"]`), `Mcp-Method` (must equal the JSON-RPC `method`), and `Mcp-Name` for `tools/call`, `resources/read`, and `prompts/get` (must equal `params.name` or `params.uri`).

DaloyJS rejects a missing or disagreeing header with HTTP `400` and JSON-RPC `-32020` (`HeaderMismatch`). This is a security control, not bookkeeping. Without it a gateway can authorize, route, or rate-limit on the header value while the server executes the body value.

The agreement check also runs on **legacy** requests, which is stricter than the specification requires. Those revisions predate the headers, so a legacy request may omit them, but one that sends them is held to them. Without that, declaring an old protocol version would be enough to keep a gateway-satisfying `Mcp-Method`, `Mcp-Name`, or `Mcp-Param-*` header while the body called something else entirely.

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32020,
    "message": "Header mismatch: Mcp-Method header value 'prompts/list' does not match body value 'tools/list'"
  }
}
```

Values that cannot be represented as plain ASCII arrive in the Base64 sentinel form `=?base64?<payload>?=`. DaloyJS decodes them before comparing, and treats an undecodable payload as a mismatch. `Mcp-Session-Id` and `Last-Event-ID` from older clients are ignored. No session is ever minted or echoed, and streams are not resumable.

## Mirrored tool parameters (`x-mcp-header`)

A tool may ask clients to mirror a primitive parameter into an `Mcp-Param-{Name}` header so infrastructure can route on it. Annotate the property in `inputSchema`:

```ts
{
  name: "execute_sql",
  description: "Execute SQL in a regional cluster.",
  inputSchema: {
    type: "object",
    properties: {
      // Mirrored into "Mcp-Param-Region" so a gateway can route on it.
      region: { type: "string", "x-mcp-header": "Region" },
      query: { type: "string" },
    },
    required: ["region", "query"],
    additionalProperties: false,
  },
  handler: async (args) => runSql(String(args.region), String(args.query)),
}
```

DaloyJS validates the contract in both directions on every `tools/call`: a value present in the arguments requires the matching header, an absent value forbids it, and integers compare numerically. Any disagreement is a `-32020`. Invalid annotations (empty, non-token, duplicated case-insensitively, or on a non-primitive property) throw at `createMcpHandler()` construction, so a misconfigured tool fails at boot rather than in front of a model.

**Do not mirror secrets.** Header values are visible to every intermediary on the path, so passwords, API keys, tokens, and PII must never carry an `x-mcp-header` annotation.

## Caching hints

Modern list results and `resources/read` carry `ttlMs` (a freshness hint) and `cacheScope` (`"public"` or `"private"`), so clients can cache instead of polling. DaloyJS defaults to `{ ttlMs: 0, scope: "private" }`. That is deliberate: MCP explicitly allows a tool list to vary with the credential on the request, and a `"public"` scope would let a shared proxy hand one caller's tools to another. Widen it once you know your results are identical for every caller.

```ts
const mcp = createMcpHandler({
  serverInfo: { name: "inventory-mcp", version: "1.0.0" },
  // Defaults: { ttlMs: 0, scope: "private" }. Clients revalidate every call and
  // no shared proxy may store the response. Raise ttlMs once you are sure the
  // list is stable, and only use "public" when every caller sees the same tools.
  cache: { ttlMs: 300_000, scope: "private" },
  tools: [/* ... */],
});
```

## Multi round-trip requests (MRTR)

Servers can no longer send their own JSON-RPC requests. When a tool, resource, or prompt needs elicitation, sampling, or the client's roots, it returns an interim result with `resultType: "input_required"`. The client gathers the input and retries the *original request* with a new JSON-RPC id, carrying `inputResponses` and whatever `requestState` the server handed back. Nothing is stored server-side between the two calls.

**Diagram: Multi round-trip request**

Participants: MCP client, DaloyJS tool, User

1. **MCP client -> DaloyJS tool** (request) - tools/call (id: 1) - deploy_service { service: 'checkout-api' }
2. **DaloyJS tool -> MCP client** (response) - input_required + signed requestState - inputRequests: { confirm: elicitation/create }
3. **MCP client -> User** (note) - Prompts for confirmation - the client owns the UI, not the server
4. **MCP client -> DaloyJS tool** (request) - tools/call (id: 2) - same params + inputResponses + requestState
5. **DaloyJS tool -> DaloyJS tool** (note) - Verify requestState before acting - HMAC/AEAD, principal binding, short expiry
6. **DaloyJS tool -> MCP client** (response) - complete - the final tool result

No shared storage and no sticky load balancing: the retry carries everything the server needs, which is why requestState has to be integrity-protected.

```ts
const mcp = createMcpHandler({
  serverInfo: { name: "deploy-mcp", version: "1.0.0" },
  tools: [
    {
      name: "deploy_service",
      description: "Deploy a service after the user confirms.",
      inputSchema: {
        type: "object",
        properties: { service: { type: "string", minLength: 1 } },
        required: ["service"],
        additionalProperties: false,
      },
      handler: async (args, ctx) => {
        const confirmation = ctx.inputResponses?.confirm as
          | { action?: string }
          | undefined;

        if (!confirmation) {
          // Nothing to resume from yet: ask the client to collect a decision.
          return {
            resultType: "input_required",
            inputRequests: {
              confirm: {
                method: "elicitation/create",
                params: {
                  mode: "form",
                  message: `Deploy ${String(args.service)} to production?`,
                  requestedSchema: {
                    type: "object",
                    properties: { approve: { type: "boolean" } },
                    required: ["approve"],
                  },
                },
              },
            },
            // Opaque to the client, attacker-controlled on the way back.
            // Sign it: this one carries the principal, the target, and an expiry.
            requestState: await signState({
              sub: ctx.request.headers.get("x-user-id"),
              service: args.service,
              exp: Date.now() + 120_000,
            }),
          };
        }

        // Retry path. Never trust requestState before verifying it.
        const state = await verifyState(ctx.requestState);
        if (state.service !== args.service) {
          throw new McpToolError("Request state does not match this call.");
        }
        if (confirmation.action !== "accept") {
          return "Deploy cancelled.";
        }

        await deploy(state.service);
        return `Deployed ${state.service}.`;
      },
    },
  ],
});
```

```json
// 1. First call. The server cannot finish yet.
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "confirm": { "method": "elicitation/create", "params": { "...": "..." } }
    },
    "requestState": "v1.eyJzdWIiOiJ1XzEifQ.<hmac>"
  }
}

// 2. Retry. NEW JSON-RPC id, original params, plus the answers and state.
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "deploy_service",
    "arguments": { "service": "checkout-api" },
    "inputResponses": { "confirm": { "action": "accept", "content": { "approve": true } } },
    "requestState": "v1.eyJzdWIiOiJ1XzEifQ.<hmac>",
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": { "elicitation": {} }
    }
  }
}
```

DaloyJS enforces the protocol rules around this so your handler cannot get them wrong: an `input_required` result must carry `inputRequests` or `requestState`, it is only valid on `tools/call`, `resources/read`, and `prompts/get` in the modern era, and a request the client did not declare support for is refused with `-32021` (`MissingRequiredClientCapability`) rather than sent to a client that cannot answer it.

> **Treat `requestState` as attacker-controlled.** It round-trips through the client. If it influences authorization, resource access, or business logic, integrity-protect it (HMAC or AEAD), bind it to the authenticated principal *and* the originating request, give it a short expiry, and reject anything that fails verification. Incoming values are capped at `MCP_MAX_REQUEST_STATE_LENGTH` (8 KiB) so a hostile client cannot force large state parsing.

## State without sessions

With protocol sessions gone, a server that needs state across calls returns an explicit handle from one tool and accepts it as an ordinary argument on the next. The model carries the handle forward. The protocol does not.

```ts
// No protocol session exists, so carry state in an explicit handle.
{
  name: "add_item",
  description: "Add a SKU to an existing basket. Baskets expire after 24h.",
  inputSchema: {
    type: "object",
    properties: {
      basket_id: { type: "string", minLength: 1 },
      sku: { type: "string", minLength: 1 },
    },
    required: ["basket_id", "sku"],
    additionalProperties: false,
  },
  handler: async (args, ctx) => {
    // A handle is a name, not a capability: re-authorize it on every call.
    const basket = await baskets.findForCaller(String(args.basket_id), ctx.request);
    if (!basket) throw new McpToolError("Unknown or expired basket.");
    await basket.add(String(args.sku));
    return `Added ${String(args.sku)} to ${basket.id}.`;
  },
}
```

A handle is a name, not a capability. Re-authorize it on every call, keep it opaque, give it a bounded lifetime, state that lifetime in the tool's description so the model can see it, and return a recoverable `McpToolError` when it expires.

## Origin validation (DNS rebinding)

The MCP Streamable HTTP spec requires servers to validate the `Origin` header so a malicious web page cannot use DNS rebinding to drive a local MCP server. `createMcpHandler()` does this on every request. Non-browser clients that send no `Origin` header work unchanged. Browser clients must be loopback or explicitly allowlisted, and everything else receives `403`. A same-origin `Origin` is deliberately **not** treated as sufficient on its own. Under DNS rebinding the attacker's hostname resolves to your host, so `Origin.host` can equal the request `Host`. The `allowedOrigins` allowlist is the real gate for public browser clients.

```ts
const mcp = createMcpHandler({
  serverInfo: { name: "inventory-mcp", version: "1.0.0" },
  // Streamable HTTP DNS-rebinding defense (spec requirement) is built in:
  // requests without an Origin header (Claude, Cursor, CLIs) and loopback
  // origins (localhost, *.localhost, 127.0.0.1, [::1]) are allowed. Every
  // other browser origin gets 403 unless listed here. A same-origin Origin is
  // NOT implicitly trusted: under DNS rebinding the attacker hostname resolves
  // to your host, so Origin.host can equal Host. The allowlist is the gate.
  allowedOrigins: ["https://app.example.com"],
  tools: [/* ... */],
});
```

## Input schema enforcement

> **Behavior change.** A tool's `inputSchema` used to be documentation only. It is now enforced server-side: `tools/call` arguments that violate the schema are rejected before your handler runs. Handlers that previously received malformed arguments (and coped) will now see those calls fail with `-32602` instead.

On every `tools/call`, DaloyJS validates `params.arguments` against the tool's `inputSchema` **before** the handler runs. A violation returns a JSON-RPC `-32602` (Invalid params) error and the handler never executes, so a tool no longer has to defend against the shapes its schema already forbids.

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32602,
    "message": "Invalid params"
  }
}
```

The enforced subset is deliberately small and dependency-free, but covers the security-relevant keywords: `type` (including `integer`), `required`, `properties`, `additionalProperties` (including `additionalProperties: false`), `enum`, `const`, and basic bounds (`minLength` / `maxLength`, `minimum` / `maximum`, `minItems` / `maxItems`). It recurses into nested `properties`, `items`, and object-form `additionalProperties`.

These keywords are advertised to clients but **not enforced**, so your handler must still check them: `pattern`, `format`, `$ref`, and `anyOf` / `oneOf` / `allOf`. `pattern` is skipped on purpose so a developer-authored regex can never become a ReDoS sink against attacker-controlled input.

The same validator is exported as `validateMcpInput(schema, value)`, which returns an array of error strings (empty when valid). Use it to pre-validate arguments in tests or in your own tooling:

```ts
import { validateMcpInput } from "@daloyjs/core";

const schema = {
  type: "object",
  properties: { sku: { type: "string", minLength: 1 } },
  required: ["sku"],
  additionalProperties: false,
} as const;

// [] means valid; a non-empty array holds human-readable error messages.
validateMcpInput(schema, { sku: "ABC-1" });        // []
validateMcpInput(schema, {});                       // ["arguments: missing required property \"sku\""]
validateMcpInput(schema, { sku: "", extra: true }); // 2 errors
```

## Resource templates

Concrete resources cover fixed documents. Resource templates cover families of them. A template advertises an RFC 6570 style URI pattern through `resources/templates/list`, and `resources/read` matches non-listed URIs against your templates, passing the extracted variables to your `read` handler. Only simple `{name}` variables are supported, and each matches a single URI segment. Operator expressions like `{+path}` are rejected at construction so the server never advertises a pattern it cannot serve.

Templates are not compiled to a regular expression. Since 2.0.0 they are matched in linear time, so a template such as `docs://{name}.{ext}` cannot be driven into catastrophic backtracking (ReDoS) by a crafted URI. Before any lookup, `resources/read` also refuses a `params.uri` longer than `maxResourceUriLength` (default `8192` UTF-16 code units, since 1.3.7) with JSON-RPC `-32602`.

```ts
const mcp = createMcpHandler({
  serverInfo: { name: "inventory-mcp", version: "1.0.0" },
  resourceTemplates: [
    {
      uriTemplate: "daloy://records/{table}/{id}",
      name: "record",
      description: "Read one record by table and id.",
      mimeType: "application/json",
      // {table} and {id} each match one URI segment. The values are raw,
      // untrusted strings: validate them before touching your database.
      read: async (uri, variables) => {
        const row = await db.findRecord(variables.table, variables.id);
        if (!row) throw new McpToolError(`No record ${variables.id}.`);
        return { uri, mimeType: "application/json", text: JSON.stringify(row) };
      },
    },
  ],
});
```

```ts
const mcp = createMcpHandler({
  serverInfo: { name: "records-mcp", version: "1.0.0" },
  resourceTemplates: [/* ... */],
  // Tighten when your URIs are always short. Default: 8192.
  maxResourceUriLength: 2048,
});
```

## What stays out of core

DaloyJS does not bundle the official MCP SDK, stdio process management, OAuth server metadata, or the `subscriptions/listen` notification stream (so no `listChanged` capability). It also does not implement the `io.modelcontextprotocol/tasks` or MCP Apps extensions. You can advertise an extension you implement yourself through the `extensions` option, but core ships none. Those pieces either add dependency weight or need a product-specific security model.

Features the specification deprecated in `2026-07-28` are deliberately absent rather than reimplemented: Roots, Sampling, and Logging (pass paths as tool parameters, call your LLM provider directly, and log to stderr or OpenTelemetry instead), the legacy HTTP+SSE transport, SSE resumability, and Dynamic Client Registration. New servers should not adopt them.

## Error handling

Throw `McpToolError` when the model can fix the call, for example missing arguments or a domain object that does not exist. The client receives an MCP tool result with `isError: true`. Unexpected errors become JSON-RPC internal errors and are redacted in production.

The raw message of an unexpected error is only included (as `error.data.detail`) when `exposeInternalErrors` is on. Since 2.0.0 the default fails closed: it is on only when `process.env.NODE_ENV` is `"development"` or `"test"`, so an unset `NODE_ENV` redacts. When the handler is mounted with `mcpRoutes()` on an App whose environment resolves to production (for example `new App({ env: "production" })`), details are redacted whatever `NODE_ENV` says. Only an explicit `exposeInternalErrors` value overrides both signals.

```ts
import { McpToolError, createMcpHandler } from "@daloyjs/core/mcp";

const mcp = createMcpHandler({
  serverInfo: { name: "inventory-mcp", version: "1.0.0" },
  tools: [
    {
      name: "inventory_lookup",
      description: "Look up inventory by SKU.",
      inputSchema: {
        type: "object",
        properties: { sku: { type: "string" } },
        required: ["sku"],
        additionalProperties: false,
      },
      handler: async (args) => {
        const sku = typeof args.sku === "string" ? args.sku.trim() : "";
        if (!sku) {
          throw new McpToolError("sku is required.");
        }

        const row = await inventory.findBySku(sku);
        if (!row) {
          throw new McpToolError(`No inventory record found for ${sku}.`);
        }

        return `${row.sku}: ${row.units} units`;
      },
    },
  ],
});
```

## The `bodySchemaMissing` warning and MCP

DaloyJS warns in development when a route declares a `2xx` response without a body schema, because OWASP API3 response-field stripping cannot run there (see the [API3 mapping](/docs/security/owasp-api-top-10#api3)). MCP responses are opaque JSON-RPC envelopes produced by `createMcpHandler()`, so the routes from `mcpRoutes()` ship with an envelope schema attached: they do not trip the warning, and the JSON-RPC envelope shows up in your generated OpenAPI document. Framework-mounted routes such as `/openapi.json` and `/docs` acknowledge themselves, so the warning only ever names routes you wrote.

If you mount the MCP handler on a hand-rolled route instead (for example to add extra `beforeHandle` hooks), declare that the opaque body is intentional with `acknowledgeNoResponseBodySchema: true`:

```ts
// Hand-rolled MCP mount (instead of mcpRoutes()): the response is an opaque
// JSON-RPC envelope built by createMcpHandler, so acknowledge the missing
// response body schema instead of leaving the boot warning unanswered.
app.post(
  "/mcp",
  {
    operationId: "mcpStreamableHttp",
    acknowledgeNoResponseBodySchema: true,
    responses: {
      200: { description: "MCP JSON-RPC response" },
      202: { description: "Accepted (notification, no content)" },
    },
  },
  ({ request }) => mcp(request),
);
```

## Security checklist

- Put auth in DaloyJS middleware before the MCP route. Bearer tokens, mTLS, IP restrictions, and per-client rate limits all work normally. In production a `secureDefaults` App [refuses to boot](/docs/security/boot-guards#6-unauthenticated-mcp-endpoint) if the `mcpRoutes()` `POST` endpoint has no auth hook. For a genuinely public server, opt out explicitly with `mcpRoutes(path, handler, { public: true })`.
- Leave the built-in `Origin` validation alone and prefer adding trusted web apps to `allowedOrigins` over any wildcard CORS layer in front of the endpoint.
- The advertised `inputSchema` is now [enforced server-side](#input-schema-enforcement) for its supported subset, but it is still not a substitute for full validation: check anything expressed only through `pattern`, `format`, or `anyOf`/`oneOf`/`allOf` inside the handler.
- Sign and bind `requestState` before it can influence anything. It passes through the client, so an unsigned blob is a request-forgery primitive. Include the authenticated principal, an identifier for the originating request, and a short expiry, and reject state that fails verification. See [multi round-trip requests](#multi-round-trip-requests).
- Pin `protocolVersions` to `["2026-07-28"]` once your clients have migrated. Header/body agreement is already enforced in both eras, so this is defense in depth rather than a fix. It means nothing reaches a tool without the full modern contract (required headers, per-request `_meta`, declared capabilities).
- Never mark a secret with `x-mcp-header`. Mirrored parameter values are visible to every proxy on the path.
- Re-authorize state handles on every call. Without protocol sessions, a handle passed as a tool argument is a string the model carries. Treat it as a name, not a capability.
- Leave `cacheScope` at `"private"` unless every caller genuinely sees the same tools, resources, and prompts.
- Keep tool descriptions precise. A vague tool is easier for a model to misuse and harder for a human to approve.
- Route outbound calls through `fetchGuard()` when a tool fetches URLs influenced by users, prompts, or external content.

---

Source: https://daloyjs.dev/docs/mcp