Skip to content

Search docs

Jump between documentation pages.

Browse docs

Model Context Protocol (MCP)

DaloyJS can host a dedicated Model Context Protocol 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.

Dedicated MCP boundary
  1. AI clientClaude, Cursor, VS Code
  2. DaloyJS MCP appPOST /mcp JSON-RPC
  3. Tools and contexttools, resources, prompts
  4. Existing systemsdatabase, 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, rate limits, auth middleware, and problem+json errors.

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,
} 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) => 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

  • initialize, ping, tools/list, tools/call, resources/list, resources/templates/list, resources/read (including template-matched URIs), prompts/list, and prompts/get with required-argument enforcement.
  • Protocol-version negotiation, MCP-Protocol-Version rejection for unsupported versions (headerless requests assume 2025-03-26per 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.

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 nottreated 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

Breaking 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.argumentsagainst 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.

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) };
      },
    },
  ],
});

What stays out of core

DaloyJS does not bundle the official MCP SDK, stdio process management, OAuth server metadata, persistent MCP sessions, server-initiated SSE, or experimental tasks. Those pieces either add dependency weight or need a product-specific security model. Keep them in your application or a separate integration package until your use case needs 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.

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). 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 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 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.
  • 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.