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 rawToolContract[]) 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 sessionThe transport — stdio or http
Section titled “The transport — stdio or http”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 environment
Section titled “stdio environment”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.
Client options
Section titled “Client options”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});namePrefixis 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.filterreceives each tool’s unprefixed name; returnfalseto drop it.signalbinds 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 latertools()or tool calls fail withMcpTransportErrortype"closed"without sending anything. Everytools/callhonors it together with the per-callctx.signalthe 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 downWhat tools() does
Section titled “What tools() does”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:
- its JSON-Schema
inputSchemais wrapped as a Standard Schema viajsonSchemaToStandard(see below); executeissuestools/callhonoringctx.signal(cooperative abort) and the configuredtimeoutMs, then unwraps the result content (a single JSON text block is parsed back into an object);- an MCP
isErrorresult is thrown so the surroundingtool()wraps it as aToolExecutionError— the agent reads it as{ error }data and self-corrects.
Selecting one discovered tool
Section titled “Selecting one discovered tool”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);}Errors
Section titled “Errors”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 processconst 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 tokenconst { 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 portThe 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.
Tool-name collisions
Section titled “Tool-name collisions”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.
Serve options
Section titled “Serve options”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});schemaTargetdefaults to"draft-2020-12"— a neutral MCP draft, deliberately overridingextractJsonSchema’s own"openai-strict"default. Set it to match a consuming client’s expectation.
How the protocol maps
Section titled “How the protocol maps”initialize→ advertises{ name, version }andcapabilities: { tools: {} }(plusresources/promptswhen 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 asSUPPORTED_PROTOCOL_VERSIONS), otherwise the newest is offered.tools/list→ one descriptor per tool; eachinputSchemais produced byextractJsonSchema(contract.input, { target: schemaTarget }).tools/call→ routes to the named contract’sinvoke()and maps the never-throwing result: by default,databecomes a{ type: "text" }content block (a string is sent verbatim, anything else is JSON-stringified), anderrorbecomes anisError: trueresult. Opted-in native results pass through after validation, as described below. An unknown tool name answers with a JSON-RPC error.ping→{}.
Return native MCP results
Section titled “Return native MCP results”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.
Serving over HTTP
Section titled “Serving over HTTP”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 }).
Authentication
Section titled “Authentication”// Bearer token(s) — several tokens allow rotation without downtimetransport: { type: "http", auth: { bearer: ["current-token", "previous-token"] } }
// A custom check — return true to allowtransport: { type: "http", auth: { authorize: async (request) => sessions.isValid(request.headers["x-session-id"]) },}bearermatchesAuthorization: Bearer <token>. Tokens are compared in constant time over SHA-256 digests withcrypto.timingSafeEqual, so neither a token’s content nor its length leaks through timing. Empty tokens are refused atstart().authorize(request)receives{ method, path, headers, remoteAddress }(lower-cased header names). A hook that throws denies the request.- Checks run in order:
bearer,authorize, thenauthenticate. Every configured check must pass; a failed check skips later hooks.authenticatecan also be used alone, including on non-loopback binds. Host/Origin checks always run first. - By default a failed check answers
401before the body is read. When bearer orauthenticateis configured, refusals includeWWW-Authenticate: Bearer realm="mcp". A throwing authentication hook denies without exposing its error.
Custom authentication refusals
Section titled “Custom authentication refusals”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.
Authenticated context and call policy
Section titled “Authenticated context and call policy”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 }), },});Discovery independent of call policy
Section titled “Discovery independent of call policy”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.
Extra authenticated HTTP routes
Section titled “Extra authenticated HTTP routes”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.
Cancellation
Section titled “Cancellation”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.
Tools see the request headers
Section titled “Tools see the request headers”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
Section titled “Resources and prompts”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.
Driving the protocol yourself
Section titled “Driving the protocol yourself”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.
Security notes
Section titled “Security notes”- Loopback by default. The HTTP transport binds
127.0.0.1unless you sethost. Binding anything else (0.0.0.0, a LAN address) requiresauth;start()throwsMcpServeError(type: "config") otherwise. - DNS-rebinding guards. Every request’s
Originis checked, as the MCP specification requires: loopback origins andallowedOriginspass, any other origin (includingnull) gets403. Requests without anOrigin(non-browser clients) pass this check. While bound to loopback theHostheader must belocalhost,127.0.0.1or[::1](plusallowedHosts), so a rebound domain name is refused even without anOrigin. - Body limit. Bodies over
maxBodyBytesget413and 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.
Serve errors
Section titled “Serve errors”McpServeError covers server construction and start-up:
type | When |
|---|---|
"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
inputSchemais 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 aninputSchemaadvertises{ type: "object", properties: {} }. - Publishing (B) — each contract’s Standard Schema is projected to JSON Schema via
extractJsonSchema(contract.input, { target: schemaTarget })fortools/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.
type, never kind
Section titled “type, never kind”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.
Optional peers
Section titled “Optional peers”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).
See also
Section titled “See also”- 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 naturalservesource. - Run agent — running the agent that consumes the adapted tools.