Fundamentals

Expose to an agent

Serve your capabilities as MCP tools, so Claude, Cursor, Copilot and any other MCP client can call them. A new project already does this over stdio; this page covers what that does, how to serve the same tools over HTTP for a team, and what a client sees.

How it works

A capability becomes an MCP tool when mcp() is one of its sources. The tool name is the capability's .id(), and its .title(), .description() and .input() schema are what the client reads to decide when to call it. Every call is validated against that schema before the capability's steps run, and the client can reach nothing else.

// capabilities/search-orders/route.ts
import { craft, direct, log } from "@routecraft/routecraft";
import { mcp } from "@routecraft/ai";
import { z } from "zod";

export default craft()
  .id("search-orders")
  .title("Search orders")
  .description("Find orders for a customer by email address")
  .input({ body: z.object({ email: z.string().email() }) })
  .from(direct(), mcp())
  .transform(({ email }) => ({ email, orders: [] }))
  .to(log());

The mcp key in craft.config.ts decides how the tools are served. See the MCP example for a complete capability and the mcp() adapter reference for every option.

Install

A project from create-routecraft already has what it needs. Elsewhere:

bun add @routecraft/ai @modelcontextprotocol/server zod

@modelcontextprotocol/server is an optional peer of @routecraft/ai, needed only by the project that serves MCP.

Over stdio, on your laptop

Stdio is the default, and the right transport for one person: the client starts the project as a subprocess and talks to it on its standard streams. No port, no credential.

// craft.config.ts
import { defineConfig } from "@routecraft/routecraft";
import "@routecraft/ai";

export const craftConfig = defineConfig({
  mcp: {},
});

Claude Code starts the server from the folder you register it in, so the short form works. From the project folder:

claude mcp add my-tools -- bunx craft start --log-file craft.log

The log goes to a file because standard output is the protocol.

Clients configured in JSON do not start the server from the project folder, so give them absolute paths: the project's own craft, the project folder, and the log file. Using the project's installed craft also pins the version to the one in its package.json. Cursor (.cursor/mcp.json) and Claude Desktop (claude_desktop_config.json):

{
  "mcpServers": {
    "my-tools": {
      "command": "/absolute/path/to/my-tools/node_modules/.bin/craft",
      "args": [
        "start",
        "/absolute/path/to/my-tools",
        "--log-file",
        "/absolute/path/to/my-tools/craft.log"
      ]
    }
  }
}

VS Code and Copilot (.vscode/mcp.json):

{
  "servers": {
    "my-tools": {
      "type": "stdio",
      "command": "/absolute/path/to/my-tools/node_modules/.bin/craft",
      "args": [
        "start",
        "/absolute/path/to/my-tools",
        "--log-file",
        "/absolute/path/to/my-tools/craft.log"
      ]
    }
  }
}

craft runs on Bun, so a client that does not inherit your shell's PATH needs Bun on its own.

Over HTTP, for a team

Serve MCP over HTTP when the tools should run always on and more than one person or agent should reach them: the team harness. The transport mounts at /mcp on the instance's default server, beside every other door. Servers and ports covers the listener, and how to give MCP a port of its own.

// craft.config.ts
import { defineConfig, jwt } from "@routecraft/routecraft";
import "@routecraft/ai";

export const craftConfig = defineConfig({
  servers: {
    default: {
      host: "0.0.0.0",
      port: 8080,
      allowedHostnames: ["mcp.example.com"],
    },
  },
  mcp: {
    transport: "http",
    // Required outside NODE_ENV development and test.
    resource: { url: "https://mcp.example.com/mcp" },
    auth: jwt({
      secret: process.env.JWT_SECRET!,
      issuer: "https://idp.example.com",
      audience: "https://mcp.example.com",
    }),
  },
});

MCP inherits the server's authentication unless it sets its own auth, as here. auth: false removes the wall: the surface demands no credentials and issues no challenge, but a valid token for the inherited validator still attaches a principal, which is how a tool's .authorize() admits on a public mount, and an invalid one is treated as absent. Anything reachable over the network must be authenticated. Credentials and identity is the model; Securing capabilities has every mode (jwt(), jwks(), custom validators, oauth() as a resource-server gate), identity enrichment and CORS.

Protected-resource metadata is served only at the path-suffixed RFC 9728 URL, which for the default path is /.well-known/oauth-protected-resource/mcp.

Connect a client with the URL instead of a command. Claude Code:

claude mcp add --transport http my-tools https://mcp.example.com/mcp \
  --header "Authorization: Bearer $MCP_TOKEN"

Cursor and Claude Desktop:

{
  "mcpServers": {
    "my-tools": {
      "url": "https://mcp.example.com/mcp",
      "headers": { "Authorization": "Bearer <token>" }
    }
  }
}

VS Code and Copilot:

{
  "servers": {
    "my-tools": {
      "type": "http",
      "url": "https://mcp.example.com/mcp",
      "headers": { "Authorization": "Bearer <token>" }
    }
  }
}

Scaling out

The HTTP transport is stateless. Following MCP revision 2026-07-28, there is no initialize handshake and no Mcp-Session-Id: every request carries its own protocol version, client identity and capabilities, and Routecraft builds a fresh server instance to answer it.

  • Any replica can answer any request. Run as many processes as you like behind a plain round-robin load balancer. No sticky sessions, no shared session store.
  • Auth is enforced per request. A credential is verified on every call rather than once per session, so an expired token stops working on its next call. jwt() and jwks() check the signature, expiry, issuer and audience, and cannot see a revocation: a revoked token still passes until it expires. Where revocation must take effect at once, use a custom validator that asks the identity provider.

Clients that only speak the 2025 revision keep working unchanged; they are served through the stateless 2025 path and do not get the newer revision's features.

Failed calls

A tool call whose route fails comes back as isError: true carrying the tool name and the error code, such as Tool "search-orders" failed (RC5001)., and never the error message: messages carry hostnames, file paths and upstream response text an agent has no business seeing. The message is in your log and on the plugin:mcp:tool:failed event.

An agent can still correct itself when the fault is its own. A call the tool's .input() schema rejects lists the failing fields, a call the tool's .authorize() refuses says why in a fixed phrase (insufficient permissions, a missing scope, an expired credential, no credential at all), and a result that breaks the declared .output() names the fields it broke. See Failed calls in the adapter reference for each text.

Going further


Tools for agents

The fifteen-minute quickstart that ends with a client calling your tool.

Servers and ports

The listener every door mounts on, and how to split them.

Calling an MCP

Call external MCP servers from within a capability.

mcp() adapter reference

Full MCP adapter API and options.

Previous
Servers and ports