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.
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.
- 01AI clientPOST /mcp tools/call
- 02Your middlewareauth + rate limit
- 03createMcpHandler()origin, headers, inputSchema
- 04Your toolvalidated args only
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:
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:
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 withmcpRoutes(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
Originheader and refuses unknown browser origins with a 403. Desktop clients and CLIs, which send noOrigin, work normally. Trusted web apps go intoallowedOrigins. - The schema is enforced, not just advertised. Arguments are checked against your
inputSchemabefore 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:
And here is the schema check doing its job:
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:
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.