sdxc

Type to search, or start from one of these:

@sdxc/mcp

Model Context Protocol servers as remix/router actions, served over stateless Streamable HTTP

npm add @sdxc/mcp
pnpm add @sdxc/mcp
yarn add @sdxc/mcp
bun add @sdxc/mcp
Depends on
remix
Used by
reader, blog

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:

Declarationresources/listresources/templates/list
Captures variables, has listthe enumerated instancesyes
Captures variables, no listyes
Captures nothingitself

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.

  • null counts as absent. Models spell an omitted optional as null constantly, and treating it as a type error refuses a call that was perfectly clear.

  • A default is substituted, and the derived type marks that property present — so a handler reads ctx.input.limit rather than ctx.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 not 20. 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.

SituationReported asHTTPThe model sees
Unknown RPC method-32601404Nothing
Unknown tool, or one available hid-32602200Nothing
Arguments fail the schema-32602, with every failed constraint200Nothing
ForbiddenError from middleware-32602200Nothing
ToolError from a handlerResult with isError, message intact200The message, verbatim
Any other exceptionResult with isError, message replaced200That the tool failed
Header disagrees with body-32020400Nothing
Unsupported protocol version-32022, listing supported400Nothing
Missing _meta protocol fields-32602400Nothing

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 with server/discover for 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 to 60000.

  • cacheScope?: "public" or "private". Defaults to "private" once any tool or resource declares available, since such a list varies by credential.

  • allowedOrigins?: A list of origins or a predicate, checked when a request carries Origin. 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> and ResourceContext<Variables> — a RequestContext plus what a tool or resource handler reads off it. AnyRequestContext is the context generic over any middleware chain, and ResourceVariableValues is what a pattern captured.

  • HandlerOptions, McpHandler and CacheScope — what createHandler takes and returns.

  • Tool, ToolGroup, ToolDefinition, ToolDescriptor, ToolAnnotations, Action, ActionOrHandler, Controller, ToolHandler, ToolMiddleware, CallToolResult, TextContent and InputOf — the tool side.

  • Resource, ResourceGroup, ResourceDeclaration, ResourceDescriptor, ResourceAction, ResourceListing, ResourceContents and ReadResult — the resource side.

  • ObjectSchema, StringSchema, NumberSchema, BooleanSchema, ArraySchema, PropertySchema, FromSchema and FromObjectSchema — the schema subset, and the two types that derive a handler's argument type from it.

  • Implementation and ClientCapabilities — 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:

MethodFields
Every methodmcp.method, mcp.protocol_version
tools/callmcp.tool, and mcp.is_error once there is a result
resources/readmcp.resource — the matched pattern
An unexpected exceptionoutcome: "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.