Skip to main content
Exposing a route as an MCP tool requires only two additions to a file you’ve already written: a meta export that opts the route in, and a paramsSchema (or bodySchema) that describes its inputs. Anvil does the rest — it converts the Zod schema to JSON Schema, names the tool, and wires it into the MCP server.

The meta.mcp annotation

Add a meta export with an mcp key to any route handler file:
// server/routes/users/[id]/get.ts
import { HttpError, type Context } from 'anvil';
import { z } from 'zod';
import { USERS } from '../data';

export const meta = { mcp: { expose: true, description: 'Fetch a user by ID' } };

export const paramsSchema = z.object({ id: z.string() });

export default function handler(ctx: Context) {
  const user = USERS.find((u) => u.id === ctx.params.id);
  if (!user) throw new HttpError(404, `No user with id "${ctx.params.id}"`);
  return user;
}
The three pieces work together:
meta.mcp.expose
boolean
required
Set to true to include this route in the MCP toolset. Routes without this field are ignored by anvil mcp.
meta.mcp.description
string
The human-readable description shown to MCP clients and AI agents when they list available tools. Keep it concise and imperative: “Fetch a user by ID”, not “This endpoint fetches a user”.
meta.mcp.name
string
An explicit tool name. Must match [a-zA-Z0-9_-]{1,64}. When omitted, Anvil derives a name from the route method and path segments — for example, GET /users/:id becomes get_users_by_id.
paramsSchema
z.ZodObject
A Zod object schema that describes the route’s URL parameters. Keys must match the dynamic segments in the folder path ([id]id). Anvil merges paramsSchema and bodySchema into a single tool input schema.
bodySchema
z.ZodObject
A Zod object schema for the request body (non-GET routes). Merged with paramsSchema when both are present.

Automatic schema conversion

When anvil mcp starts, it calls the built-in Zod-to-JSON-Schema converter on each exposed route’s merged input schema. The result is advertised to MCP clients in the tools/list response so agents know exactly what arguments to pass. At call time, Anvil re-validates the incoming arguments against the original Zod schema before invoking your handler — so any refinements or constraints you defined still enforce, even if the JSON Schema representation was slightly looser.

Zod types that convert cleanly

The converter supports the full data-shape subset of Zod:
Zod typeJSON Schema equivalent
z.string() (with .min(), .max(), .email(), .url(), .uuid(), .regex()){ type: "string", ... }
z.number() / z.number().int() (with .min(), .max()){ type: "number" } / { type: "integer" }
z.boolean(){ type: "boolean" }
z.literal(value){ const: value }
z.enum([...]){ type: "string", enum: [...] }
z.object({...}){ type: "object", properties: {...}, required: [...] }
z.array(...) (with .min(), .max()){ type: "array", items: {...} }
z.optional(...) / z.default(...)field omitted from required
z.nullable(...){ type: ["string", "null"] }
z.union([...]){ anyOf: [...] }
z.record(...){ type: "object", additionalProperties: {...} }
z.tuple([...]){ type: "array", items: [...], minItems, maxItems }
The following Zod constructs cannot be losslessly represented as JSON Schema and will throw a SchemaConversionError during anvil lint or anvil mcp startup:
  • .transform() / .refine() / .superRefine() — move transformation logic into the handler body; the schema must be a plain data shape.
  • z.lazy() — recursive schemas cannot be expressed as a static JSON Schema tool. Flatten or bound the recursion.
  • .pipe() — split into a plain input schema and a handler-side transform.
  • z.date() — use z.string().datetime() instead; JSON has no native date type.
  • z.bigint() — use z.string() instead; JSON has no bigint type.
  • z.map() — use z.record() instead so it serializes as a JSON object.
  • z.set() — use z.array() instead; uniqueness can be enforced in the handler.
  • z.intersection() — use .merge() to combine object shapes instead.
  • z.function() / z.promise() / z.symbol() / z.undefined() / z.never() — these types have no JSON-serializable representation.
These restrictions only apply to schemas on MCP-exposed routes. Non-exposed routes can use any Zod feature.

Tool naming

Anvil derives the tool name from the HTTP method and path segments by default:
GET  /users/:id      →  get_users_by_id
POST /orders         →  post_orders
GET  /               →  get_root
Set meta.mcp.name to override this when you want a domain-friendly name:
export const meta = {
  mcp: { expose: true, name: 'fetch_user', description: 'Fetch a user by ID' },
};
Tool names must be globally unique across all routes and standalone tools. Collisions are a hard startup error.

Validating with anvil lint

Run anvil lint before deploying to catch schema and naming issues at build time:
anvil lint           # warnings are reported, exits 0
anvil lint --strict  # warnings become errors, exits non-zero
anvil lint checks two things for every MCP-exposed route:
  1. Param key match — every key in paramsSchema corresponds to a [dynamic] segment in the folder path, and vice versa.
  2. Lossless conversion — the schema converts to JSON Schema without throwing SchemaConversionError.

Running the MCP server

Once your routes are annotated, start the server:
anvil mcp
You’ll see output like:
[anvil] mcp (Streamable HTTP) on http://localhost:3100/mcp
[anvil] 2 tool(s): get_users_by_id, get_widgets_by_id
Each tool listed corresponds to one meta.mcp.expose = true route that Anvil found under server/routes/. The server is now ready to accept tools/list and tools/call requests from any MCP-compatible client.
Add anvil lint to your CI pipeline so schema issues surface on every pull request — long before anvil mcp ever starts in production.