Skip to content

Connect MCP

The Model Context Protocol (MCP) surface ships in @warlock.js/ai-tools and registers as ai.mcp the moment you import the package. It runs in both directions:

  • ai.mcp(server) — connect to any external MCP server and adapt its tools as native agent tools (consume).
  • ai.mcp.serve(source, options) — expose a local Warlock agent / supervisor / orchestrator (or a raw ToolContract[]) AS an MCP server other clients call (publish).
import "@warlock.js/ai-tools";
import { ai } from "@warlock.js/ai";

ai.mcp is a callable factory with a .serve member attached — one object, both directions.

Direction A — consume an external MCP server

Section titled “Direction A — consume an external MCP server”

ai.mcp(server, options?) opens a connection, lists the server’s tools, and adapts each into a native ToolContract — so a remote MCP tool is indistinguishable from a local one and drops straight into ai.agent({ tools: [...] }).

const github = ai.mcp(
{ type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
{ namePrefix: "github." },
);
const dev = ai.agent({
model,
systemPrompt: "Use the GitHub tools to triage issues.",
tools: [...(await github.tools())], // handshake + tools/list, run once and cached
maxTrips: 20,
});
await dev.execute("Find the oldest open 'bug' issue and propose a fix.");
await github.close(); // on teardown — kills the child / ends the HTTP session

The server argument is discriminated by type:

// Spawn a child process and speak JSON-RPC over its stdin/stdout.
// Uses node:child_process + node:readline — NO dependency.
{ type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"], env: { GITHUB_TOKEN: "…" } }
// Streamable HTTP against an endpoint.
{ type: "http", url: "https://mcp.example.com/rpc", headers: { authorization: "Bearer …" } }

stdio servers receive a deliberately small inherited environment so the executable and platform runtime can start, without receiving your application’s ambient configuration. The inherited keys are PATH/Path, home-directory variables (HOME, USERPROFILE, HOMEDRIVE, HOMEPATH), and Windows runtime variables (SystemRoot/SYSTEMROOT, WINDIR, ComSpec/COMSPEC, PATHEXT). Everything else is omitted, including application secrets such as DATABASE_URL, OPENAI_API_KEY, and tokens in the parent process.

Pass env to provide only the server-specific values it needs. Explicit values take precedence over inherited defaults, including PATH when a server needs a particular executable location:

const github = ai.mcp({
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
env: {
GITHUB_TOKEN: process.env.GITHUB_TOKEN!, // deliberately opt this server into its token
PATH: "/opt/mcp-tools/bin", // overrides inherited PATH for this child
},
});

Do not spread process.env into this object: doing so defeats the boundary and forwards unrelated secrets to the server. Omit env when the server needs no configuration.

ai.mcp(server, {
namePrefix: "github.", // prepended to every remote tool name
filter: (name) => name.startsWith("issues_"), // only adapt tools whose name passes
timeoutMs: 30_000, // per tools/call deadline (default 30s)
signal: controller.signal, // optional: close the client when this aborts
});
  • namePrefix is the collision mitigation — there is no runtime tool-name collision guard (the agent resolves a tool by first name match), so the author owns uniqueness. Prefix remote tools to keep them distinct from local ones.
  • filter receives each tool’s unprefixed name; return false to drop it.
  • signal binds the client’s lifetime to a run. When it aborts, the client closes its transport (kills the stdio child / ends the HTTP session), in-flight requests reject, and later tools() or tool calls fail with McpTransportError type "closed" without sending anything. Every tools/call honors it together with the per-call ctx.signal the agent passes — whichever aborts first. close() remains available and is idempotent.
const controller = new AbortController();
const github = ai.mcp(
{ type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
{ namePrefix: "github.", signal: controller.signal },
);
const dev = ai.agent({ model, tools: [...(await github.tools())] });
const result = await dev.execute("Triage the open issues", { signal: controller.signal });
controller.abort(); // ends the run AND shuts the MCP server down

client.tools() is lazy and cached: the first call runs the initialize handshake, sends notifications/initialized, then tools/list, and maps each descriptor into a ToolContract. Repeat calls return the cached array without re-handshaking. For each remote tool:

  1. its JSON-Schema inputSchema is wrapped as a Standard Schema via jsonSchemaToStandard (see below);
  2. execute issues tools/call honoring ctx.signal (cooperative abort) and the configured timeoutMs, then unwraps the result content (a single JSON text block is parsed back into an object);
  3. an MCP isError result is thrown so the surrounding tool() wraps it as a ToolExecutionError — the agent reads it as { error } data and self-corrects.

When selecting one discovered tool, guard the array result before invoking it. A server may advertise no tools, and noUncheckedIndexedAccess reflects that:

import type { McpClient } from "@warlock.js/ai-tools";
export async function invokeFirst(client: McpClient, input: unknown) {
const [contract] = await client.tools();
if (!contract) throw new Error("The MCP server advertised no tools");
return contract.invoke(input);
}

Connection / handshake failures surface at agent-construction time because the example awaits github.tools() — you see them then, not mid-run. A tools/call failure raised mid-run is wrapped by tool() and reaches the model as data. Branch on McpTransportError.type:

import { McpTransportError } from "@warlock.js/ai-tools";
try {
await github.tools();
} catch (error) {
if (error instanceof McpTransportError && error.type === "connect") {
// the child failed to spawn, the endpoint was unreachable, or the handshake failed
}
}

McpTransportError.type: "connect" | "protocol" | "timeout" | "closed" (plus an optional .method naming the in-flight JSON-RPC method).

Direction B — expose a Warlock primitive AS an MCP server

Section titled “Direction B — expose a Warlock primitive AS an MCP server”

ai.mcp.serve(source, options) turns a local set of ToolContracts into a Model Context Protocol server — so Cursor, Claude Desktop, or any MCP client can call your Warlock tools / agent over the wire. It serves over stdio (the default) or the MCP Streamable HTTP transport.

// stdio — for a client that spawns your process
const server = ai.mcp.serve([ai.tools.calculator(), ai.tools.dateTime()], {
name: "warlock-utils",
transport: { type: "stdio" },
});
await server.start();
// ...later, on teardown:
await server.stop();

Over stdio the server reads one JSON-RPC message per line. Blank and non-JSON lines and notifications get no answer; a JSON value that is not a request object (null, a number, an array) gets a JSON-RPC -32600 “Invalid Request” error with id: null. A bad line never stops the server.

// http — a loopback endpoint guarded by a bearer token
const { url, close } = await ai.mcp
.serve([ai.tools.calculator(), ai.tools.dateTime()], {
name: "warlock-utils",
transport: { type: "http", auth: { bearer: process.env.MCP_TOKEN ?? "" } },
})
.start();
console.log(url); // http://127.0.0.1:53211/mcp — port 0 (the default) picks a free port
await close(); // drains in-flight calls, then frees the port

The source — array, anything with tools(), or named groups

Section titled “The source — array, anything with tools(), or named groups”
// A literal array of contracts:
ai.mcp.serve([calc, dt], { name: "utils" });
// Anything that can enumerate its ToolContracts — e.g. a workspace:
ai.mcp.serve({ tools: () => ws.tools.all() }, { name: "acme-workspace" });
// Several named tool sets:
ai.mcp.serve({ groups: { github: githubAgent, files: [readFile, writeFile] } }, { name: "dev" });

The tools are snapshotted when serve() is called (source.tools() runs once), so a stable surface is advertised for the life of the server.

Every exposed tool name must be unique. Two tools with the same name throw McpServeError (type: "collision") when serve() is called — nothing is served with an ambiguous name. To serve same-named tools from different sets, use a grouped source and opt into prefixing:

ai.mcp.serve(
{ groups: { prod: [searchTool], staging: [searchTool] } },
{ name: "search", prefixToolNames: true }, // exposes prod_search and staging_search
);
ai.mcp.serve(
{ groups: { github: githubAgent } },
{ name: "dev", prefixToolNames: (group, tool) => `${group}.${tool}` }, // github.create_issue
);

prefixToolNames on an ungrouped source throws McpServeError (type: "config"). Duplicate resource URIs and prompt names are collisions too.

ai.mcp.serve(source, {
name: "warlock-utils", // advertised in the initialize response (required)
version: "1.0.0", // default: the package version
transport: { type: "stdio" }, // default: stdio — or { type: "http", … } below
schemaTarget: "draft-2020-12", // JSON-Schema dialect for each tool's inputSchema
results: "text", // default — opt in to native CallToolResult detection with "native"
prefixToolNames: false, // grouped sources only — see above
resources: [], // optional — see "Resources and prompts"
prompts: [], // optional
});
  • schemaTarget defaults to "draft-2020-12" — a neutral MCP draft, deliberately overriding extractJsonSchema’s own "openai-strict" default. Set it to match a consuming client’s expectation.
  • initialize → advertises { name, version } and capabilities: { tools: {} } (plus resources / prompts when configured). The protocol version is negotiated: the client’s requested version is echoed when the server speaks it (2025-11-25, 2025-06-18, 2025-03-26 — exported as SUPPORTED_PROTOCOL_VERSIONS), otherwise the newest is offered.
  • tools/list → one descriptor per tool; each inputSchema is produced by extractJsonSchema(contract.input, { target: schemaTarget }).
  • tools/call → routes to the named contract’s invoke() and maps the never-throwing result: by default, data becomes a { type: "text" } content block (a string is sent verbatim, anything else is JSON-stringified), and error becomes an isError: true result. Opted-in native results pass through after validation, as described below. An unknown tool name answers with a JSON-RPC error.
  • ping → {}.

Return mcpResult(...) from a tool to preserve MCP content blocks, isError, structuredContent and _meta over either stdio or HTTP:

import { mcpResult } from "@warlock.js/ai-tools";
import { ai } from "@warlock.js/ai";
const count = ai.tool({
name: "count",
description: "Return a structured count",
meta: {
outputSchema: {
type: "object",
properties: { count: { type: "number" } },
required: ["count"],
},
},
execute: async (_input: unknown, ctx) =>
mcpResult({
content: [{ type: "text", text: '{"count":2}' }],
structuredContent: { count: 2 },
_meta: { tenant: ctx?.request?.headers["x-tenant-id"] ?? "unknown" },
}),
});
const server = ai.mcp.serve([count], { name: "counts" });
await server.start();

mcpResult returns a branded McpNativeResult wrapper; the server unwraps it and sends the original payload without adding or rewriting fields. A native isError: true remains a tool error. Thrown errors keep the existing tool-error mapping. Unbranded objects still become JSON text by default, even if they contain content or isError.

For an existing server whose tools already return native objects, enable detection with results: "native":

const nativeTool = ai.tool({
name: "native-result",
description: "Return a native MCP tool error",
execute: async () => ({ content: [{ type: "text", text: "fixture" }], isError: true }),
});
ai.mcp.serve([nativeTool], { name: "native-tools", results: "native" });

In native mode, an object containing any of content, isError, structuredContent or _meta is validated as a native result; other values retain text serialization. Set meta: { mcpResults: "native" } on a tool to enable detection for that tool, or meta: { mcpResults: "text" } to disable server-wide detection for it. Explicit mcpResult(...) returns always opt in.

Native content must be an array of MCP text, image, audio, resource-link or embedded-resource blocks with the required fields for each type. isError must be boolean when supplied; structuredContent and _meta must be JSON objects. Malformed or non-JSON native payloads produce a JSON-RPC internal error (-32603) instead of being sent to clients. Validation occurs at the serving boundary, including for helper returns.

meta.outputSchema is advertised as outputSchema in tools/list. The native result’s structuredContent is passed through unchanged; tool authors must ensure it conforms to that schema. The MCP 2025-11-25 tool specification recommends also including the serialized output in a text block for compatibility.

Native tools receive the same per-request ctx.request.headers, ctx.request.remoteAddress and ctx.signal as ordinary tools. Request identity is supplied independently on every HTTP call; over stdio ctx.request is absent. Only fields explicitly returned by the tool are sent to the client.

const server = ai.mcp.serve(tools, {
name: "session-tools",
transport: {
type: "http",
host: "127.0.0.1", // default — loopback only
port: 0, // default — a free ephemeral port
path: "/mcp", // default — the single MCP endpoint
auth: { bearer: sessionToken }, // required off loopback
allowedOrigins: ["https://app.example.com"], // browser origins besides loopback
maxBodyBytes: 1_048_576, // default 1 MiB — larger bodies get 413
drainTimeoutMs: 5_000, // how long close() waits for in-flight calls
},
});
const { url, close } = await server.start();

start() resolves once the port is bound with { url, close }; calling it again while running resolves the same handle. close() (or server.stop()) stops accepting connections, lets in-flight calls finish, and frees the port. A call still running after drainTimeoutMs sees its ctx.signal aborted and its connection is dropped. The server can be started again afterwards.

Each server is one endpoint, so a host can give every agent session its own loopback URL and token:

const token = randomBytes(32).toString("hex");
const session = await ai.mcp
.serve(sessionTools, { name: "session", transport: { type: "http", auth: { bearer: token } } })
.start();
spawnAgentCli({ mcpUrl: session.url, mcpToken: token });
// on session end:
await session.close();

The transport follows the MCP Streamable HTTP specification and is stateless: each POST carries one JSON-RPC message and a request is answered with application/json; notifications and responses get 202 Accepted; no MCP-Session-Id is issued, so GET (server-initiated stream) and DELETE answer 405. JSON-RPC batches get 400, an unsupported MCP-Protocol-Version header gets 400, a non-JSON Content-Type gets 415. It is built on node:http and needs no extra dependency. Any MCP client works — including the official SDK’s StreamableHTTPClientTransport and this package’s own ai.mcp({ type: "http", url, headers }).

// Bearer token(s) — several tokens allow rotation without downtime
transport: { type: "http", auth: { bearer: ["current-token", "previous-token"] } }
// A custom check — return true to allow
transport: {
type: "http",
auth: { authorize: async (request) => sessions.isValid(request.headers["x-session-id"]) },
}
  • bearer matches Authorization: Bearer <token>. Tokens are compared in constant time over SHA-256 digests with crypto.timingSafeEqual, so neither a token’s content nor its length leaks through timing. Empty tokens are refused at start().
  • authorize(request) receives { method, path, headers, remoteAddress } (lower-cased header names). A hook that throws denies the request.
  • Checks run in order: bearer, authorize, then authenticate. Every configured check must pass; a failed check skips later hooks. authenticate can also be used alone, including on non-loopback binds. Host/Origin checks always run first.
  • By default a failed check answers 401 before the body is read. When bearer or authenticate is configured, refusals include WWW-Authenticate: Bearer realm="mcp". A throwing authentication hook denies without exposing its error.

Use transport.onAuthRefusal to keep an application’s JSON error contract on both the MCP endpoint and transport.routes:

import { ai } from "@warlock.js/ai";
import type { McpHttpAuthRefusal } from "@warlock.js/ai-tools";
import "@warlock.js/ai-tools";
const refuse: McpHttpAuthRefusal = async (context) => ({
status: 401,
headers: { "cache-control": "no-store" },
body: { error: "MEMBER_UNAUTHORIZED", check: context.check },
});
const server = ai.mcp.serve([], {
name: "member-tools",
transport: {
type: "http",
auth: { authorize: () => false },
onAuthRefusal: refuse,
routes: [{ method: "GET", path: "/policy", handler: () => ({ body: {} }) }],
},
});
const { url, close } = await server.start();
// Both url and `${url}/policy` return the custom denial.
await close();

The hook receives only { check, method, path, target }: check is "bearer", "authorize" or "authenticate"; method is uppercase, path excludes the query string, and target is "mcp" or "route". It never receives request headers, body, credentials, principal or thrown authentication errors. False/undefined auth results and thrown/rejected auth checks use the same safe context.

Return { status?, headers?, body }, synchronously or asynchronously. Status defaults to 401 and must be an integer from 400 through 599. The required body accepts plain JSON, including arrays, primitives and null; cycles, non-finite numbers, undefined entries, functions and class instances are invalid. Headers may contain only www-authenticate, cache-control, retry-after, content-language and x-request-id (names are case-insensitive at runtime; the public type uses lowercase). They merge over the default challenge. Header values must be valid HTTP strings. Cookies, redirects, CORS and framing headers cannot be set; content type stays application/json and content length is computed by the transport.

Returning nothing, throwing/rejecting, or returning an invalid status, body or header falls back to the complete original 401 JSON-RPC Unauthorized. response and its default headers. Host/Origin refusals remain unchanged and never call this hook. A custom response always ends the denied exchange: later authentication checks, route handlers, call policy and tools never run. Successful authentication never calls the hook.

Public types: McpHttpAuthRefusal, McpHttpAuthRefusalContext, McpHttpAuthRefusalResponse, McpHttpAuthRefusalBody, McpHttpAuthRefusalHeader.

transport.auth.authenticate(request) resolves the caller. Return any principal value except undefined to allow; return undefined or throw to deny with HTTP 401. Principals stay in server context and are typed as unknown, so narrow them before use. The request argument contains { method, path, headers, remoteAddress }.

import { ai } from "@warlock.js/ai";
import "@warlock.js/ai-tools";
import type { McpHttpRequestInfo, McpCallRefusal } from "@warlock.js/ai-tools";
type Member = { memberId: string; canCall: boolean };
declare function authenticateMember(request: McpHttpRequestInfo): Promise<Member | undefined>;
const denied: McpCallRefusal = {
isError: true,
content: [{ type: "text", text: "This member cannot call tools." }],
_meta: { policy: "membership" },
};
const identity = ai.tool({
name: "identity",
description: "Read the authenticated member and call id",
execute: async (_input: unknown, ctx) => {
ctx?.signal?.throwIfAborted();
return { requestId: ctx?.requestId, principal: ctx?.principal };
},
});
const server = ai.mcp.serve([identity], {
name: "member-tools",
transport: { type: "http", auth: { authenticate: authenticateMember } },
beforeCall: async (context, tool, input) => {
const member = context.principal;
if (
typeof member !== "object" ||
member === null ||
!("canCall" in member) ||
member.canCall !== true
) {
return denied;
}
// context.requestId is the original JSON-RPC tools/call id (string or number).
// tool is the original ToolContract; input is schema-validated, read-only data.
},
});

beforeCall(context, tool, input) runs after successful input validation and before invocation. Return nothing to continue, or return a native McpCallRefusal (isError: true with valid MCP content) to refuse. Refusals, including _meta and structuredContent, are sent unchanged regardless of results mode. A thrown McpError preserves its integer code, message and optional data; other throws answer a JSON-RPC error (-32602); a malformed refusal answers -32603. Invalid input skips the hook and remains a tool error. With a policy hook, the serving boundary validates input before the hook and the contract’s invoke() retains its own validation; schemas must be deterministic and hooks must treat input as read-only.

Both tools and serve hooks receive requestId, principal, signal and, over HTTP, the request metadata. ToolContext.requestId and .principal are optional additions; in-process tool dispatch can omit them. McpServeToolContext, McpBeforeCall, McpCallRefusal and McpStdioServeTransport are exported from @warlock.js/ai-tools.

For a trusted local stdio client, supply a static principal or a provider. The provider runs per request and takes precedence over the static value; its context includes the request id and signal. Provider errors answer JSON-RPC internal errors. ctx.request stays absent.

ai.mcp.serve([identity], {
name: "local-tools",
transport: { type: "stdio", principal: { memberId: "local", canCall: true } },
});
ai.mcp.serve([identity], {
name: "local-tools",
transport: {
type: "stdio",
principalProvider: async (context) => ({ memberId: "local", callId: context.requestId }),
},
});

listFilter(context, tools) optionally filters tools/list on every request over HTTP or stdio. It can be async and receives the same authenticated principal, JSON-RPC request id, request metadata and abort signal as other serve hooks. tools is a fresh array of wire McpToolDescriptors, including inputSchema, outputSchema when configured, and the exposed names after prefixToolNames. Return the descriptors to advertise, in the desired order:

import { ai } from "@warlock.js/ai";
import "@warlock.js/ai-tools";
ai.mcp.serve([ai.tools.calculator(), ai.tools.dateTime()], {
name: "discovery",
transport: { type: "stdio", principal: "local" },
listFilter: async (context, tools) =>
context.principal === "local" ? tools.filter((tool) => tool.name === "calculator") : [],
});

Omitted tools remain callable by their exposed names. listFilter never changes the construction-time call registry or invokes beforeCall; calls to hidden tools still run input validation and the same call policy. An empty list is valid. Without the hook, every tool is listed. A failing filter returns a JSON-RPC error for that list request and leaves the registry intact. Visibility is a discovery choice; enforce permissions in beforeCall.

Inspect call envelopes and choose protocol errors

Section titled “Inspect call envelopes and choose protocol errors”

validateCallParams(context, params) inspects the complete tools/call params object, including name, arguments, _meta, task and arbitrary extension fields. It runs after the basic object/name check, before tool lookup, input schema validation, beforeCall or invocation. It may be async; treat params as read-only. Return nothing to continue or throw to refuse. This hook works over both HTTP and stdio.

import { ai } from "@warlock.js/ai";
import { McpError } from "@warlock.js/ai-tools";
ai.mcp.serve([ai.tools.calculator()], {
name: "interactive-tools",
validateCallParams: (_context, params) => {
const unsupported = Object.keys(params).filter(
(key) => !["name", "arguments", "_meta"].includes(key),
);
if (unsupported.length > 0) {
throw new McpError(-32600, "Unsupported call envelope", { fields: unsupported });
}
},
beforeCall: (context) => {
if (context.principal === "blocked") {
throw new McpError(-32600, "Caller denied");
}
},
});

McpError(code, message, data?) is exported from @warlock.js/ai-tools and extends Error. In beforeCall and validateCallParams, it becomes a JSON-RPC error with exactly those fields. The official SDK’s McpError is also recognized without adding a runtime SDK dependency: an Error named McpError with an integer code opts in. Its existing .message is preserved verbatim, including any SDK-added prefix. Other errors and thrown values retain the -32602 mapping. This does not change tool execution failures, which remain isError: true results.

The server negotiates 2025-11-25, 2025-06-18 and 2025-03-26. It does not advertise task capability. By default it continues to ignore extra call envelope fields, including task, and invokes the tool normally. The MCP 2025-11-25 task support rules require receivers without the corresponding task capability to ignore task augmentation metadata. Task support first appeared in that version. The validation example is an explicit application envelope restriction, not automatic task protocol enforcement; omit or adjust it for clients relying on that default. _meta is protocol metadata, not an unknown field, so the example allows it.

Set transport.routes to serve small application endpoints on the same HTTP server. Routes share all configured bearer, authorize and authenticate checks, in the same order, plus the Host/Origin guards. As with the MCP endpoint, loopback can run without configured auth; non-loopback requires it. Authentication happens before reading the body or calling the handler.

import { randomBytes } from "node:crypto";
import { ai } from "@warlock.js/ai";
import { McpError } from "@warlock.js/ai-tools";
const tools = [ai.tools.calculator(), ai.tools.dateTime()];
const token = randomBytes(32).toString("hex");
const allowed = (principal: unknown, name: string) =>
principal === "member" && tools.some((tool) => tool.name === name);
const { url, close } = await ai.mcp
.serve(tools, {
name: "policy-tools",
listFilter: (_context, descriptors) => descriptors.filter((tool) => tool.name === "calculator"),
beforeCall: (context, tool) => {
if (!allowed(context.principal, tool.name)) throw new McpError(-32600, "Denied");
},
transport: {
type: "http",
path: "/mcp",
auth: {
bearer: token,
authenticate: () => "member", // identity associated with this bearer token
},
routes: [
{
method: "GET",
path: "/tool-policy",
handler: (context) => ({
body: {
allowed: allowed(context.principal, context.url.searchParams.get("name") ?? ""),
},
}),
},
],
},
})
.start();
// GET `${url}/tool-policy?name=date_time` with Authorization: Bearer <token>
// returns { allowed: true }; date_time is callable even though hidden above.
await close();

Paths are exact suffixes beneath the MCP URL: /tool-policy with /mcp or /mcp/ mounts at /mcp/tool-policy; with MCP path /, it mounts at /tool-policy. Each segment must be nonempty and use letters, digits, _, -, . or ~; . and .. segments, percent escapes, queries, fragments, wildcards, repeated slashes and trailing slashes are rejected in configured route paths. Read incoming query parameters from context.url.searchParams; queries do not affect route matching. When routes are configured, the MCP path must be a canonical URL pathname without query/fragment or normalization. The MCP endpoint itself is reserved for all methods: empty and / route suffixes are invalid, so a route cannot replace MCP POST, GET or DELETE behavior. Duplicate method/path pairs fail start() with McpServeError.type: "collision"; invalid routes fail with "config". Different methods may share one route path. There is no wildcard matching, implicit HEAD mapping or automatic OPTIONS/CORS handler.

A known route path with an unregistered method answers 405 with Allow; unknown paths answer 404. Supported methods are GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. MCP protocol-version, Accept and JSON Content-Type requirements apply to the MCP endpoint; side routes receive the UTF-8 body without JSON parsing, capped by the same maxBodyBytes (413 on overflow).

The handler receives McpHttpRouteContext: request (method/path/headers/remoteAddress), principal, url, body and signal; requestId is absent because it is not a JSON-RPC request. The signal aborts on disconnect or shutdown after the drain timeout. Return McpHttpRouteResponse with optional status (default 200), headers and a JSON-serializable body; omitting body sends an empty response, and HEAD always suppresses the body. A thrown handler error returns a generic 500 without exposing the error. In-flight routes participate in close() draining. A route can reuse an application’s policy function, as above; the transport does not invoke beforeCall for route requests. Routes are HTTP-only.

Public types include McpListFilter, McpValidateCallParams, McpHttpRoute, McpHttpRouteContext and McpHttpRouteResponse.

Following the MCP 2025-11-25 cancellation specification, send a notification with the original request id:

{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": { "requestId": 42, "reason": "Caller cancelled" }
}

The matching call’s ctx.signal aborts and its JSON-RPC result or error is suppressed, even if the tool finishes later. Tools should pass the signal to their cancellable work. Unknown, completed or malformed cancellation ids are ignored; initialize cannot be cancelled. Number and string ids stay distinct. Existing calls with duplicate in-flight ids still execute, but cancellation is ignored while the id is ambiguous within one caller scope.

Stdio cancellation applies to that connection. HTTP is stateless: a cancellation POST must repeat the original caller’s credential and application headers (for example Authorization, Cookie, x-tenant-id), from the same remote address. Transport headers such as Content-Length, Accept and MCP-Protocol-Version can differ. Different credentials can reuse the same id without cancelling each other’s calls; clients sharing credentials and application headers must use distinct in-flight ids for cancellation to identify a call. No principal serialization or object identity comparison is required. Notification POSTs get 202; the cancelled call’s HTTP exchange ends with 202 and no JSON-RPC body when its handler settles. Concurrent calls keep separate contexts and controllers.

A client disconnect also aborts its HTTP call. Stdio EOF/close and server stop abort pending stdio work; HTTP shutdown preserves the existing drain timeout before aborting remaining calls.

Over HTTP every tool call receives the inbound request on ctx.request — useful for a per-user token, a tenant id or a trace id the tool forwards downstream. Nothing on ctx.request reaches the model.

const lookup = ai.tool({
name: "lookup_order",
description: "Find an order",
input: v.object({ id: v.string() }),
execute: async ({ id }, ctx) => {
const tenant = ctx?.request?.headers["x-tenant-id"];
return orders.find(tenant, id);
},
});

Tools see every header the client sent, including authorization. Over stdio ctx.request is absent.

Resources and prompts are optional. Configuring either turns on its MCP capability:

ai.mcp.serve(tools, {
name: "handbook",
resources: [
{
uri: "docs://handbook",
name: "handbook",
mimeType: "text/markdown",
read: () => readFile("handbook.md", "utf8"), // string → text, Uint8Array → base64 blob
},
],
prompts: [
{
name: "review",
description: "Review a diff",
arguments: [{ name: "diff", required: true }],
get: ({ diff }) => `Review this diff:\n${diff}`, // a string becomes one user message
},
],
});

resources/list, resources/read (unknown URI → -32002), an empty resources/templates/list, prompts/list and prompts/get (unknown prompt or missing required argument → -32602) are served. read() and get() receive the same { request, signal } context as tools.

createServeHandler exposes the pure protocol core, with no I/O — feed it a JSON-RPC request, get the response. It is the cleanest seam for unit tests, or for mounting MCP inside a server you already run:

import { createServeHandler } from "@warlock.js/ai-tools";
const handler = createServeHandler([calc, dt], { name: "warlock-utils" });
const response = await handler.handle(jsonRpcRequest, {
request: { method: "POST", path: "/mcp", headers }, // optional — reaches tools as ctx.request
});

If you mount it yourself, you own the transport’s security: validate Origin, authenticate, and cap body size.

  • Loopback by default. The HTTP transport binds 127.0.0.1 unless you set host. Binding anything else (0.0.0.0, a LAN address) requires auth; start() throws McpServeError (type: "config") otherwise.
  • DNS-rebinding guards. Every request’s Origin is checked, as the MCP specification requires: loopback origins and allowedOrigins pass, any other origin (including null) gets 403. Requests without an Origin (non-browser clients) pass this check. While bound to loopback the Host header must be localhost, 127.0.0.1 or [::1] (plus allowedHosts), so a rebound domain name is refused even without an Origin.
  • Body limit. Bodies over maxBodyBytes get 413 and the connection is closed.
  • Tokens. Use long random tokens (for example randomBytes(32).toString("hex")), one per session, and keep them out of logs. The transport is plain HTTP: put TLS in front of it (a reverse proxy) before exposing it beyond the machine.

McpServeError covers server construction and start-up:

typeWhen
"collision"two tools, resources or prompts share a name — thrown by serve()
"config"non-loopback bind without auth, empty token, bad path / port, misused prefixToolNames
"listen"the port could not be bound (in use, permission denied)

Bad requests never throw: they get an HTTP status or a JSON-RPC error. Tool-level failures come back as isError: true results, exactly as MCP expects.

The JSON-Schema ↔ Standard-Schema bridge

Section titled “The JSON-Schema ↔ Standard-Schema bridge”

MCP describes a tool’s arguments with a JSON Schema, but Warlock’s ToolContract validates input with a Standard Schema. jsonSchemaToStandard bridges the two and runs at both boundaries:

  • Consuming (A) — each remote tool’s JSON-Schema inputSchema is wrapped into a Standard Schema so the adapted contract validates calls locally. The wrapper also exposes the original schema as Standard JSON Schema (~standard.jsonSchema), so provider adapters advertise the server’s real argument names to the model; a tool without an inputSchema advertises { type: "object", properties: {} }.
  • Publishing (B) — each contract’s Standard Schema is projected to JSON Schema via extractJsonSchema(contract.input, { target: schemaTarget }) for tools/list.
import { jsonSchemaToStandard } from "@warlock.js/ai-tools";
const schema = jsonSchemaToStandard({
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
});

The conversion is Ajv-backed — ajv is an optional peer needed only on the consuming side.

MCP’s wire vocabulary uses kind in some content / capability descriptors. The client translates any inbound kind to type when normalizing content blocks, and the server only ever emits type — so every discriminator the agent (or a consuming client) sees is type.

The MCP client does not use @modelcontextprotocol/sdk: it speaks MCP’s JSON-RPC itself, over a spawned child process (node:child_process) for { type: "stdio" } and the global fetch for { type: "http" }, so neither transport needs a dependency. Its only optional peer is ajv, which validates tool-call input against each MCP tool’s inputSchema. It is imported lazily the first time a schema is checked; when it is missing, the call fails validation with a curated npm install ajv message instead of crashing at import time. A tool with no inputSchema needs no validator.

ai.mcp.serve needs no peer at all: both its stdio and HTTP transports run on Node built-ins (node:readline, node:http, node:crypto).

  • Use AI tools — the built-in web / HTTP / calculator / date-time belt you can consume or expose.
  • Use a workspace — a workspace whose tools.all() is a natural serve source.
  • Run agent — running the agent that consumes the adapted tools.