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:
Set to
true to include this route in the MCP toolset. Routes without this field are ignored by anvil mcp.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”.
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.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.A Zod object schema for the request body (non-GET routes). Merged with
paramsSchema when both are present.Automatic schema conversion
Whenanvil 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 type | JSON 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 } |
Tool naming
Anvil derives the tool name from the HTTP method and path segments by default:meta.mcp.name to override this when you want a domain-friendly name:
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:
- Param key match — every key in
paramsSchemacorresponds to a[dynamic]segment in the folder path, and vice versa. - Lossless conversion — the schema converts to JSON Schema without throwing
SchemaConversionError.
Running the MCP server
Once your routes are annotated, start the server: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.