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:
The three pieces work together:
boolean
required
Set to true to include this route in the MCP toolset. Routes without this field are ignored by anvil mcp.
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”.
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.
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.
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:
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:
Set meta.mcp.name to override this when you want a domain-friendly name:
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 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:
You’ll see output like:
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.