Skip to content
<- Back to blog
MCPAI agentsSecurity

Build Your Own MCP Server on DaloyJS, and Why It Refuses to Run Without Auth

Turn your API into tools an AI assistant can call, with createMcpHandler() and mcpRoutes(). No MCP SDK, no extra dependency, and a production app that fails closed if you forget the auth.

Devlin Duldulaosoftware engineer & published book author8 min read

Two days ago I wrote about A2A, and in the first paragraph I casually said DaloyJS "answered" the MCP question with its built-in server. Then I went to link the blog post about it and found out there was none. I wrote the feature, wrote a very long docs page, and apparently forgot to tell anyone. Classic developer marketing strategy. So here it is.

The short version: if you want an AI assistant like Claude, Cursor or Copilot to use your API, you give it an MCP server. DaloyJS ships one in core. You describe your tools, mount one route, and your existing auth, rate limits and validation apply to the model exactly like they apply to everybody else.

MCP in one paragraph

The Model Context Protocol is how an AI client discovers and calls tools. Your server lists tools with a name, a description and a JSON Schema for the input. The model reads that list, decides which tool fits, and calls it with arguments. You run the tool and send back text and structured data. That is the whole idea. The rest is making sure the model can only do what you meant it to do.

One tool call, end to end
  1. 01AI clientPOST /mcp tools/call
  2. 02Your middlewareauth + rate limit
  3. 03createMcpHandler()origin, headers, inputSchema
  4. 04Your toolvalidated args only
The model never reaches your handler without passing the same auth and rate limits as a human caller, and arguments that do not match the declared inputSchema are refused before your code runs.

What it looks like

createMcpHandler() is the protocol layer. mcpRoutes() turns it into normal DaloyJS routes, so it sits behind the same middleware as the rest of your app. This is a complete, runnable server:

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", version: "1.0.0" },
  instructions: "Look up stock levels for Acme products.",
  tools: [
    {
      name: "inventory_lookup",
      description: "Units on hand for one SKU.",
      inputSchema: {
        type: "object",
        properties: { sku: { type: "string", minLength: 1 } },
        required: ["sku"],
        additionalProperties: false,
      },
      annotations: { readOnlyHint: true },
      handler: async (args) => {
        const sku = String(args.sku);
        const units = await inventory.unitsFor(sku);
        if (units === 0) throw new McpToolError(`Unknown SKU ${sku}.`);
        return {
          content: [{ type: "text", text: `${sku}: ${units} units` }],
          structuredContent: { sku, units },
        };
      },
    },
  ],
});

const app = new App();
app.use(rateLimit({ windowMs: 60_000, max: 120 }));
app.use(bearerAuth({ validate: (token) => timingSafeEqual(token, process.env.MCP_TOKEN!) }));
for (const route of mcpRoutes("/mcp", mcp)) app.route(route);

serve(app, { port: 3001 });

A few things are doing more work than they look. The inputSchema is not just documentation for the model, it is enforced (more on that below). readOnlyHint tells the client this tool does not change anything, which a client can use when it decides whether to ask the user before calling it. And McpToolError sends the model a readable tool error instead of a stack trace, so it can say "that SKU does not exist" instead of inventing stock numbers. Models already invent enough.

Then point any MCP client at it:

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

No SDK, no extra dependency

DaloyJS does not wrap the official MCP SDK. The protocol is implemented in core, and @daloyjs/core still has zero runtime dependencies. I care about this more than is probably healthy, but every dependency is one more thing that can get a malicious release on a Friday night.

It also means the server speaks the current spec, MCP 2026-07-28, which removed the old session handshake. Every request now carries its own protocol version and client capabilities, so any request can land on any instance. That is the exact shape serverless and edge platforms want, and it is the shape DaloyJS was built for. Older clients still work on the same endpoint: the server picks the protocol era from what the client declares.

That dual-era support is also where a lazy implementation gets burned. If a gateway in front of your server routes on the MCP headers, an attacker could declare an old protocol version, keep the headers the gateway likes, and send a body that does something else. DaloyJS checks that the headers and the body agree in both eras, and refuses the request when they do not.

Secure by default, because the caller is a model

