Skip to main content

Expose this deployment over MCP

@forge/agentkit/mcp-server makes a deployment's tools available to any MCP client — Claude Code, Claude Desktop, Cursor. It is the inbound direction; @forge/agentkit/mcp is the outbound one, where a tenant registers their MCP server and this platform consumes it.

Nothing here re-implements a capability. Every call goes through registry.execute exactly as an agent's would, so authorization, the tenant's toolset, the approval gate, argument validation, idempotency and audit attribution all apply unchanged.

Tools

There are no tools of its own. What a client sees is the authenticated caller's authorized tools, with MCP annotations derived from each tool's ToolEffect by one function, so the advertisement cannot drift from what is enforced:

ToolEffectreadOnlyHintdestructiveHintopenWorldHintMeaning to a client
readSafe to call and to repeat
internal-writeChanges state inside this deployment
external-writeReaches something outside this deployment
destructiveAsk first

read is the only effect that claims readOnlyHint, deliberately. Over-claiming read-only is the dangerous direction, because a client may skip a confirmation on that basis.

Wire it up

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { registerForgeTools } from "@forge/agentkit/mcp-server";

import type { ExecutionContext } from "@forge/agentkit";
import type { ToolRegistry } from "@forge/agentkit/tools";

export const mount = (registry: ToolRegistry, context: ExecutionContext) => {
const server = new Server({ name: "forge", version: "0.2.0" }, { capabilities: { tools: {} } });
registerForgeTools(
server,
{ listTools: ListToolsRequestSchema, callTool: CallToolRequestSchema },
{ registry, context },
);
return server.connect(new StdioServerTransport());
};

A runnable one is in the repository: examples/scripts/mcp-server.mjs, plus mcp-verify.mjs, which drives it with a real MCP client.

claude mcp add forge -- node /abs/path/examples/scripts/mcp-server.mjs

Credentials and scopes

@modelcontextprotocol/sdk is an optional peer dependency. Install it only if you mount a server.

There is no authenticate callback and no default. registerForgeTools takes a resolved ExecutionContext, so a host that has not authenticated has nothing to pass and cannot construct a server. That is the same decision the API host makes — "a permissive default would serve an open API to anyone who forgot to set it" — expressed in the type rather than in a runtime check, because this surface gets exposed to the internet and a runtime check can be skipped.

One server per authenticated session:

  • stdio — the client launches the server as a subprocess, so there is no request to authenticate: the process boundary is the session, and the host builds the context once from its own configuration.
  • HTTP — authenticate per request and build a context per request, as the GraphQL host does.

What a client may reach is decided entirely by that context: its roles drive listAuthorized, and the tenant's toolset and the agent's toolPolicy.excluded apply before authorization. A tool the caller may not use is absent, not present-and-refused.

Behaviour worth knowing

The trust direction inverts. Outbound, a remote server's hints are advisory and untrusted — "a remote server cannot talk its way down to" a weaker effect. Inbound, this package is the remote server, so the mirror obligations hold: advertise only what is enforced, and trust nothing the client sends. A client that includes annotations: { readOnlyHint: true } or approved: true in its arguments gets exactly the same refusal.

A refusal is isError: true, not a protocol error. A protocol error tells the client the server is broken; a tool refusal is a normal outcome a model should see and respond to. Both kinds of refusal are mapped — including the ones the registry raises by throwing, which is what an unauthorized tool does.

Schemas are converted where they must be. MCP requires inputSchema.type === "object" and the SDK's client validates it, so one tool carrying a Zod schema makes the entire listing fail — no tools at all, not one odd entry. Zod schemas are converted; anything unconvertible is advertised as a permissive { "type": "object" } and still validated against the real schema on execution, so the failure direction is a rejected call rather than an accepted bad one.

Approval still stops the call. A gated tool returns approval_required and does not run. Verified against a real client, not only in a test.

Limits

No prompts, no resources. Only tools/list and tools/call. Exposing agents as MCP prompts and knowledge as MCP resources are natural follow-ups and are not built.

No sampling — a server asking the client's model to generate. That inverts the trust direction again and needs its own thinking, not a flag.

No transport is chosen for you. The package registers handlers on a Server you construct, which leaves the transport — and therefore the authentication story — with the host. A convenience wrapper would have to guess at that, and guessing about authentication is how a surface ends up open.