Build

Serve your tools over MCP

mcpServe(tools, opts) exposes agentfootprint Tool[] AS an MCP server — stdio or Streamable HTTP. Schemas map 1:1, the served tool is the same object you passed in, so the governance you wrapped around it still runs.

Someone else wants to call your tools. Another team's agent, a desktop MCP host, an IDE. You already wrote the tool, tested it and put a permission check around it — and the only thing standing between them and it is a protocol.

mcpClient pulls someone else's tools in. mcpServe pushes yours out.

The one call

import { mcpServe } from 'agentfootprint/providers';

const handle = await mcpServe([lookupOrder, refundOrder], {
  name: 'support-desk',
  version: '1.0.0',
});

process.on('SIGINT', () => void handle.close());

That is a live MCP server. handle.toolNames lists what it advertises; handle.close() stops it, and is idempotent, so a shutdown hook and an explicit close can coexist.

const sdk = fakeSdkServer();const handle = await mcpServe([lookupOrder, refundOrder], {  name: 'support-desk',  version: '1.0.0',  _server: sdk, // ← test injection; remove for a real stdio/http server});

Serving is the same door, not a second one

This is the promise the whole feature rests on: a served tool is the 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 what runs when a remote client calls it:

const guarded = (inner: Tool): Tool => ({
  ...inner,
  execute: (args, ctx) =>
    policy.isAllowed(inner.schema.name)
      ? inner.execute(args, ctx)
      : `denied: '${inner.schema.name}' is not permitted for this role`,
});

await mcpServe([guarded(lookupOrder), guarded(refundOrder)], { name: 'support-desk' });
// tools/call refund_order → "denied: 'refund_order' is not permitted for this role"

Serving is not a back door into your tool. It is the same door with a longer corridor.

What it refuses, and why

Two Tool capabilities cannot be honoured over a request/response protocol. Both are refused at construction rather than silently dropped, because a governance gate that quietly stopped gating is the worst possible outcome:

DeclaredRefused because
checkInIt asks a human to approve the call before it runs. MCP has no pause to carry that ask. Serve a variant without it, or keep the tool inside an agent that can pause.
needs without credentialsThe tool would run with ctx.credential undefined. Pass mcpServe(tools, { credentials }) and the credential is resolved before execute, exactly as the Agent resolves it — fail-closed, so a tool that asked for a credential never runs without one.

Duplicate tool names and an empty tool list are refused too: MCP clients dispatch by name, so one of two same-named tools could never be reached, and a server advertising nothing is indistinguishable from a broken one.

A hostile client is the tool's problem, never the server's

The request came from someone you do not control. So every tools/call is answered, including the bad ones:

  • an unknown tool name → a tool error naming what is served;
  • arguments that are null, a number, an array, or nonsense → forwarded to the tool verbatim, because a second, weaker copy of the tool's own contract is not validation;
  • a tool that throws or rejects → its message comes back as isError: true.

None of these is an exception escaping the dispatch loop, and the server answers the next call normally. Validating args here would only duplicate what the tool already does, and worse, do it less well.

Schemas map 1:1

Tools already carry JSON Schema in schema.inputSchema, and MCP's tools/list wants JSON Schema, so the mapping is the identity function — the same object, passed straight through.

That is also why this builds on the SDK's low-level Server rather than its McpServer convenience wrapper: McpServer.registerTool takes zod schemas, which would mean converting a JSON Schema you already have into zod (a new dependency) to convert it straight back.

Transports

// stdio (default) — how a desktop MCP host launches a server: it spawns your
// process and talks down the pipe. stdout belongs to the protocol, so log to stderr.
await mcpServe(tools, { transport: { transport: 'stdio' } });

// Streamable HTTP — stateless, so several replicas can serve the same tools.
await mcpServe(tools, { transport: { transport: 'http', port: 8931, path: '/mcp' } });

McpServeTransport is the union of McpStdioServeTransport and McpHttpServeTransport; McpServeOptions carries name, version, transport, and credentials; McpServeHandle is what you get back. McpSdkServer is the narrow structural type of the SDK server we touch — exported for the same reason McpSdkClient is, so a test can inject one.

@modelcontextprotocol/sdk is a lazy peer dependency, exactly as it is for mcpClient: the require() only fires when you actually call mcpServe, so apps that never serve pay nothing. Missing, you get an install hint rather than a stack trace.

The MCP endpoint is not a container contract — settled by a test

The streamable-HTTP transport serves MCP, statelessly, on the path and port you choose, and nothing else: it answers neither of the routes the managed runtime contract documented in AgentCore adapters uses, so GET /ping and POST /invocations are 404s from it. That is pinned in test/lib/mcp/mcpServe.real.test.ts against the SDK's own client rather than left to prose. The two are different protocols: serve the runtime contract with agentCoreRuntimeHost and MCP with mcpServe. They cannot share a port today — mcpServe's HTTP transport always owns its listener, and it has no { server } option the way the hosting adapters do.

When to serve, and when not to

Serve over MCP when…Keep it in the agent when…
Another process needs to call your toolOnly your agent calls it
A desktop MCP host should see your capabilitiesThe tool needs a human check-in
You want one definition consumed two waysThe tool depends on ReAct loop state

Next steps

On this page