> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.thatdevguy.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Expose Anvil Routes as MCP Tools — Step-by-Step

> Add two exports to any route file and Anvil automatically converts it into a fully typed MCP tool — same handler, no extra code.

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:

```ts theme={null}
// server/routes/users/[id]/get.ts
import { HttpError, type Context } from 'anvil-sdk';
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:

<ParamField body="meta.mcp.expose" type="boolean" required>
  Set to `true` to include this route in the MCP toolset. Routes without this field are ignored by `anvil mcp`.
</ParamField>

<ParamField body="meta.mcp.description" type="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"*.
</ParamField>

<ParamField body="meta.mcp.name" type="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`.
</ParamField>

<ParamField body="paramsSchema" type="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.
</ParamField>

<ParamField body="bodySchema" type="z.ZodObject">
  A Zod object schema for the request body (non-GET routes). Merged with `paramsSchema` when both are present.
</ParamField>

## 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 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 }`    |

<Warning>
  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.
</Warning>

## Tool naming

Anvil derives the tool name from the HTTP method and path segments by default:

```text theme={null}
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:

```ts theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
anvil mcp
```

You'll see output like:

```text theme={null}
[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.

<Tip>
  Add `anvil lint` to your CI pipeline so schema issues surface on every pull request — long before `anvil mcp` ever starts in production.
</Tip>
