DaloyJS MCP server
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.
- AI clientClaude, Cursor, VS Code
- DaloyJS MCP appPOST /mcp JSON-RPC
- Tools and contexttools, resources, prompts
- Existing systemsdatabase, REST API, queues
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 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:
| 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 in the spec (DaloyJS answers -32602 in both eras) | -32602 |
Install
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. The GET hint is credential-free, so it only says which capability kinds exist (tools, resources, resourceTemplates, prompts as booleans); tool names, resource URIs, templates, and prompt names are returned by the authenticated POST discovery calls only (since 1.17.0).
When the MCP routes share an app with other routes that need different auth, scope the hook to the transport with mcpRoutes("/mcp", mcp, { hooks: bearerAuth({ ... }) }). The hook then runs on POST only, the GET hint and OPTIONS preflight stay credential-free, and the production boot guard is satisfied. Prefer this to a hand-rolled route.
Who is calling: ctx.state
Since 1.7.0, mcpRoutes() forwards the Daloy ctx.state to every tool, resource, and prompt handler as ctx.state. Whatever your auth middleware stored after verifying the credential is the caller's identity: jwk() puts the verified token on ctx.state.user (with sub and scope), and a custom hook wrapped in markAuthHook() can store its own record. Use it for per-tool authorization, tenant scoping, and binding requestState.
Never take identity from a request header or argument. Headers likex-user-id, tool arguments,_meta.clientInfo, andrequestStateare all chosen by the caller. Only the verifiedctx.stateprincipal is identity.ctx.stateis{}when the handler is called directly or from a hand-rolled route that does not pass it, so treat a missing principal as unauthenticated.
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.
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.
The REST endpoint should return 200 OK with a response like this:
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:
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, andprompts/get. Every result carriesresultTypeand_meta.serverInfo; cacheable ones also carryttlMs/cacheScope. Per-request_metavalidation, standard-header validation (-32020), multi round-trip results, andx-mcp-headermirroring are all enforced. - MCP 2025-11-25 and earlier (legacy):
initializeandpingon 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 assume2025-03-26per the spec), JSON-RPC parse errors, accepted notifications, unknown-pagination-cursor rejection, and bounded request bodies parsed with the framework'ssafeJsonParseso__proto__/constructor/prototypekeys are stripped, matching the REST body parsers. - Server-side
tools/callargument validation against each tool'sinputSchemabefore the handler runs (see below). - Built-in
Originvalidation against DNS rebinding, with anallowedOriginsallowlist for browser-based clients. - MCP 2025-11-25 metadata: server
description,websiteUrl, andicons. TooloutputSchema,annotations(read-only, destructive, idempotent, open-world hints), andicons. Icons on resources, templates, and prompts. Tool results that return onlystructuredContentget 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.
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.
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.
A separate credential for discovery
By default one auth middleware covers the whole POST /mcp route, so tools/list needs the same credential as tools/call. That is the safe default, but it hurts when the caller is a backend acting for users with delegated (on-behalf-of) tokens: there is no user at startup, so the backend cannot list the tools to log or register them, and ends up running discovery on every query.
mcpRoutes() takes a methodAuth option (since 1.8.0) that gives discovery its own credential. The discovery methods (server/discover, tools/list, resources/list, resources/templates/list, prompts/list, exported as MCP_DISCOVERY_METHODS) use discovery. Everything else uses call.
- Mcp-Method headerread before the body
- Discovery method?exact match, else call
- discovery or call authonly that gate runs
- Header must match bodyelse 400, -32020
How it stays fail-closed:
- Header and body must agree. Routing auth on a header is only safe because the handler rejects a mismatch with
400and-32020(see required request headers), in both protocol eras. For the same reasonmethodAuthonly accepts a handler built bycreateMcpHandler()and throws at construction otherwise. - No header means
call. Legacy clients that send noMcp-Methodheader, and any value that is not an exact discovery method name (Tools/List,tools/list, tools/call), get thecallcredential. - Only auth gates are accepted.
discoveryandcallmust be authentication hooks (bearerAuth(),jwk(),basicAuth(),clientCertAuth(),httpSignatureAuth(), or a bundle wrapped withmarkAuthHook()), optionally combined withsome(),every(), andrequireScopes(). A bundle that also carries other middleware, such asevery(rateLimit(...), bearerAuth(...)), throws: that middleware would only run for one credential class. Put it in thehooksoption instead (see below), or inapp.use(). - Scopes follow the selected bundle. A request is checked only against the
requireScopes()of the bundle it selects, so discovery never needs the call scopes. Those scopes are recorded before any replay middleware runs, soresponseCache()andidempotency()never replay atools/callresult to a caller without them. - The boot guard still applies. The combined hook counts as auth for the production boot guard and for
app({ requireAuth: true }).methodAuthtogether withpublic: truethrows. - Works with
hooks. Non-auth middleware in thehooksoption, such asrateLimit(), runs first and applies to every MCP request, so it also counts the ones auth rejects. An auth hook inhooksthrows, because it would apply to discovery too. Move it intomethodAuth.call. - Opt-in only. Without
methodAuth, nothing changes.
Some useful variations:
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:
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. Since 1.20.0 an integer header must also be written in decimal (42, -7, or 3.0). Forms that JavaScript's Number() would coerce to the same value, such as 0x2A, 4.2e1, +42, 042, or an empty or padded value, are refused, because a gateway reading the header as text or with parseInt would see a different number than the tool runs on. 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.
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.
- 01requestMCP clientDaloyJS tooltools/call (id: 1)deploy_service { service: 'checkout-api' }
- 02responseDaloyJS toolMCP clientinput_required + signed requestStateinputRequests: { confirm: elicitation/create }
- 03noteMCP clientUserPrompts for confirmationthe client owns the UI, not the server
- 04requestMCP clientDaloyJS tooltools/call (id: 2)same params + inputResponses + requestState
- 05noteDaloyJS toolDaloyJS toolVerify requestState before actingHMAC/AEAD, principal binding, short expiry
- 06responseDaloyJS toolMCP clientcompletethe final tool result
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.
TreatrequestStateas 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 fromctx.stateand the originating request, give it a short expiry, and on the retry check that the signed principal equals the current one. Reject anything that fails verification. Incoming values are capped atMCP_MAX_REQUEST_STATE_LENGTH(8 KiB) so a hostile client cannot force large state parsing.
inputResponsesare client assertions, not proof. A client, or anyone holding a valid token, can sendinputResponses: { confirm: { action: "accept" } }on the first call without ever showing a prompt. Elicitation is a UX step for honest clients. Do not use it as the only control on a destructive action. Gate the action on the verified principal's permissions, and require a signedrequestStateso the confirmation is tied to an interim result your server actually issued.
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.
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.
Input schema enforcement
Behavior change. A tool'sinputSchemaused to be documentation only. It is now enforced server-side:tools/callarguments 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-32602instead.
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.
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:
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 1.3.7 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.
Variables are passed through exactly as they appear in the URI. They are not percent-decoded, and nothing filters their content: .., ..%2F..%2Fetc%2Fpasswd, __proto__, and 1' OR '1'='1 all reach read unchanged. Allowlist the values you expect, never build a filesystem path from one, and use parameterized queries. If you decode a variable yourself, validate the decoded value, not the raw one.
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 1.3.7 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.
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.
For extra per-endpoint hooks, use mcpRoutes(path, handler, { hooks }) rather than a hand-rolled route.
Security: a hand-rolled route loses whatmcpRoutes()sets up. The production auth boot guard only recognizesmcpRoutes()routes, so a hand-rolledPOST /mcpwith no auth starts cleanly and runs tools for anyone. Tools also see an emptyctx.stateunless you pass it. If you must hand-roll, declareauthon the route so the boot guard refuses to start without an auth hook, and forwardstate.
A hand-rolled route must also declare that the opaque body is intentional with acknowledgeNoResponseBodySchema: true:
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
secureDefaultsApp refuses to boot if themcpRoutes()POSTendpoint has no auth hook. Since 1.5.4 the auth hook must also run on each request: if it is skipped (for example by anexcept()wrapper), the request is refused with500in production and logged asauth.not_enforcedelsewhere. For a genuinely public server, opt out explicitly withmcpRoutes(path, handler, { public: true }). - Prefer
mcpRoutes()(with{ hooks }for endpoint-scoped auth) over a hand-rolled route. A hand-rolled route is invisible to the boot guard unless it declaresauth. - Authorize per tool, not just per server. Read the verified principal from
ctx.stateand check its scopes in each side-effecting tool. Never take identity from a header, an argument, orclientInfo. - Validate audience and issuer on incoming tokens (for example
jwk({ audience, issuer })) so a token minted for another service is refused. Do not forward the caller's token to downstream APIs (token passthrough). Call them with the server's own credentials and the caller's identity as data. - Tool
annotations(readOnlyHint,destructiveHint, and so on) are hints for the client's UI. The server enforces none of them. - Treat everything a tool or resource returns as untrusted text for the model. Database rows, web pages, emails, and tickets can carry instructions (indirect prompt injection). Return only the fields the task needs, and never put secrets in results.
- Fail at startup when a token or key environment variable is missing, instead of discovering it on the first request.
- If discovery needs a lighter credential than
tools/call, usemethodAuthrather than anexcept()on the MCP path or a header check of your own. It relies on the handler's header/body agreement check and falls back to thecallcredential when in doubt. - Leave the built-in
Originvalidation alone and prefer adding trusted web apps toallowedOriginsover any wildcard CORS layer in front of the endpoint. - The advertised
inputSchemais now enforced server-side for its supported subset, but it is still not a substitute for full validation: check anything expressed only throughpattern,format, oranyOf/oneOf/allOfinside the handler. - Sign and bind
requestStatebefore it can influence anything. It passes through the client, so an unsigned blob is a request-forgery primitive. Include the authenticated principal fromctx.state, an identifier for the originating request, and a short expiry, and reject state that fails verification.inputResponsesare client assertions, not proof that a person approved anything. See multi round-trip requests. - Pin
protocolVersionsto["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
cacheScopeat"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.