@sdxc/mcp
Model Context Protocol servers as remix/router actions, served over stateless Streamable HTTP
- Installs with
- @sdxc/logger@sdxc/result
- Depends on
remix- Used by
- reader, blog
- Source
- packages/mcp
Model Context Protocol servers as remix/router actions, served over stateless Streamable HTTP.
Revision 2026-07-28 of the Model Context Protocol made
MCP stateless: no handshake, no session id, no held-open stream. What is left is a function
from a request to a response, which is an ordinary route — so a tool's name and input schema
are its route, a handler and its middleware are its controller, and fetch takes the
RequestContext an application already has.
Installation
npm add @sdxc/mcp
Handlers receive the RequestContext from remix,
argument validation reports through
@sdxc/result, and log enrichment goes through
@sdxc/logger. All three install alongside this
package.
Usage
Declare A Tool
A tool declaration is the route table: the name a client calls, the prompt a model chooses it by, and the JSON Schema its arguments satisfy.
import { tool, tools } from "@sdxc/mcp";
export default tools({
searchDocuments: tool("search_documents", {
description: "Searches published documents by title, excerpt and tags.",
input: {
type: "object",
properties: {
query: { type: "string", description: "What to search for." },
limit: { type: "integer", minimum: 1, maximum: 50, default: 10 },
},
required: ["query"],
},
annotations: { readOnlyHint: true },
}),
});
Handle It
import { createHandler, ToolError } from "@sdxc/mcp";
import toolset from "./tools.js";
let mcp = createHandler({
name: "documents",
version: "1.0.0",
instructions: "Search and read the documents this server publishes.",
});
mcp.tools.map(toolset.searchDocuments, async (ctx) => {
// ctx.input.query is string and ctx.input.limit is number, both derived from the
// schema, with no second declaration.
let documents = await search(ctx.input.query, ctx.input.limit);
if (documents.length === 0) throw new ToolError("Nothing matched. Try a broader query.");
return documents;
});
export default { fetch: mcp.fetch };
A returned string becomes the answer verbatim, and any other value is serialized as JSON. A
ToolError message reaches the model as a result it can act on, so it reads as guidance: what
was wrong, and what would work instead.
Group Tools Under One Controller
Grouping is what lets one map() call cover several tools under a shared middleware chain. A
tool's address stays the name it was declared with, wherever it sits in the tree.
import { tool, tools } from "@sdxc/mcp";
let toolset = tools({
documents: tools({
list: tool("list_documents", { description: "…", input: { type: "object", properties: {} } }),
get: tool("get_document", {
description: "…",
input: { type: "object", properties: { slug: { type: "string" } }, required: ["slug"] },
}),
}),
});
mcp.tools.map(toolset.documents, {
middleware: [requireScope("documents:read")],
actions: {
list: (ctx) => listDocuments(ctx.get(Database)),
get: (ctx) => findDocument(ctx.get(Database), ctx.input.slug), // ctx.input.slug: string
},
});
A controller answers every tool in the group it names, so adding a tool to the declaration
is a type error until it is handled. A nested group needs its own map() call.
Declare A Resource
A resource is addressed by URI, and a URI is a URL — so the pattern is a
remix/route-pattern, the same syntax an application declares routes with.
import { resource, resources } from "@sdxc/mcp";
let resourceset = resources({
article: resource("https://example.com/articles/:slug.md", {
name: "Article",
title: "Published article",
description: "A published article, as Markdown.",
mimeType: "text/markdown",
}),
});
mcp.resources.map(resourceset.article, {
// Optional. Enumerates instances for resources/list.
list: async (ctx) => {
let articles = await listArticles(ctx.get(Database));
return articles.map((article) => ({
uri: resourceset.article.href({ slug: article.slug }), // typed, never concatenated
name: article.title,
}));
},
// Required. null reports the resource as missing.
read: async (ctx) => {
let article = await findArticle(ctx.get(Database), ctx.variables.slug);
return article?.content ?? null;
},
});
Tools and resources differ by who reaches for them: a tool is chosen by the model, a resource
is picked by the person or attached by their client, and resources/list is what puts a
corpus in the client's picker. Captures arrive as ctx.variables, leaving
RequestContext.params to the route's own params.
Resource URIs
The RFC 6570 template MCP publishes is derived from the pattern, so there is no second
declaration to keep in step: :name becomes {name}, and *name becomes {+name}, whose
reserved expansion is what allows the / a wildcard matches.
resource() converts the pattern at declaration and throws when it cannot. Optionals
((.:ext)), search constraints (?draft=1), unnamed wildcards (*), braces and repeated
capture names have no RFC 6570 equivalent.
Refusing early beats publishing a template a client would expand into a URI this server never
matches; a resource needing an optional segment is two resources.
A scheme of the server's own — notes://, db:// — declares and matches the same way,
and is what addresses content a client cannot fetch for itself, where a URI is an identity
rather than a fetchable address. Prefer https:// only when the client can fetch the
resource; otherwise every read goes through resources/read anyway. Such a scheme travels
as the host of an https address while a URI is matched, so a server declaring both
notes:// and https://notes/ would address one declaration twice.
Which list a declaration lands in follows from the declaration itself:
| Declaration | resources/list | resources/templates/list |
|---|---|---|
Captures variables, has list | the enumerated instances | yes |
Captures variables, no list | — | yes |
| Captures nothing | itself | — |
A read has no isError channel: MCP gives it only JSON-RPC errors, so returning null is
-32602 carrying the URI, and any exception is -32603 plus onError. null rather than an
empty array, which the specification reserves for a resource that exists with nothing to show.
Argument Handling
Arguments are filled in by a language model, so the validator is shaped around what a model actually sends rather than around strictness for its own sake.
An undeclared property is dropped, not refused. A model that invents an argument has still asked for something the tool can do.
nullcounts as absent. Models spell an omitted optional asnullconstantly, and treating it as a type error refuses a call that was perfectly clear.A
defaultis substituted, and the derived type marks that property present — so a handler readsctx.input.limitrather thanctx.input.limit ?? 20, which would restate the default the schema already declares.Every constraint is checked before answering, so a caller that got two arguments wrong learns about both in one round trip.
Values are taken as sent:
"20"is not20. A model that sent a string for a number misread the schema, and quietly accepting it hides that from the next call.
Validation runs before tool middleware, which is what lets middleware read ctx.input as a
typed value.
The schema subset is string (with enum, minLength, maxLength, pattern, format,
default), number and integer (with minimum, maximum, default), boolean, array
(with items, minItems, maxItems), and object (with properties, required), nested
however deep. There is no oneOf, no nullable and no union: each makes a schema harder for a
model to satisfy without making the tool more capable, and four clearly named tools beat one
tool with a union argument.
Where Each Kind Of Failure Is Reported
MCP reports two categories in two places, and putting one in the other is the mistake worth avoiding: a JSON-RPC error never reaches the model, so a failure it could act on disappears, while a tool result the model reads is the wrong home for a stack trace.
| Situation | Reported as | HTTP | The model sees |
|---|---|---|---|
| Unknown RPC method | -32601 | 404 | Nothing |
Unknown tool, or one available hid | -32602 | 200 | Nothing |
| Arguments fail the schema | -32602, with every failed constraint | 200 | Nothing |
ForbiddenError from middleware | -32602 | 200 | Nothing |
ToolError from a handler | Result with isError, message intact | 200 | The message, verbatim |
| Any other exception | Result with isError, message replaced | 200 | That the tool failed |
| Header disagrees with body | -32020 | 400 | Nothing |
| Unsupported protocol version | -32022, listing supported | 400 | Nothing |
Missing _meta protocol fields | -32602 | 400 | Nothing |
ToolError's message is the one written for a model. Every other exception was written for an
operator and may carry a query fragment or an upstream URL, so only onError receives it.
Below the message layer, a request that is not a POST is answered with 405, a disallowed
Origin with 403, a body that is not declared application/json with 415, and a
notification — a message with no id — with 202.
SUPPORTED_PROTOCOL_VERSIONS lists what this package speaks: 2026-07-28 only, so a request
naming any other version is refused with the supported list, which is how a client is told to
retry rather than to fall back. Batched requests are refused, since this revision removed them.
The surface is tools and resources, and capabilities advertises only what is actually mapped.
API
createHandler(options: HandlerOptions): McpHandler
Builds the handler for one server. Tools and resources register in insertion order, so every list stays deterministic for whatever cache a client keeps of it.
name: Stable identifier for this server.title?: Human-readable name for a client that renders one.version: This server's own version, independent of the protocol revision.instructions?: How to use the server as a whole, delivered withserver/discoverfor a client to put in the model's system prompt.toolMiddleware?: Middleware wrapping every tool call, before any group or action middleware.listTtlMs?: How long a client may cache a list result. Defaults to60000.cacheScope?:"public"or"private". Defaults to"private"once any tool or resource declaresavailable, since such a list varies by credential.allowedOrigins?: A list of origins or a predicate, checked when a request carriesOrigin. Omitting it allows any origin.onError?: Receives an exception a handler did not expect, along with the method, tool and URI, while the caller receives only a generic failure.
mcp.tools.map(tool: Tool, action: ActionOrHandler): void
Binds a handler to one tool, or an action object carrying that tool's own middleware and
available. Mapping is what registers a tool: only a mapped tool exists, the same way only a
mapped route is served. Mapping the same tool twice throws.
mcp.tools.map(group: ToolGroup, controller: Controller): void
Binds one action per tool in a group, under the controller's shared middleware. A nested
group typed as an action is a type error, and throws at map time.
mcp.resources.map(resource: Resource, action: ResourceAction): void
Binds read, and optionally list and available, to one resource. Every pattern joins one
matcher, so a URI matching more than one resource resolves to the most specific match.
mcp.fetch(input: Request | RequestContext): Promise<Response>
Answers one MCP request. Pass the RequestContext when there is one, so handlers read what the
surrounding middleware provided; pass a bare Request and one is built.
tool(name: string, definition: ToolDefinition): Tool
Declares one tool. name is limited to the 1–128 characters MCP allows — letters, digits, _,
- and . — and anything else throws at declaration rather than failing a call later.
definition carries description, input, and optionally title, output and
annotations.
tools(group: ToolGroup): ToolGroup
Groups declared tools and nested groups, returning the tree unchanged. Two tools sharing a name throw, since one of them would be permanently unreachable.
walk(group: ToolGroup): Generator<Tool>
Yields every tool in a declaration tree, depth first in declaration order.
createTool(tool: Tool, action: ActionOrHandler): ActionOrHandler
Types one tool's implementation against its declaration so it can live in its own file. A type
anchor at runtime: it returns what it was given, and ctx.input stays typed from the schema.
createToolController(group: ToolGroup, controller: Controller): Controller
The same for a whole group, requiring one action per tool the group declares.
resource(pattern: string, declaration: ResourceDeclaration): Resource
Declares one resource from a remix/route-pattern source. declaration carries name, and
optionally title, description and mimeType. A pattern RFC 6570 cannot express throws; see
Resource URIs.
resource.href(...args): string
Builds this resource's URI from its variables, typed by the declared pattern, so a listing never concatenates one by hand.
resources(group: ResourceGroup): ResourceGroup
Groups declared resources, returning the tree unchanged. Two resources sharing a name or a pattern throw.
walkResources(group: ResourceGroup): Generator<Resource>
Yields every resource in a declaration tree, depth first in declaration order.
createResource(resource: Resource, action: ResourceAction): ResourceAction
Types one resource's list and read against its declaration, so they can live in their own
file with ctx.variables typed from the pattern.
validateArguments(schema: ObjectSchema, value: unknown): Result<Record<string, unknown>, InvalidArgumentsError>
Checks a tools/call arguments object against a schema, filling in defaults and dropping
undeclared properties. The handler runs this for you; call it directly to apply the same rules
somewhere else.
contextFor(input: Request | RequestContext): RequestContext
Returns a RequestContext unchanged, or builds one around a bare Request. This is what lets
fetch accept either.
Errors
ToolError
A tool ran and could not do what was asked. Its message reaches the model verbatim, as a result
carrying isError.
ForbiddenError
A tool call is not permitted for this caller, reported as -32602. Reaching it means a call
got past the available predicate meant to hide the tool — the backstop for a client working
from a stale list.
InvalidArgumentsError
Arguments did not satisfy a tool's declared schema. Its issues array holds one entry per
failed constraint, each naming the property path it applies to.
Protocol Constants
SUPPORTED_PROTOCOL_VERSIONS
The revisions this package implements, newest first. A refusal carries this list.
LATEST_PROTOCOL_VERSION
The revision a server announces when it has to name one.
MetaKey
The _meta keys this revision reserves: ProtocolVersion, ClientInfo, ClientCapabilities
and ServerInfo.
ErrorCode
The JSON-RPC codes this server answers with, from ParseError through
UnsupportedProtocolVersion.
Context Keys
ToolInput, CurrentTool, ResourceUri, ResourceVariables and CurrentResource are the
keys the dispatcher publishes ctx.input, ctx.tool, ctx.uri, ctx.variables and
ctx.resource through. Read them with ctx.get() from code holding only a plain
RequestContext.
Types
ToolContext<Input>andResourceContext<Variables>— aRequestContextplus what a tool or resource handler reads off it.AnyRequestContextis the context generic over any middleware chain, andResourceVariableValuesis what a pattern captured.HandlerOptions,McpHandlerandCacheScope— whatcreateHandlertakes and returns.Tool,ToolGroup,ToolDefinition,ToolDescriptor,ToolAnnotations,Action,ActionOrHandler,Controller,ToolHandler,ToolMiddleware,CallToolResult,TextContentandInputOf— the tool side.Resource,ResourceGroup,ResourceDeclaration,ResourceDescriptor,ResourceAction,ResourceListing,ResourceContentsandReadResult— the resource side.ObjectSchema,StringSchema,NumberSchema,BooleanSchema,ArraySchema,PropertySchema,FromSchemaandFromObjectSchema— the schema subset, and the two types that derive a handler's argument type from it.ImplementationandClientCapabilities— a self-reported name and version, and what a client declares it can do.
Pattern: Mounting On Any Host
fetch takes a RequestContext or a bare Request, which covers every host. Mounted on a
route, everything the application's middleware provides reaches tools and resources:
import { createHandler } from "@sdxc/mcp";
let mcp = createHandler({ name: "documents", version: "1.0.0" });
router.map(routes.mcp, {
middleware: [requireApiKey()], // an ordinary remix middleware
handler: (ctx) => mcp.fetch(ctx),
});
Request middleware is the router's own — authentication, logging, providing a database, none of
it MCP-specific — and it runs for every method, which is what authentication needs:
tools/list must be authenticated too, since the list a caller sees depends on the credential.
A host with no middleware of its own passes the request straight through, and one with values to provide builds the context itself:
import { RequestContext } from "remix/router";
// A Worker, with nothing to provide.
export default { fetch: mcp.fetch };
// A Durable Object, providing its own values.
export class DocumentsMcp extends DurableObject {
fetch(request: Request) {
let ctx = new RequestContext(request);
ctx.set(Database, this.db);
return mcp.fetch(ctx);
}
}
Pattern: Hiding A Tool From A Caller
available decides whether a tool exists for this caller. One it refuses is absent from
tools/list and reported by tools/call as an unknown tool, so a read-only credential
never learns that a write tool is there:
mcp.tools.map(toolset.documents.create, {
available: (ctx) => ctx.get(ApiKey).scopes.includes("documents:write"),
middleware: [requireScope("documents:write")],
handler: (ctx) => createDocument(ctx.get(Database), ctx.input),
});
The scope appears twice because middleware only runs on a call, while tools/list needs an
answer before any call happens: once to hide the tool, once to enforce it. Declaring any
available also flips the list's cacheScope to private, since a list that varies by
credential must not be held by a shared intermediary. Resources take the same predicate, with
the same effect on their lists and reads.
Pattern: Metering A Tool Call
Tool middleware is this package's own, because a tool call is not an HTTP request and its
answer is not a Response — a middleware that meters or logs an outcome needs the result:
import type { ToolMiddleware } from "@sdxc/mcp";
export function meterUsage(): ToolMiddleware {
return async (ctx, next) => {
let result = await next();
if (!result.isError) await recordUsage(ctx.get(ApiKey).teamId, ctx.tool.name);
return result;
};
}
It attaches at three levels, innermost last: createHandler({ toolMiddleware }) wraps every
call, a controller's middleware wraps that group's calls, and an action's middleware wraps
one tool's.
ToolMiddleware's input type is erased by default, and because parameters are contravariant a
middleware written that way is assignable anywhere. Name a tool's input only when the middleware
reads it:
import type { InputOf, ToolMiddleware } from "@sdxc/mcp";
import { ToolError } from "@sdxc/mcp";
function requireOwnDocument(): ToolMiddleware<InputOf<typeof toolset.documents.get>> {
return async (ctx, next) => {
let document = await findDocument(ctx.get(Database), ctx.input.slug); // typed
if (document?.teamId !== ctx.get(ApiKey).teamId) throw new ToolError("No such document.");
return next();
};
}
Pattern: Enriching The Request Log
The handler writes into the request's own log, so the record a request already emits gains what only the handler knows:
| Method | Fields |
|---|---|
| Every method | mcp.method, mcp.protocol_version |
tools/call | mcp.tool, and mcp.is_error once there is a result |
resources/read | mcp.resource — the matched pattern |
| An unexpected exception | outcome: "error" with the error's fields, in addition to onError |
mcp.is_error is true for a ToolError result, for refused arguments, for a
ForbiddenError, and for an unexpected exception, so one filter finds every call the model did
not get a clean answer to. Only the last of those fails the log — a ToolError is the tool
answering as designed.
mcp.resource records the pattern (https://example.com/articles/:slug.md), never the URI: the
URI carries the slug, and a field with one value per document grows the index forever.
The enrichment goes through @sdxc/logger's
currentLog(), so it reaches a bare Worker or a Durable Object that opened a log around its own
fetch just as it reaches a router with log() in its chain. A host with no log open is served
the same, with nothing recorded.