agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
663 lines • 35.2 kB
JavaScript
;
/**
* mcpServe — expose your agentfootprint tools AS an MCP server.
*
* const handle = await mcpServe([lookupTool, refundTool], {
* name: 'support-desk',
* version: '1.0.0',
* });
*
* // …later
* await handle.close();
*
* Over HTTP the handle also reports the socket it bound, so `port: 0`
* ("any free port") is usable from the outside:
*
* const handle = await mcpServe(tools, {
* transport: { transport: 'http', port: 0, host: '127.0.0.1' },
* });
* connect(`http://127.0.0.1:${handle.port}/mcp`);
*
* `mcpClient` pulls someone else's tools in. This pushes yours out, so
* the tool you already wrote, tested and governed can be called by any
* MCP client — a desktop host, another team's agent, an IDE — without
* being rewritten against a second tool interface.
*
* Pattern: Adapter (GoF), pointed the other way. `mcpClient` translates
* MCP → `Tool`; this translates `Tool` → MCP. Same SDK, same
* lazy-require discipline, mirrored lifecycle (`close()`).
*
* ── The one promise worth stating plainly ────────────────────────────
* A served tool is THE SAME OBJECT you passed in. `mcpServe` holds your
* `Tool` by reference and calls `tool.execute(args, ctx)` — it never
* copies the schema and re-implements the body, never unwraps a
* decorator, never reaches past a wrapper to an inner tool. So whatever
* governance you composed around that tool — a permission check inside
* `execute`, a redaction step, an audit hook — is still the thing that
* runs when a remote client calls it. Serving is not a second door into
* your tool; it is the same door with a longer corridor.
*
* Two demands the library CANNOT keep over MCP are refused at
* construction rather than silently dropped:
* - `checkIn` (human consent before the tool runs) needs a pause
* channel that a request/response protocol does not have.
* - `needs` (declare-and-push credentials) needs a provider; without
* one the tool would run with `ctx.credential` undefined.
*
* ── Declarations travel; execution does not (9.71.0) ─────────────────
* A served tool's DECLARATIONS — `argumentsFrom`, `resultKind`, `owner`,
* `resultClass`, `resultCeiling` — are written into MCP's own `_meta` bag
* under one namespaced key, so an agentfootprint client arms the checks and
* rails that read them exactly as it would for a local `defineTool`. The
* execution-side fields never go: see `toolExtras.ts` for the bar and the
* reasoning. A tool declaring none of the five lists exactly as before.
*
* ── Schemas ──────────────────────────────────────────────────────────
* Tools already carry JSON Schema in `schema.inputSchema`, and MCP's
* `tools/list` wants JSON Schema, so the mapping is the identity
* function. That is also why this builds on the SDK's low-level
* `Server` rather than its `McpServer` convenience wrapper — the latter
* takes zod schemas, which would mean converting a JSON Schema you
* already have into zod (a new dependency) to convert it straight back.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CALL_TOOL_SENTINEL = exports.LIST_TOOLS_SENTINEL = exports.mcpServe = void 0;
const toolSessions_js_1 = require("../tools/toolSessions.js");
const types_js_1 = require("../identity/types.js");
const requestContext_js_1 = require("../identity/requestContext.js");
const capability_js_1 = require("../artifacts/capability.js");
const wants_js_1 = require("../artifacts/wants.js");
const doorGuard_js_1 = require("../hosting/doorGuard.js");
const lazyRequire_js_1 = require("../lib/lazyRequire.js");
const sdkLoadFailure_js_1 = require("./sdkLoadFailure.js");
const toolExtras_js_1 = require("./toolExtras.js");
const runChain_js_1 = require("../tools/runChain.js");
const toolArgsValidation_js_1 = require("../tools/toolArgsValidation.js");
const DEFAULT_SERVER_NAME = 'agentfootprint';
const DEFAULT_SERVER_VERSION = '0.0.0';
const DEFAULT_HTTP_PATH = '/mcp';
/**
* Serve `tools` over MCP. Resolves once the transport is connected (for
* `http`, once the socket is listening), so holding the handle means the
* server is live.
*
* @throws at construction when `tools` is empty, when two tools share a
* name, when a tool declares `checkIn`, or when a tool declares `needs`
* without `opts.credentials` — see the module header. Also throws when
* `@modelcontextprotocol/sdk` is not installed.
*/
async function mcpServe(tools, opts = {}) {
const name = opts.name ?? DEFAULT_SERVER_NAME;
const version = opts.version ?? DEFAULT_SERVER_VERSION;
const byName = validateServableTools(tools, name, opts.credentials);
// A served call belongs to no agent run, so it carries no incoming request:
// whatever `request` a tool presents is removed (`bindRequest`), and a
// request-sourced credential fails closed here by name.
const credentials = (0, requestContext_js_1.bindRequest)(opts.credentials ?? (0, types_js_1.unconfiguredCredentialProvider)(), undefined, undefined);
const listing = tools.map((t) => {
// The declaration half of the tool, in MCP's own extensibility bag
// (9.71.0). Absent fields stay absent and a tool that declares NOTHING
// gets no `_meta` key at all, so a server whose tools carry no
// declarations lists exactly the bytes it always did. See toolExtras.ts
// for the inclusion bar and for why `needs` / `checkIn` never travel.
const extras = (0, toolExtras_js_1.toolExtrasOf)(t);
return {
name: t.schema.name,
...(t.schema.description !== undefined && { description: t.schema.description }),
inputSchema: t.schema.inputSchema ?? { type: 'object', properties: {} },
...(extras !== undefined && { _meta: { [toolExtras_js_1.MCP_TOOL_EXTRAS_KEY]: extras } }),
};
});
// Order matters: the server is resolved FIRST so that a missing SDK is
// reported by the message that tells you how to install it, rather than
// by the narrower one about a single submodule.
const rootServer = opts._server ?? (await resolveServer(name, version));
const { listSchema, callSchema } = await resolveRequestSchemas(opts._server !== undefined);
let callCounter = 0;
const handleCall = async (request, extra) => {
// Everything below this line is inside one try/catch on purpose. A
// client we do not control chose this request; a bad choice is a tool
// error reported back over the protocol, never an exception that
// escapes the SDK's dispatch loop and takes the server down with it.
try {
const toolName = request?.params?.name;
const tool = typeof toolName === 'string' ? byName.get(toolName) : undefined;
if (!tool) {
return toolError(`Unknown tool '${String(toolName)}'. Served tools: ${listing
.map((t) => t.name)
.join(', ')}.`);
}
const toolCallId = `mcp-${name}-${++callCounter}`;
// Keep the original value until middleware explicitly replaces it.
// Admission below consumes the shared schema rule without coercing,
// stripping keys, or replacing an accepted argument object.
let args = request?.params?.arguments;
// The governance chain, when one was passed. Same walker the Agent's
// dispatch loop uses, so the ordering rule, the transform rule and the
// throw-is-a-denial rule mean here exactly what they mean there — and,
// as there, it runs BEFORE credentials are resolved: a call the chain is
// about to refuse should never have acquired a secret first.
//
// There is no history and no iteration at this boundary — a served call
// is one call, not a turn in a conversation — so a middleware that needs
// either belongs on an agent, not here.
if (opts.toolMiddleware && opts.toolMiddleware.length > 0) {
const proposed = (args ?? {});
const verdict = await (0, runChain_js_1.runToolChain)(opts.toolMiddleware, {
toolName: tool.schema.name,
// Same context type as the Agent's chain, so the same field means the
// same thing here: present when the tool we are serving came from
// somewhere else (re-serving another MCP server's tool), absent when
// it is ours. It is the SERVED tool's provenance, never the calling
// client's — a client does not get to say where a tool came from.
...(tool.source !== undefined && { toolSource: tool.source }),
toolCallId,
iteration: 0,
args: proposed,
history: [],
...(extra?.signal && { signal: extra.signal }),
// No pause exists here. An `ask` becomes a named refusal rather than
// an ungoverned execution.
askPolicy: 'refuse',
});
if (verdict.kind === 'deny')
return toolError(verdict.reason);
// Replace only when the chain actually produced something different.
// A served call nobody transformed reaches `execute` as the exact value
// the client sent — including `undefined`, which is not `{}`.
if (verdict.args !== proposed)
args = verdict.args;
}
// A tool that declares artifact arguments (`wants`, 9.22.0) cannot be
// served here: this door has no artifact store, so its refs could never
// resolve — and running it with the raw ref STRING where it expects
// resolved data would be accepted-and-silently-wrong. Refused by name,
// before credentials, like every other thing this door cannot honour.
if (tool.wants !== undefined) {
return toolError((0, wants_js_1.wantsNeedsStoreRefusal)(tool.schema.name, tool.wants));
}
// Same schema owner as Agent dispatch, over the actual middleware
// output. Invalid calls acquire no credentials and never reach the
// handler or its after-tool hooks. A served call has no Agent dial:
// its declared supported schema constraints are always enforced.
const verdict = (0, toolArgsValidation_js_1.validateToolArgs)(args, tool.schema.inputSchema);
if (!verdict.ok)
return toolError((0, toolArgsValidation_js_1.formatToolArgIssues)(tool.schema.name, verdict.issues));
const ctx = await buildExecutionContext(tool, toolCallId, {
credentials,
hasCredentials: opts.credentials !== undefined,
...(extra?.signal && { signal: extra.signal }),
});
if ('blocked' in ctx)
return toolError(ctx.blocked);
// The call's time, when the client sent one (`_meta.agentfootprint.time`, the
// time layer) — read, never repaired; one this runtime cannot read is not handed on.
const time = (0, toolExtras_js_1.callTimeOf)(request?.params?._meta);
const context = time !== undefined ? { ...ctx.context, time } : ctx.context;
// `'call'` is the ONLY scope this door can honour, and it honours it for
// real: the cleanups a served call registers run when that call settles,
// resolve or throw. There is no run and no session out here to hang a
// longer-lived one on — `buildExecutionContext` says so in
// `teardownScopes`, and asking for anything else is refused by name
// rather than accepted and quietly never fired.
let result;
try {
result = await tool.execute(args, context);
}
finally {
await ctx.endCall();
}
// The after-tool moment, on the same shared walker and therefore under the
// same laws: backwards through the chain, a transform declares itself, a
// throwing link is a refusal. It runs only here — after a tool that
// really executed — for the same reason it does inside an agent.
//
// There is no ledger at this boundary (no scope, no run), so the rows
// the walker produces have nowhere to be committed; what the CLIENT sees
// is the whole record here, which is why a refusal answers as a named
// tool error rather than quietly swapping the payload.
if (opts.toolMiddleware && opts.toolMiddleware.length > 0) {
const verdict = await (0, runChain_js_1.runToolAfterChain)(opts.toolMiddleware, {
toolName: tool.schema.name,
...(tool.source !== undefined && { toolSource: tool.source }),
toolCallId,
iteration: 0,
args: (args ?? {}),
result,
history: [],
...(extra?.signal && { signal: extra.signal }),
});
if (verdict.kind === 'deny')
return toolError(verdict.reason);
return { content: [{ type: 'text', text: stringifyResult(verdict.result) }] };
}
return { content: [{ type: 'text', text: stringifyResult(result) }] };
}
catch (error) {
return toolError(error instanceof Error ? error.message : String(error));
}
};
/**
* Put our two handlers on one SDK server. Factored out of the body
* because the streamable-HTTP path cannot reuse a single server: the
* SDK's stateless transport refuses a second request ("Stateless
* transport cannot be reused across requests"), so that path builds a
* fresh server + transport per request and needs to install the same
* two handlers on each one. `listing` and `byName` are captured, so
* every server instance serves the same tools — the same objects.
*/
const install = (server) => {
server.setRequestHandler(listSchema, () => ({ tools: listing }));
server.setRequestHandler(callSchema, handleCall);
return server;
};
const sdk = install(rootServer);
const connection = await connectTransport(sdk, opts.transport ?? { transport: 'stdio' }, opts, async () => install(await resolveServer(name, version)));
let closed = false;
return {
name,
toolNames: listing.map((t) => t.name),
// Present only when a socket was bound (the http transport). Spread so
// a stdio handle has no `port` key at all, rather than one holding
// undefined — "there is no socket" and "the socket is unknown" are
// different claims.
...(connection.bound !== undefined && {
port: connection.bound.port,
address: connection.bound.address,
}),
async close() {
if (closed)
return;
closed = true;
await connection.close();
await sdk.close();
},
};
}
exports.mcpServe = mcpServe;
// ─── Construction-time refusals ────────────────────────────────────
/**
* Build the dispatch map and refuse, by name, every tool this transport
* cannot honour. All of these are author mistakes, and all of them are
* invisible at run time if we let them through — a duplicate name means
* one tool silently shadows another; a dropped `checkIn` means a
* consent gate that quietly stopped gating.
*/
function validateServableTools(tools, serverName, credentials) {
if (tools.length === 0) {
throw new Error(`mcpServe('${serverName}'): no tools to serve. Pass at least one Tool — a server ` +
`advertising an empty tool list is indistinguishable from a broken one.`);
}
const byName = new Map();
for (const tool of tools) {
const name = tool.schema.name;
if (byName.has(name)) {
throw new Error(`mcpServe('${serverName}'): duplicate tool name '${name}'. MCP clients dispatch by ` +
`name, so one of the two could never be reached.`);
}
if (tool.checkIn !== undefined) {
throw new Error(`mcpServe('${serverName}'): tool '${name}' declares checkIn, which asks a human to ` +
`approve the call before it runs. MCP is request/response — there is no pause to ` +
`carry that ask — so serving it would drop the consent gate silently. Serve a ` +
`variant without checkIn, or keep this tool inside an agent that can pause.`);
}
if (tool.needs !== undefined && credentials === undefined) {
throw new Error(`mcpServe('${serverName}'): tool '${name}' declares needs.credential ` +
`'${tool.needs.credential}' but no credential provider was passed. Pass ` +
`mcpServe(tools, { credentials }) so the credential is resolved before execute, ` +
`the same way the Agent resolves it.`);
}
byName.set(name, tool);
}
return byName;
}
// ─── Per-call execution context ────────────────────────────────────
/**
* Assemble the `ToolExecutionContext` for one served call, resolving a
* declared credential first. Returns `{ blocked }` instead of throwing
* when the credential is unavailable, so the client is told WHY in a
* tool-error result rather than seeing a generic failure.
*/
async function buildExecutionContext(tool, toolCallId, wiring) {
const { credentials, hasCredentials, signal } = wiring;
let credential;
const need = tool.needs;
if (need) {
const resolved = await credentials.getCredential({
service: need.credential,
...(need.scopes && { scopes: need.scopes }),
...(need.mode && { mode: need.mode }),
});
// Fail-closed, mirroring the Agent's tool-call stage: a tool that
// asked for a credential never runs without one.
//
// The consent URL is NOT returned (8.6.0). Out here there is no run to
// pause and no caller to hand a `PendingAsk` — the only channel is the MCP
// tool result, which lands in some other agent's transcript, across a
// process boundary this library has no record of and no reach into. That
// is the widest possible audience for a bearer capability carrying a
// session-correlating `state`, so the client is told WHAT is missing and
// WHO can fix it, and the URL stays with the operator of this server.
if (resolved.status !== 'issued') {
return {
blocked: `authorization required for '${need.credential}': a person must consent ` +
`before this tool can run. The consent link is not returned over MCP — ` +
`ask the operator of this server to authorize '${need.credential}'.`,
};
}
credential = resolved.credential;
}
// One tier per served call. It exists only if the tool registers something;
// an ordinary tool costs one object allocation and no firing.
const sessions = new toolSessions_js_1.ToolSessionTier();
return {
context: {
toolCallId,
// There is no ReAct loop out here — a served call is a single
// request, so the iteration a tool might branch on is zero.
iteration: 0,
credentials,
hasCredentials,
// No artifact store out here (9.21.0): a served call has no run to
// scope refs to, so the capability is the fail-closed teacher — a tool
// that needs one branches on `ctx.hasArtifacts`, exactly as it would
// for credentials. No sink: there is no run record to land facts on.
artifacts: (0, capability_js_1.unconfiguredArtifacts)(),
hasArtifacts: false,
// A mid-call progress report has nowhere to go out here (9.52.0): a
// served call is one request/response over MCP, with no run, no event
// dispatcher and no recording to file on. So the capability is the
// NO-OP rather than a throw — unlike credentials and artifacts, whose
// absence changes what a tool may safely do, a dropped progress report
// changes nothing about the work or the result. The tool stays portable:
// the same handler runs inside an Agent, where the reports land.
progress: () => {
/* nowhere to file it here — see the note above */
},
...(signal && { signal }),
...(credential && { credential }),
// No `runId`, no `sessionId`, no `identity` — and that ABSENCE is the
// fact (9.7.0). A served call is one call, not a turn in a conversation;
// minting a run id here so the field could be non-optional would tell a
// tool it is part of a run that does not exist, and a session-scoped
// sandbox keyed on it would be shared by every client that reached this
// server.
teardownScopes: MCP_TEARDOWN_SCOPES,
onTeardown: (cleanup, options) => {
const scope = options?.scope ?? 'run';
if (scope !== 'call') {
throw new Error(`tool '${tool.schema.name}': onTeardown scope '${scope}' is not honoured over ` +
'mcpServe. A served call is one call — there is no run and no session here to ' +
"end. Read `ctx.teardownScopes` (it says ['call']) and use scope: 'call', or " +
'run this tool inside an Agent.');
}
sessions.register({ tool: tool.schema.name, toolCallId }, cleanup, options);
},
},
endCall: () => sessions.fireCall(toolCallId),
};
}
/** The one scope a served call can honour. See `buildExecutionContext`. */
const MCP_TEARDOWN_SCOPES = ['call'];
// ─── Result mapping ────────────────────────────────────────────────
/** MCP carries text blocks; a Tool returns anything JSON-serializable. */
function stringifyResult(result) {
if (typeof result === 'string')
return result;
if (result === undefined)
return '';
try {
return JSON.stringify(result) ?? String(result);
}
catch {
// Circular or otherwise unserializable — say so rather than throwing
// from the happy path.
return String(result);
}
}
/** A tool-level error: reported IN the result, per the MCP spec, not thrown. */
function toolError(message) {
return { content: [{ type: 'text', text: message }], isError: true };
}
// ─── SDK construction (lazy require) ───────────────────────────────
/**
* What to say when the LOADER, not the package, is the thing that is missing.
*
* `mcpServe` has no browser-shaped answer to offer: it listens on stdio or on a
* Node HTTP socket, and neither exists in a browser. So the honest sentence
* names the runtime instead of inventing a seam — the point of the classifier
* is to stop telling people to install a package they already have, not to
* promise this direction is portable.
*/
const SERVE_IS_A_NODE_ROLE = 'mcpServe listens on stdio or on a Node HTTP socket, so this direction of MCP ' +
'cannot run in a browser at all.';
async function resolveServer(name, version) {
let mod;
try {
mod = (0, lazyRequire_js_1.lazyRequire)('@modelcontextprotocol/sdk/server/index.js');
}
catch (err) {
throw new Error((0, sdkLoadFailure_js_1.sdkLoadFailure)(err, {
notInstalled: 'mcpServe requires @modelcontextprotocol/sdk.\n' +
' Install: npm install @modelcontextprotocol/sdk\n' +
' Or pass `_server` for test injection.',
caller: 'mcpServe',
specifier: '@modelcontextprotocol/sdk/server/index.js',
instead: `${SERVE_IS_A_NODE_ROLE} Pass \`_server\` to serve a server you built yourself.`,
}));
}
return new mod.Server({ name, version }, { capabilities: { tools: {} } });
}
/**
* The request schemas the SDK dispatches on. With an injected `_server`
* there is no SDK to read them from, so tests get stable sentinel
* objects — the injected server decides what to do with them.
*/
async function resolveRequestSchemas(injected) {
if (injected) {
return { listSchema: exports.LIST_TOOLS_SENTINEL, callSchema: exports.CALL_TOOL_SENTINEL };
}
let types;
try {
types = (0, lazyRequire_js_1.lazyRequire)('@modelcontextprotocol/sdk/types.js');
}
catch (err) {
throw new Error((0, sdkLoadFailure_js_1.sdkLoadFailure)(err, {
notInstalled: 'mcpServe requires @modelcontextprotocol/sdk/types.js — check that ' +
'@modelcontextprotocol/sdk is installed at the latest version.',
caller: 'mcpServe',
specifier: '@modelcontextprotocol/sdk/types.js',
instead: SERVE_IS_A_NODE_ROLE,
}));
}
return { listSchema: types.ListToolsRequestSchema, callSchema: types.CallToolRequestSchema };
}
/**
* Sentinels handed to an injected `_server` in place of the SDK's zod
* schemas. Exported so a test can tell the two handlers apart.
* @internal
*/
exports.LIST_TOOLS_SENTINEL = Object.freeze({ method: 'tools/list' });
/** @internal @see LIST_TOOLS_SENTINEL */
exports.CALL_TOOL_SENTINEL = Object.freeze({ method: 'tools/call' });
/**
* For stdio and for an injected test server there is nothing extra to
* unwind: the SDK's own `server.close()` closes the transport it is
* connected to. Only the HTTP path owns a socket of its own.
*/
const NOTHING_TO_UNWIND = { close: () => Promise.resolve() };
async function connectTransport(sdk, transport, opts, newServer) {
// An injected server is a test double: there is no real transport to
// build, and building one would spawn a listener or seize stdio.
if (opts._server) {
await sdk.connect(undefined);
return NOTHING_TO_UNWIND;
}
if (transport.transport === 'stdio') {
let stdioMod;
try {
stdioMod = (0, lazyRequire_js_1.lazyRequire)('@modelcontextprotocol/sdk/server/stdio.js');
}
catch (err) {
throw new Error((0, sdkLoadFailure_js_1.sdkLoadFailure)(err, {
notInstalled: 'mcpServe(stdio) requires @modelcontextprotocol/sdk/server/stdio.js — ' +
'check that @modelcontextprotocol/sdk is installed at the latest version.',
caller: 'mcpServe(stdio)',
specifier: '@modelcontextprotocol/sdk/server/stdio.js',
instead: SERVE_IS_A_NODE_ROLE,
}));
}
await sdk.connect(new stdioMod.StdioServerTransport());
return NOTHING_TO_UNWIND;
}
// HTTP deliberately does not use `sdk`: it mints a server per request
// (see connectHttp). The root server stays unconnected, which makes the
// handle's `sdk.close()` a harmless no-op on this path.
return connectHttp(transport, newServer, opts.name ?? DEFAULT_SERVER_NAME);
}
/**
* Streamable HTTP. The SDK's transport handles the protocol but does not
* own a socket — it expects to be driven from a request handler — so the
* listener is ours, built on node:http (built in; no new dependency).
*
* Stateless (`sessionIdGenerator: undefined`): no session table to keep,
* nothing to expire, and several replicas can serve the same tools.
*
* Statelessness is not just a config flag here — it decides the shape of
* this function. The SDK's stateless transport is single-use by design
* (reusing one would let two clients collide on JSON-RPC message ids), so
* it throws on its second request. Each request therefore gets its own
* transport, and — because one SDK server can only be attached to one
* transport — its own server. Both are thrown away when the response
* closes. The tools they serve are the same objects either way.
*/
async function connectHttp(transport, newServer, serverName) {
// The hosting door guard, built before the SDK is even loaded so a
// configuration it could only misread is refused by name first. All three
// rules are the LIBRARY's, the JSON content type included: the SDK is a peer
// at any version, and an older one matched `application/json` as a
// substring, so leaving that rule to it would make its owner a version this
// package does not pin. (The SDK's own 415 is then simply unreachable.)
//
// A loopback bind with `allowedHosts` unset answers the loopback names only
// — exactly what the SDK's own `createMcpExpressApp` does for a localhost
// bind, and what the transport spec's "MUST validate … to prevent DNS
// rebinding" needs on the machine that is its classic victim.
const doorName = `mcpServe('${serverName}')`;
const guard = (0, doorGuard_js_1.doorGuard)({
name: doorName,
...(transport.host !== undefined && { bindHost: transport.host }),
...(transport.allowedOrigins !== undefined && { allowedOrigins: transport.allowedOrigins }),
...(transport.allowedHosts !== undefined && { allowedHosts: transport.allowedHosts }),
});
let httpMod;
try {
httpMod = (0, lazyRequire_js_1.lazyRequire)('@modelcontextprotocol/sdk/server/streamableHttp.js');
}
catch (err) {
throw new Error((0, sdkLoadFailure_js_1.sdkLoadFailure)(err, {
notInstalled: 'mcpServe(http) requires @modelcontextprotocol/sdk/server/streamableHttp.js — ' +
'check that @modelcontextprotocol/sdk is installed at the latest version.',
caller: 'mcpServe(http)',
specifier: '@modelcontextprotocol/sdk/server/streamableHttp.js',
instead: SERVE_IS_A_NODE_ROLE,
}));
}
const { createServer } = await Promise.resolve().then(() => __importStar(require('node:http')));
const serveOne = async (req, res) => {
const server = await newServer();
const mcpTransport = new httpMod.StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
// The pair lives exactly as long as the response does.
res.on('close', () => {
void mcpTransport.close();
void server.close();
});
await server.connect(mcpTransport);
await mcpTransport.handleRequest(req, res);
};
const path = transport.path ?? DEFAULT_HTTP_PATH;
const listener = createServer((req, res) => {
// Route on the path only; the transport itself decides which HTTP
// methods it accepts on the MCP endpoint.
const url = req.url ?? '';
if (url.split('?')[0] !== path) {
res.statusCode = 404;
res.end();
return;
}
// Before any server or transport exists for this request: a page on
// another site, or one that re-pointed its own name here, never reaches
// a tool. Answered in the SDK's own JSON-RPC error shape, so an MCP
// client reads it the way it reads the transport's other refusals.
const refusal = guard.check(req);
if (refusal !== undefined) {
res.writeHead(refusal.status, { 'content-type': 'application/json', connection: 'close' });
res.end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message: refusal.message, data: { code: refusal.code } },
id: null,
}));
return;
}
// One failed request must not take the listener down. The client is
// told (500 is a protocol-level error it surfaces); the next request
// is served normally.
void serveOne(req, res).catch(() => {
if (!res.headersSent) {
res.statusCode = 500;
res.end();
}
});
});
await new Promise((resolve, reject) => {
listener.once('error', reject);
if (transport.host !== undefined)
listener.listen(transport.port, transport.host, resolve);
else
listener.listen(transport.port, resolve);
});
// The staged default, said once: with no `allowedHosts` this endpoint
// answers any name, which is exactly what DNS rebinding needs.
if (guard.allowedHostsUnset)
(0, doorGuard_js_1.warnAllowedHostsUnset)(doorName);
// Read the socket AFTER listen resolves — before that, `port: 0` is still
// 0 and the address is null. A pipe/socket-path bind reports a string
// instead of an AddressInfo; there is no port to report then, so we report
// none rather than a made-up one.
const bound = listener.address();
return {
...(bound !== null &&
typeof bound === 'object' && {
bound: { port: bound.port, address: bound.address },
}),
close: () => new Promise((resolve, reject) => {
listener.close((err) => (err ? reject(err) : resolve()));
}),
};
}
//# sourceMappingURL=mcpServe.js.map