A tool call looks like an API request, but the thing deciding to make it is a language model that can be talked into things by whatever text it read last. So the MCP endpoint gets stricter defaults than a normal route:

  • No auth, no service. In a production app with secureDefaults (the default), an MCP route without an auth hook fails closed. Every request gets a 500, and your logs get an error that tells you exactly how to fix it. A genuinely public MCP server has to say so with mcpRoutes(path, handler, { public: true }).
  • DNS rebinding is blocked. A malicious web page can trick a browser into calling a server on your machine or your network. The handler checks the Origin header and refuses unknown browser origins with a 403. Desktop clients and CLIs, which send no Origin, work normally. Trusted web apps go into allowedOrigins.
  • The schema is enforced, not just advertised. Arguments are checked against your inputSchema before your handler runs.
  • The boring bits are on too: body size limits, prototype-pollution-safe parsing, and internal errors redacted in production, so a failing tool does not leak your database error to the model (and from there, to whoever is chatting with it).

Here is what the boot guard says when you forget auth. I wrote it to be the kind of error message I wish I got at 2 AM:

text
MCP route POST /mcp (from mcpRoutes()) has no authentication hook in its
effective hook chain. MCP tools are model-controlled and can trigger side
effects, so an unauthenticated endpoint is a high-impact default. Install an
auth middleware covering the MCP route (e.g. app.use(bearerAuth({ ... }))),
wrap a custom auth hook with markAuthHook(...), pass
mcpRoutes(path, handler, { public: true }) to intentionally expose it, ...

And here is the schema check doing its job:

ts
// The model "helpfully" adds an argument you never declared:
{ "name": "inventory_lookup", "arguments": { "sku": "ACME-1", "drop": "table" } }

// Your handler never runs. The model gets this back instead:
{
  "code": -32602,
  "message": "Invalid arguments for tool \"inventory_lookup\": arguments.drop: unexpected property (additionalProperties is false)"
}

The enforcement covers the security-relevant subset of JSON Schema: types, required, additionalProperties, enums and const, and length, range and item-count bounds, including nested objects and arrays. Anything you express only with pattern, format, $ref or anyOf still needs a check in the handler. (pattern is skipped on purpose, so your regex can never become a ReDoS target for attacker-controlled input.) The docs list exactly what is covered, because "we validate your input" with a hidden asterisk is how people get hurt.

Tools that should ask a human first

Some tools should not run just because a model felt confident. Deploying, refunding, deleting. The current spec lets a tool pause and ask the user a question through the client, then continue when the answer comes back. DaloyJS supports it, and the interesting part is the requestState the tool hands out while it waits:

ts
// Excerpt: a tool that pauses to ask the human before deploying.
if (!ctx.inputResponses?.confirm) {
  return {
    resultType: "input_required",
    inputRequests: { confirm: { method: "elicitation/create", params: { /* ... */ } } },
    // This comes back through the client. Treat it as attacker-controlled:
    // sign it, bind it to the user and the target, and give it an expiry.
    requestState: await signState({ sub: userId, service: args.service, exp: Date.now() + 120_000 }),
  };
}

That state travels through the client and comes back on the retry, so by the time you read it, anyone could have edited it. An unsigned blob there is a request-forgery primitive with a nice JSON shape. Sign it, bind it to the user and the thing being changed, and reject it when it does not verify. The full example shows the verify side too.

What stays out of core

No stdio transport, no OAuth authorization-server metadata, no subscription stream, and none of the features the spec deprecated in 2026-07-28 (Roots, Sampling, Logging, the old HTTP+SSE transport). New servers should not adopt them, so DaloyJS does not reimplement them just to have a longer feature list. If you need auth for remote clients, put your identity provider in front and verify its tokens in middleware, the same way you would for any API. The docs explain each one.

MCP or A2A?

Same rule as last time: pick by who is calling. If an AI assistant needs to use your API as a set of tools, that is MCP, and this post. If another company's agent needs to hand your service a whole job and get a result back, that is A2A. One DaloyJS app can do both, next to its normal REST API, with the same auth and validation for all three.

A small confession

The docs MCP server on this very website, the one at /mcp that lets your assistant search these docs, does not run on DaloyJS. It runs on Vercel's mcp-handler. The shoemaker's children, shoes, you know how it goes. I am not going to pretend otherwise in a post about honest defaults. Moving it over is overdue.

Try it

MCP support has been in @daloyjs/core since 1.0.0, so there is nothing extra to install. Start with the MCP server docs, run through the security checklist before you ship, and read boot guards if you want to know what else refuses to run in production. Give the model a token. It is very polite, and it should still authenticate.

About the author: Filipino fullstack developer in Norway. Has spent around 12 years building APIs for humans, and has now accepted that the most frequent caller of his APIs will be a language model that never reads the docs but somehow still has opinions about them.