This is the full developer documentation for mcify
# What is mcify
> A small, opinionated framework for building MCP servers in TypeScript.
mcify is to MCP servers what Hono is to HTTP servers: a small, opinionated framework that handles the protocol boilerplate so you only write your tools’ business logic.
The Model Context Protocol (MCP) is Anthropic’s spec for letting AI agents discover and call tools. An MCP server is the thing on the other end — the one that actually executes the tools, returns data, and (often) talks to your business APIs.
## The pieces
| Package | What it gives you |
| ------------------ | ----------------------------------------------------------------------------------------------- |
| `@mcify/cli` | The `mcify` binary. `init`, `dev`, `build`, `generate`, `deploy`. |
| `@mcify/core` | `defineTool`, `defineResource`, `definePrompt`, `defineConfig`, schema/auth/middleware helpers. |
| `@mcify/runtime` | The MCP server runtime. stdio + HTTP transports. Adapters for Node, Bun, Workers. Event bus. |
| `@mcify/inspector` | Local web UI served by `mcify dev` at `:3001`. Tools list, calls log, playground, chat tab. |
## Design tenets
* **Type-safe end-to-end.** One Zod schema is your handler args, your JSON Schema for `tools/list`, and your generated client types. No drift.
* **Edge-first.** The same handler runs on Cloudflare Workers, Vercel Edge, Bun, Node, or Docker. Adapters live in the runtime; you don’t rewrite tools.
* **AI-agent-aware.** Every scaffold ships an [`AGENTS.md`](https://github.com/openai/agents.md) so Claude Code / Cursor / Cody / Windsurf / Copilot Workspace already know your project’s conventions.
* **Composable middleware.** `requireAuth`, `rateLimit`, `withTimeout` ship in core. Wrap any tool. Compose like Express/Hono.
* **Self-host or cloud.** Apache 2.0 OSS today; managed hosting via mcify Cloud later for vendors who don’t want to manage infra.
## When to use it
* You have an existing API and want an AI agent to call it.
* You want to wrap multiple internal microservices behind one MCP server.
* You need bearer / API-key auth, rate limiting, and per-tool timeouts in production.
* You want the same code to run on edge and on a self-hosted box.
## When not to use it
* You’re writing an MCP **client** (the agent side). Use the official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) instead — that’s what AI apps consume.
* You don’t need typed schemas. The `@modelcontextprotocol/sdk` server side is fine if you’ll write JSON Schemas by hand and don’t want any abstraction.
## Next
* [Install](/start/install/) — get the CLI on your machine.
* [Your first MCP server](/start/first-server/) — `init` → `dev` → connect a client.
* [Connect to Claude / Cursor](/start/connect-clients/) — wire it up to a real agent.
# Install
> One-line install via npx, no global needed.
mcify ships as three npm packages on the `alpha` dist-tag. The CLI is the only thing you invoke directly:
```bash
npx @mcify/cli@alpha init my-mcp
```
That command:
1. Downloads the latest `@mcify/cli@alpha`.
2. Scaffolds `./my-mcp/` from the `from-scratch` template.
3. Substitutes `{{name}}` with `my-mcp` everywhere.
No global install. No `pnpm add -g`. The CLI lives in your project’s `devDependencies` after `pnpm install`.
## Requirements
* **Node ≥ 20** — the runtime targets ES2022 and modern Web APIs.
* **A package manager**: pnpm 9+ (recommended), npm 10+, or yarn 4+. The CLI auto-detects from your lockfile.
## Pick a template
```bash
# Empty starter (one greet tool)
npx @mcify/cli@alpha init my-mcp
# Code-first with Zod schemas centralized in src/schemas.ts
npx @mcify/cli@alpha init my-mcp --template from-zod
# Clone the Khipu connector (Chilean payment links) as a starting point
npx @mcify/cli@alpha init my-khipu --template example-khipu
```
There’s no `--template from-openapi` (yet) — that flow lives behind the [generator command](/guides/from-openapi/) instead, so you keep authoring your own `mcify.config.ts` and only generate the per-spec tool files.
## Verify
```bash
cd my-mcp
pnpm install
pnpm dev
```
You should see:
```plaintext
✓ my-mcp v0.1.0
mcify MCP http://localhost:8888/mcp
mcify inspector http://localhost:3001
```
Open `http://localhost:3001` — that’s the inspector. The `Tools` tab lists the one tool the template ships (`greet`); `Playground` lets you call it.
## Next
* [Your first MCP server](/start/first-server/) — add a real tool.
* [Connect to Claude / Cursor](/start/connect-clients/) — point an agent at the dev server.
# Your first MCP server
> Add a real tool, see it in the inspector, ship it.
import { Steps } from ‘@astrojs/starlight/components’;
You scaffolded with `mcify init` and ran `pnpm dev`. Now let’s add a real tool that calls a real API.
We’ll build a tiny server that wraps a public weather API. Two tools: `weather_get_current` and `weather_forecast`.
1. **Open `mcify.config.ts`.** It has one tool (`greet`). Delete it and the import.
2. **Create the input/output schemas.**
src/schemas.ts
```ts
import { z } from 'zod';
export const Coords = z.object({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
});
export const CurrentWeather = z.object({
temperatureC: z.number(),
windKmh: z.number(),
conditions: z.string(),
});
export const Forecast = z.object({
daily: z.array(
z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
minC: z.number(),
maxC: z.number(),
}),
),
});
```
3. **Write the tools.** Keep handlers small. Validate at the boundary, do the call, map the response.
src/tools/get-current.ts
```ts
import { defineTool } from '@mcify/core';
import { Coords, CurrentWeather } from '../schemas.js';
export const getCurrent = defineTool({
name: 'weather_get_current',
description:
'Current temperature, wind, and conditions for a coordinate. Use when the user asks for "weather right now" at a specific place.',
input: Coords,
output: CurrentWeather,
handler: async ({ latitude, longitude }, ctx) => {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true`;
const res = await ctx.fetch(url);
const data = (await res.json()) as {
current_weather: { temperature: number; windspeed: number; weathercode: number };
};
return {
temperatureC: data.current_weather.temperature,
windKmh: data.current_weather.windspeed,
conditions: codeToWords(data.current_weather.weathercode),
};
},
});
const codeToWords = (code: number): string => {
// Open-Meteo WMO codes — abridged for the example.
if (code === 0) return 'clear';
if (code <= 3) return 'partly cloudy';
if (code <= 67) return 'rain';
if (code <= 77) return 'snow';
return 'storm';
};
```
4. **Wire it into the config.**
mcify.config.ts
```ts
import { defineConfig } from '@mcify/core';
import { getCurrent } from './src/tools/get-current.js';
export default defineConfig({
name: 'weather',
version: '0.1.0',
description: 'Weather data via Open-Meteo, exposed as MCP tools.',
tools: [getCurrent],
});
```
5. **Run it.** `pnpm dev` is still watching. Save your files; the runtime hot-reloads.
Hit the inspector at `http://localhost:3001`, switch to **Playground**, pick `weather_get_current`, paste:
```json
{ "latitude": -33.45, "longitude": -70.66 }
```
Click **Invoke**. You get the current weather in Santiago.
## What just happened
* Your handler ran with **typed args** — `latitude` and `longitude` were already validated by Zod before your code saw them.
* The response was checked against `CurrentWeather` on the way out. If the upstream API ever returned a different shape, you’d see a `ValidationError` with the exact field that drifted, not a runtime crash three layers deep.
* The inspector observed everything via the runtime’s event bus. Switch to the **Calls Log** tab to see the call, latency, args, and result.
## Add auth before you ship
Right now anyone who hits `http://localhost:8888/mcp` can call your tool. For real deploys, gate it with a bearer token:
mcify.config.ts
```ts
import { bearer, defineConfig } from '@mcify/core';
import { getCurrent } from './src/tools/get-current.js';
export default defineConfig({
name: 'weather',
version: '0.1.0',
description: 'Weather data via Open-Meteo, exposed as MCP tools.',
auth: bearer({ env: 'MCIFY_AUTH_TOKEN' }),
tools: [getCurrent],
});
```
```bash
export MCIFY_AUTH_TOKEN="$(openssl rand -hex 32)"
pnpm dev
```
Now requests to `/mcp` need an `Authorization: Bearer ` header. The inspector still works because it goes through `/api/tools/...`, not `/mcp`.
## Next
* [Connect this to Claude / Cursor](/start/connect-clients/) — point a real agent at the server.
* [Concepts → Tools](/concepts/tools/) — the full anatomy of `defineTool`.
* [Creating effective tools](/guides/creating-effective-tools/) — what to put in the description, how to size schemas, when to add middleware.
# Tools
> The smallest unit an MCP agent can invoke. Anatomy and lifecycle of a defineTool.
A tool is a function the agent can call. It has a name, a description, an input schema, an output schema, and a handler.
## Anatomy
```ts
import { defineTool } from '@mcify/core';
import { rateLimit, requireAuth, withTimeout } from '@mcify/core/middleware';
import { z } from 'zod';
export const createPayment = defineTool({
// 1. Identity. Snake-case, prefixed by the service. Stable across versions.
name: 'khipu_create_payment',
// 2. What the agent reads to decide whether to call this tool. Be specific
// about *when* to use it (and when *not* to).
description:
'Create a Khipu payment link. Returns a URL the customer opens to pay via Chilean banks. ' +
'Use for one-shot charges; no recurring support.',
// 3. Composable middleware. Order matters — auth runs first, then rate limit,
// then timeout, then your handler.
middlewares: [
requireAuth({ message: 'khipu_create_payment requires authentication' }),
rateLimit({ max: 60, windowMs: 60_000 }),
withTimeout({ ms: 5_000 }),
],
// 4. Input schema. Zod doubles as JSON Schema 7 (for tools/list) and TS types.
input: z.object({
subject: z.string().min(1).max(255).describe('What the payer sees on the bank screen'),
currency: z.enum(['CLP', 'USD']),
amount: z.number().positive(),
}),
// 5. Output schema. The runtime validates handler returns against this.
output: z.object({
paymentId: z.string(),
paymentUrl: z.string().url(),
}),
// 6. The handler. Pure: input → output. Use ctx for dependency injection.
handler: async (input, ctx) => {
const res = await ctx.fetch('https://payment-api.khipu.com/v3/payments', {
method: 'POST',
headers: { 'x-api-key': process.env.KHIPU_API_KEY, 'content-type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json();
return { paymentId: data.payment_id, paymentUrl: data.payment_url };
},
});
```
## Lifecycle of a call
When an MCP client invokes `khipu_create_payment`:
1. **Transport** receives the JSON-RPC request (HTTP `POST /mcp` or stdio).
2. **Auth** runs first — `requireAuth` reads the token from headers and rejects 401 on miss.
3. **Input validation** — `input.parse()`. On failure: `McifyValidationError` with `phase: 'input'` and the offending field.
4. **Rate limit** — `rateLimit` checks the per-token bucket; returns 429 on overflow.
5. **Timeout** — `withTimeout` races your handler against a deadline.
6. **Your handler runs** — receives the parsed input + a `ctx` with `fetch`, `logger`, and the auth state.
7. **Output validation** — `output.parse()` on the return value. Drift here is *your* bug, not the agent’s; the runtime surfaces it loudly.
8. **Response** is wrapped in MCP’s `CallToolResult` format and sent back.
## What goes in `ctx`
```ts
handler: async (input, ctx) => {
ctx.fetch // The runtime's fetch. Inject in tests, leave alone in prod.
ctx.logger // Pino-ish logger with .info, .warn, .error.
ctx.auth // { token, claims, scopes? } — set by your auth config.
ctx.request // RequestMeta — origin URL, headers, method.
return ...
}
```
Use `ctx.fetch` in handlers, not `globalThis.fetch`. Tests inject a mock through `ctx`; using the global means the mock doesn’t apply and your tests hit the network.
## Naming
Tool names must be unique within a server, snake\_case, and prefixed by the service or domain:
| Good | Bad |
| ----------------------- | ------------------------- |
| `khipu_create_payment` | `createPayment` |
| `users_list` | `list` |
| `inventory_check_stock` | `checkStockAndAlertIfLow` |
Why prefix? Because when the agent sees 200 tools across 12 servers (a real shape), `users_list` is grep-able and `list` is a 12-way collision. The [from-openapi generator](/guides/from-openapi/) does this automatically.
## Where to be explicit
The agent reads three things to decide whether and how to call your tool:
1. **`description`** — what the tool does + *when* to use it. One sentence about the action, one about the trigger condition.
2. **Per-field `.describe()` on inputs** — what each parameter means and what format it expects.
3. **Errors you throw** — the message becomes context for the agent’s next decision.
Don’t skip any of the three.
## Next
* [Resources](/concepts/resources/) — read-only data the agent fetches, addressed by URI.
* [Prompts](/concepts/prompts/) — pre-built message templates the agent can request.
* [Auth](/concepts/auth/) — bearer, API key, OAuth.
* [Creating effective tools](/guides/creating-effective-tools/) — the longer best-practices guide.
# Creating effective tools
> Best practices for writing tools that AI agents call correctly the first time.
The agent has hundreds of tools across dozens of MCP servers in its context. It picks yours based on three signals: name, description, and per-field descriptions. Get those right and your tool gets called correctly. Get them wrong and the agent either skips your tool or calls it with malformed args and a wrong understanding of the result.
This guide is the long version of “what works.” For the things to *not* do, see [Antipatterns](/guides/antipatterns/).
## The three things the agent reads
```ts
defineTool({
name: 'khipu_create_payment', // 1. Identity — keep it stable.
description: // 2. Decision signal.
'Create a Khipu payment link. Returns a payment_url the customer ' +
'opens to pay via Chilean banks. Use for one-shot charges; no ' +
'recurring support.',
input: z.object({
subject: z.string()
.describe('Short text shown on the bank screen, e.g. "Order #1234"'), // 3. Each field.
...
}),
...
});
```
### Name: stable, prefixed, snake\_case
* **Stable.** Renaming a tool is a breaking change for every agent that’s been told about it. If the operation changes, version it (`v2_`) instead of renaming.
* **Prefixed.** The same agent might have `users_list` from your service and `list_users` from another. Snake-prefix by service or domain (`khipu_`, `inventory_`, `support_`).
* **Verb-object.** `create_payment`, not `payment_creator`. The agent thinks in actions.
### Description: what it does, *when* to use it
Two sentences. First: action and result. Second: trigger condition or constraint.
> Create a Khipu payment link. Returns a `payment_url` the customer opens to pay via Chilean banks. **Use for one-shot charges; no recurring support.**
The “use for X; not Y” sentence is the most undervalued part of any tool description. It’s the line that prevents the agent from calling your one-shot payment tool to set up a subscription.
Other things to put in description:
* **Side effects.** “Sends an email to the customer” / “Logs to the audit table” — the agent should know.
* **Latency hints when extreme.** “This call typically takes 30 seconds; consider running it in the background.”
* **Cost signals.** “Each call charges the customer’s card.”
Don’t put:
* The schema (it’s already in `input`).
* “This tool” / “This function” — the agent knows it’s a tool.
* Marketing language. The agent is reading, not buying.
### Per-field `.describe()`
Every input field should have a `.describe()`. Especially:
* **Identifiers** — what shape are they? `'Khipu payment id, like "p_abcd..."'` is more useful than just “the id”.
* **Enums** — when the values aren’t self-explanatory. `z.enum(['done', 'committed'])` should describe what ‘committed’ means vs ‘done’.
* **Money / units.** `'Amount in CLP (no decimals)'` vs `'Amount in USD cents'` vs `'Amount in BTC satoshis'`. Pick one, document it.
* **Dates / times.** `'ISO 8601 in UTC'` vs `'YYYY-MM-DD'` vs `'unix timestamp seconds'`.
```ts
input: z.object({
paymentId: z
.string()
.regex(/^p_[a-z0-9]{16}$/)
.describe('Khipu payment id (returned by khipu_create_payment), shape p_<16 alphanumeric>'),
amount: z
.number()
.positive()
.describe('Amount in the major unit (CLP pesos, USD dollars). Do not pass cents.'),
}),
```
## Schema sizing
The agent generates structured args. The looser your schema, the more it has to invent. The tighter your schema, the more often it gets things right on the first try.
**Use enums when the value set is closed.** Don’t take a free-form `status: string` if the API only accepts `'pending' | 'done' | 'failed'`.
**Use formats.** `z.string().email()`, `.uuid()`, `.url()` — Zod renders these as JSON Schema with `format`, which Claude / GPT honor.
**Use min/max for numbers and lengths.** `z.string().max(255)` keeps the agent from passing your 4KB internal note into a `subject` field.
**Don’t over-validate.** `z.string().regex(/^[a-zA-Z0-9-_]{8,32}$/)` for a name field is too strict and forces the agent into trial-and-error. If the upstream API would accept “User Tester”, let yours.
## Output shape
Return objects, not strings. The agent can introspect an object and decide what to do next; it has to grep a string.
```ts
// Good
output: z.object({
paymentId: z.string(),
paymentUrl: z.string().url(),
expiresAt: z.string().datetime(),
}),
// Bad
output: z.string(), // "Created payment p_abc; pay at https://... by 2026-05-12T00:00:00Z"
```
Even when the upstream returns a string, parse it into a structure if you can.
## Errors as messages
When something goes wrong, the message you throw becomes context for the agent’s next turn. Make it actionable.
```ts
// Good — agent can recover (lookup the right user, retry with the right id)
throw new Error(`User ${input.userId} not found in Khipu. Check the id with khipu_list_users.`);
// Bad — agent has no path forward
throw new Error('Khipu request failed: 404');
```
Use [`McifyValidationError`](/reference/core/#mcifyvalidationerror) for input/output drift — the runtime serializes it with `phase` and the offending field, so the agent knows whether to fix its args or give up.
## Granularity
> One tool per logical operation. Not one per HTTP endpoint.
When mapping an API to MCP, resist the urge to make 1:1 tools for every CRUD endpoint. Group:
* **Idempotent reads** can stay 1:1. `users_list`, `users_get`, `users_search` — fine.
* **Multi-step writes should be one tool.** `create_payment_with_callback` is one tool that internally hits two endpoints. The agent shouldn’t have to orchestrate.
* **Variants of one operation should be one tool with a discriminator.** `create_payment(currency: 'CLP'|'USD')` not `create_clp_payment` + `create_usd_payment`.
The right number of tools is “as few as possible while still letting the agent achieve every supported task.” When you’re not sure, ship fewer; you can split later. Splitting is forward-compatible (the old tool still works); merging is breaking.
## Middleware: defaults that scale
Every tool should have at minimum:
```ts
middlewares: [
requireAuth(),
rateLimit({ max: , windowMs: 60_000 }),
withTimeout({ ms: }),
],
```
Reasonable values depend on the operation:
| Operation type | rateLimit max/min | timeout ms |
| ------------------------------------------ | ----------------- | ---------- |
| Read (list / get) | 120–240 | 5,000 |
| Search | 60 | 8,000 |
| Idempotent write (upsert) | 60 | 8,000 |
| Side-effect write (charge / send / refund) | 30 | 15,000 |
| Slow side-effect (export, batch) | 5 | 60,000 |
## Test the description with a real agent
Before you ship, point Claude / Cursor at your local server and ask, in plain language, to do the thing. If the agent picks the right tool with the right args on the first try, the description is good. If you have to coach it (“you should use the X tool”), the description is hiding information.
The inspector’s [Chat tab](/start/connect-clients/) is the fastest way to do this loop — paste an API key, send a message, watch the tool call appear in the calls log.
## Next
* [Antipatterns](/guides/antipatterns/) — what *not* to do, with concrete examples.
* [AI prompts → Add a tool](/prompts/add-tool/) — copy-paste prompt for Claude Code that walks the canonical pattern.
* [Concepts → Tools](/concepts/tools/) — the API reference for `defineTool`.
# Antipatterns to avoid
> Six concrete failure modes when building tools, why they break, and what to do instead.
These are the patterns that make agents pick the wrong tool, call it with wrong args, or fail to recover from errors. Each one is paired with a fix. Drawn from Anthropic’s [Writing Tools for Agents](https://www.anthropic.com/engineering/writing-tools-for-agents) plus practical experience from the Lelemon Agentes connectors.
## 1. Vague descriptions / overlapping purposes
**The smell:**
```ts
defineTool({
name: 'search_users',
description: 'Search users.',
...
});
defineTool({
name: 'list_users',
description: 'List users.',
...
});
```
The agent has no way to choose. It either picks one at random, calls both, or asks the user to clarify (wasted turn).
**Fix:** consolidate into one tool with a discriminating parameter, *or* differentiate the descriptions so each one’s trigger is obvious.
```ts
defineTool({
name: 'users_query',
description:
'Find users. Pass `query` for free-text search across name/email; ' +
'leave empty to list the most-recent. Returns up to 50 per page.',
input: z.object({
query: z.string().optional(),
limit: z.number().int().min(1).max(50).default(20),
}),
...
});
```
## 2. Returning everything, unfiltered
**The smell:**
```ts
handler: async () => {
const all = await db.contacts.findAll(); // 47,000 rows
return { contacts: all };
};
```
The response is enormous. The agent burns tokens parsing it. The model’s context window fills with mostly irrelevant data.
**Fix:** require pagination + filters in the schema. Truncate proactively. Tell the agent in the response when there’s more.
```ts
input: z.object({
query: z.string().optional(),
limit: z.number().int().min(1).max(100).default(50),
cursor: z.string().optional(),
}),
output: z.object({
contacts: z.array(Contact),
nextCursor: z.string().optional(),
totalApprox: z.number().optional(),
}),
handler: async (input) => {
const page = await db.contacts.find(input.query, { limit: input.limit, cursor: input.cursor });
return {
contacts: page.items,
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
...(page.totalApprox ? { totalApprox: page.totalApprox } : {}),
};
},
```
## 3. Ambiguous parameter names
**The smell:**
```ts
input: z.object({
user: z.string(), // a name? an email? an id?
type: z.string(), // of what?
data: z.record(z.any()), // good luck
}),
```
The agent is forced to guess. It picks one interpretation and is wrong half the time.
**Fix:** name parameters by the *thing they identify*, not by their abstract role. Add `.describe()` with format hints.
```ts
input: z.object({
userId: z.string().uuid().describe('User UUID, e.g. "550e8400-e29b-..."'),
documentType: z.enum(['invoice', 'receipt', 'credit_note']).describe('SII tax document type'),
lineItems: z.array(LineItem).min(1).describe('At least one item; net unit price + quantity per row'),
}),
```
If a field is `Record`, the agent is reading your mind. Replace it with a typed object every time you can.
## 4. Hidden side effects
**The smell:**
```ts
defineTool({
name: 'update_user',
description: 'Update a user.',
handler: async (input) => {
await db.users.update(input);
await sendEmail(input.email, 'profile changed'); // surprise
await audit.log({ kind: 'sensitive_change', userId: input.id }); // surprise
},
});
```
The agent doesn’t know the email goes out. It calls `update_user` to fix a typo and a customer gets a passive-aggressive notification.
**Fix:** put every side effect in the description. Offer a `dry_run` if the action is destructive or expensive.
```ts
defineTool({
name: 'update_user',
description:
'Update a user. Sends a "your profile changed" email to the user and ' +
'writes an audit log entry. Pass `notify: false` to skip the email; ' +
'audit log is always written.',
input: z.object({
userId: z.string().uuid(),
patch: UserPatch,
notify: z.boolean().default(true),
}),
...
});
```
Mute-by-default is generally a worse default than notify-by-default — but the agent needs to know either way.
## 5. Schemas without per-field descriptions
**The smell:**
```ts
input: z.object({
status: z.enum(['p', 'v', 'd', 'c', 'f', 'r']),
amount: z.number(),
currency: z.string(),
}),
```
`status: 'c'` — committed? cancelled? closed? The agent guesses. The model hallucinates `currency: 'pesos'` when the API expects `'CLP'`.
**Fix:** describe every non-obvious field. Cap free-form strings with `enum` or `regex` if the upstream API does.
```ts
input: z.object({
status: z
.enum(['pending', 'verifying', 'done', 'committed', 'failed', 'rejected'])
.describe('Khipu payment status — see https://docs.khipu.com/api/payments'),
amount: z.number().positive().describe('Amount in the major unit (CLP pesos, USD dollars)'),
currency: z.enum(['CLP', 'USD']).describe('ISO 4217 code, only CLP and USD supported'),
}),
```
## 6. Tools that are too granular
**The smell:**
```ts
get_user_email(userId);
get_user_name(userId);
get_user_role(userId);
get_user_created_at(userId);
get_user_last_login(userId);
```
To answer “what’s this user’s role and last login,” the agent makes two calls. To render a profile, five.
**Fix:** group fetches that always travel together into one tool. Let the caller subset, not the agent.
```ts
defineTool({
name: 'users_get',
description: 'Fetch a user by id. Returns the full profile.',
input: z.object({ userId: z.string().uuid() }),
output: User, // includes email, name, role, createdAt, lastLogin, ...
...
});
```
The mirror-image antipattern is *too* coarse:
```ts
do_user_thing(userId, action: 'create' | 'update' | 'delete' | 'reset_password' | ...)
```
Don’t bundle unrelated operations behind a `kind` field. The right granularity is “one tool per logical operation, not one per HTTP endpoint and not one per micro-attribute.”
## Bonus: the universal mistake
**Returning a string when you could return an object.**
```ts
// Bad
output: z.string(),
handler: async (...) => `Payment p_abc created; pay at https://... by 2026-05-12.`,
// Good
output: z.object({
paymentId: z.string(),
paymentUrl: z.string().url(),
expiresAt: z.string().datetime(),
}),
handler: async (...) => ({ paymentId: 'p_abc', paymentUrl: '...', expiresAt: '2026-05-12T00:00:00Z' }),
```
The agent can `result.paymentUrl` an object. It has to grep a string. Always return structured.
## Next
* [Creating effective tools](/guides/creating-effective-tools/) — the positive-form best practices.
* [Concepts → Tools](/concepts/tools/) — the `defineTool` API reference.
# How to use these prompts
> Copy a prompt, paste it into Claude Code / Cursor / Windsurf, get production-ready output.
import { Card, CardGrid } from ‘@astrojs/starlight/components’;
The pages in this section are **prompts you can copy and paste into your AI coding assistant** to get correct, idiomatic mcify code on the first try. Each one assumes the assistant has access to these docs (via `llms-full.txt`) and your local repo.
## Two ways to use them
### Option 1: paste into a chat (Claude.ai / ChatGPT / Cursor agent)
Open the prompt page, click the copy button on the prompt block, paste into your assistant. The prompt starts with a context-setting block that points at our `llms-full.txt`, so the model can pull all the framework docs into its context before doing the work.
### Option 2: drop into Claude Code / Cursor as a slash command
Each prompt has a frontmatter block at the top you can save as a project-level slash command:
```bash
# Claude Code
mkdir -p .claude/commands
curl -o .claude/commands/add-mcp-tool.md https://docs.mcify.dev/prompts/add-tool/raw
# Cursor (similar — paste into .cursor/rules/)
```
Then `/add-mcp-tool` in Claude Code triggers the prompt with the rest of your conversation as input.
## Why this works
mcify ships three things specifically for this loop:
1. **`docs.mcify.dev/llms-full.txt`** — every page of these docs in one markdown file. Models can read it in one fetch.
2. **`AGENTS.md`** — every `mcify init` scaffold ships with one. Claude Code, Cursor, Cody, Windsurf, and Copilot Workspace all read it automatically.
3. **Slash commands in templates** — `from-scratch` and `from-zod` templates include `.claude/commands/add-tool.md` so the slash command works out of the box.
## Available prompts
Tell the assistant what API call you want to expose. It scaffolds the tool with the right schemas, middleware, and tests. \[Open prompt →]\(/prompts/add-tool/) You point at an OpenAPI spec or a few endpoints. The assistant generates a full mcify connector. \[Open prompt →]\(/prompts/wrap-api/) Paste the error or the agent transcript. The assistant locates the bug (schema mismatch, auth, timeout, side effect). \[Open prompt →]\(/prompts/debug-tool/) You already have one MCP server. The assistant adds N microservices behind it via \`generate from-openapi\`. \[Open prompt →]\(/prompts/migrate-multispec/)
## Building your own
Every prompt in this section starts with the same three-block structure:
```markdown
You are helping a developer build an MCP server with mcify.
Read these docs first to ground your knowledge:
- https://docs.mcify.dev/llms-full.txt
Project context:
[the developer's repo state here, if relevant]
Task:
[what the developer wants done]
Conventions:
- TypeScript strict, ES modules, Node ≥ 20.
- Zod schemas for inputs and outputs (defineTool).
- requireAuth + rateLimit + withTimeout middleware on every tool.
- Snake-case, service-prefixed tool names.
- Per-field .describe() on every input.
- See https://docs.mcify.dev/guides/antipatterns/ for what to avoid.
```
Reuse that scaffold; swap in the task. The “Read these docs first” line is the load-bearing one — without it, the model relies on stale training data.
# Auth
> Bearer, API key, OAuth, or none. Configured at the server, enforced per request.
Auth in mcify is **between the agent and your MCP server**. Don’t confuse it with the auth between your server and the upstream API it wraps — those are two different layers.
```plaintext
[ Agent ] ← bearer token → [ mcify server ] ← upstream API key → [ Khipu / Bsale / ... ]
```
Your `auth` config governs the left arrow only.
## Bearer (recommended default)
```ts
import { bearer, defineConfig } from '@mcify/core';
defineConfig({
auth: bearer({ env: 'MCIFY_AUTH_TOKEN' }),
...
});
```
The agent sends `Authorization: Bearer `. The runtime compares with `process.env.MCIFY_AUTH_TOKEN` using a constant-time check.
Generate the token: `openssl rand -hex 32`. Store it where you store other secrets (Workers `wrangler secret`, Fly `flyctl secrets`, Railway env vars, Kubernetes Secret).
## API key
```ts
import { apiKey } from '@mcify/core';
auth: apiKey({ headerName: 'x-api-key', env: 'MCIFY_API_KEY' }),
```
Same shape as bearer, different header. Useful when the consuming agent already speaks `x-api-key` to its other backends.
## OAuth
For multi-tenant setups where each user has their own credentials:
```ts
import { oauth } from '@mcify/core';
auth: oauth({
provider: 'workos',
audience: 'mcify-server',
// Provider-specific options.
}),
```
The runtime validates JWTs against the provider’s JWKS. The decoded claims land in `ctx.auth.claims` so your handlers can do per-user authorization.
## Custom verify
If your token shape doesn’t fit `bearer` / `apiKey`, pass a `verify` function:
```ts
auth: bearer({
verify: async (token, ctx) => {
const session = await mySessionStore.lookup(token);
if (!session) return null; // → 401
return { token, claims: { userId: session.userId, scopes: session.scopes } };
},
}),
```
The runtime calls `verify` once per request, caches the result for the duration of that request, and exposes the return value as `ctx.auth`.
## None
For local dev or fully-public servers:
```ts
import { auth } from '@mcify/core';
auth: auth.none(),
```
Don’t ship this to production unless your server only exposes idempotent reads of public data. Even then, `rateLimit` middleware on every tool is mandatory.
## Per-tool auth
The server-level `auth` is the gate. To require an *additional* check per tool, use `requireAuth` middleware with a predicate:
```ts
defineTool({
middlewares: [
requireAuth({
check: (auth) => auth.claims.scopes?.includes('payments:write'),
message: 'requires the payments:write scope',
}),
],
...
});
```
`requireAuth` returns 403 (not 401) when the request authenticated but lacks the right scope. The agent gets a useful error.
# Middleware
> Composable wrappers for cross-cutting tool concerns.
Middleware in mcify wraps a tool’s handler. Each one runs in order, can short-circuit (block the call), and can mutate the response.
## Built-ins
```ts
import { requireAuth, rateLimit, withTimeout } from '@mcify/core/middleware';
middlewares: [
requireAuth(), // Reject if ctx.auth is null.
rateLimit({ max: 60, windowMs: 60_000 }), // Per-token bucket.
withTimeout({ ms: 5_000 }), // AbortController-backed deadline.
],
```
| Middleware | What it does |
| ------------- | -------------------------------------------------------------------------------------------------- |
| `requireAuth` | Asserts the request authenticated. Optional `check` predicate for scope checks. Returns 401 / 403. |
| `rateLimit` | Sliding window per token. In-memory by default; pluggable store for distributed setups. |
| `withTimeout` | Wraps the handler in `Promise.race` against a timer. Aborts via `ctx.signal`. |
## Order matters
Middleware runs **outer-to-inner** in the order you list them, then the handler runs, then they unwind in reverse. Same as Express / Hono / Koa.
The recommended order:
1. `requireAuth` — fail fast on unauthenticated calls; everything below is wasted compute on a request you’re going to reject anyway.
2. `rateLimit` — gate per token, *after* you know the token is valid.
3. `withTimeout` — last, so the timer doesn’t tick during auth work.
## Custom middleware
```ts
import type { Middleware } from '@mcify/core';
export const auditLog: Middleware = async (ctx, next) => {
const start = Date.now();
try {
const result = await next();
ctx.logger.info('tool_invoked', { tool: ctx.toolName, ms: Date.now() - start });
return result;
} catch (e) {
ctx.logger.warn('tool_failed', {
tool: ctx.toolName,
ms: Date.now() - start,
error: String(e),
});
throw e;
}
};
```
Use it like any other:
```ts
defineTool({
middlewares: [requireAuth(), auditLog, withTimeout({ ms: 5_000 })],
...
});
```
## composeMiddlewares (utility)
When you have a stack you want to share across many tools:
```ts
import { composeMiddlewares } from '@mcify/core';
const standardStack = composeMiddlewares([
requireAuth(),
rateLimit({ max: 60, windowMs: 60_000 }),
withTimeout({ ms: 5_000 }),
auditLog,
]);
defineTool({ middlewares: [standardStack], ... });
```
`composeMiddlewares` flattens the stack into a single middleware so you don’t have to spread an array into every tool definition.
# Prompts
> Pre-built message templates the agent requests by name.
A prompt is a parameterized message template the agent can fetch. Useful when you want to standardize how an agent should handle a recurring request — you ship the template, the agent substitutes the args.
```ts
import { definePrompt } from '@mcify/core';
import { z } from 'zod';
export const refundFlow = definePrompt({
name: 'refund_flow',
description: 'Walk the agent through gathering info to issue a refund.',
argumentsSchema: z.object({
orderId: z.string(),
locale: z.enum(['es', 'en']).default('es'),
}),
render: async ({ orderId, locale }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text:
locale === 'es'
? `Quiero hacer un refund de la orden ${orderId}. Pide motivo y monto, luego confirma.`
: `I want to refund order ${orderId}. Ask the user for reason and amount, then confirm.`,
},
},
],
}),
});
```
The agent discovers prompts via `prompts/list` and pulls a specific one with `prompts/get`.
## When to use a prompt
* You want a standard format for a multi-turn conversation that always starts the same way.
* Localization: serve `es`/`en`/`pt` versions of the same script.
* Compliance: legal text that must appear verbatim in a specific flow.
If you’d write the prose in your agent’s system message anyway, a prompt is just a way to keep it on the server (versioned, edit-without-deploy) instead of in the agent’s code.
## Wiring
```ts
defineConfig({ prompts: [refundFlow], ... });
```
Prompts are part of `tools/list` discovery — the runtime exposes them on the `prompts/*` JSON-RPC methods automatically.
# Resources
> Read-only data addressed by URI. Static or templated.
A resource is something the agent reads (not invokes). Each one is identified by a URI — opaque to the agent, structured to your server.
```ts
import { defineResource } from '@mcify/core';
export const config = defineResource({
uri: 'config://current',
name: 'Server config',
description: 'The mcify server config in JSON form',
mimeType: 'application/json',
read: async () => ({
contents: [
{ uri: 'config://current', text: JSON.stringify({ name: 'weather', version: '0.1.0' }) },
],
}),
});
```
Wire it the same way as a tool:
```ts
defineConfig({ resources: [config], ... });
```
The agent calls `resources/list` to discover them, then `resources/read` with a URI.
## Templated resources
URIs can be templated. The runtime extracts path parameters and passes them to your handler:
```ts
export const userProfile = defineResource({
uri: 'user://{userId}',
name: 'User profile',
read: async (params) => {
// params.userId — typed via the schema below
const user = await fetchUser(params.userId);
return {
contents: [{ uri: `user://${params.userId}`, text: JSON.stringify(user) }],
};
},
paramsSchema: z.object({ userId: z.string().uuid() }),
});
```
When the agent calls `resources/read` with `user://550e8400-e29b-41d4-a716-446655440000`, the runtime pattern-matches against `user://{userId}`, validates with `paramsSchema`, and invokes `read({ userId: '550e...' })`.
## Resource vs tool
| Use a resource | Use a tool |
| ----------------------------------------------- | -------------------------------------------- |
| Read-only. Idempotent. | Causes side effects. Writes, sends, charges. |
| The agent wants to *see* something. | The agent wants to *do* something. |
| Result is large enough that pagination matters. | Result fits in a JSON object. |
Most connectors lean tool-heavy. Resources are useful for read-only catalog APIs (a list of products, a knowledge base article, a config dump).
## Next
* [Prompts](/concepts/prompts/)
* [Tools](/concepts/tools/) — the more common case.
# Deploy to Cloudflare Workers
> Deploy your mcify server to Cloudflare Workers — edge runtime, scale-to-zero.
## TL;DR
```bash
# One-time: log in
npx wrangler login
# Every deploy
mcify deploy cloudflare
```
The CLI bundles your config for the Workers runtime, generates `dist/cloudflare/wrangler.toml`, then runs `wrangler deploy`. The result is a worker at `https://..workers.dev/mcp`.
## Prerequisites
* A Cloudflare account.
* Either:
* `CLOUDFLARE_API_TOKEN` set in your shell, **or**
* having run `npx wrangler login` once.
* `--account-id ` flag, or `CLOUDFLARE_ACCOUNT_ID` env var, if your token doesn’t have it embedded.
## Command
```bash
mcify deploy cloudflare [options]
```
| Flag | Default | What it does |
| -------------------------- | ------------------- | -------------------------------------------------------------------- |
| `--config ` | `./mcify.config.ts` | Path to your mcify config. |
| `--project-name ` | `config.name` | Worker name (also the subdomain). |
| `--account-id ` | from token | Cloudflare account id, only needed if the token doesn’t include it. |
| `--compatibility-date ` | `2026-01-01` | Workers compatibility date. |
| `--dry-run` | off | Generate `wrangler.toml` and the bundle, but skip `wrangler deploy`. |
## What gets generated
```plaintext
dist/cloudflare/
├── wrangler.toml # name, main, compatibility_date, nodejs_compat
└── worker.js # bundled runtime + your config
```
If `dist/cloudflare/wrangler.toml` already exists, mcify **does not overwrite it** — your edits (custom routes, KV bindings, secrets) survive every redeploy.
## Setting secrets
The runtime reads env vars at boot. On Workers those come from `wrangler secret`:
```bash
npx wrangler secret put MCIFY_AUTH_TOKEN # the runtime's bearer token
npx wrangler secret put KHIPU_API_KEY # whatever your tools need
```
For non-secret config you can put `[vars]` in `wrangler.toml`. Avoid that for anything sensitive — use `secret put`.
## Bundle size limits
Cloudflare Workers reject bundles larger than:
* **1 MB** on the free tier.
* **10 MB** on the paid plan.
mcify warns you before pushing if you cross 1 MB and errors out at 10 MB. If you hit it, options are:
1. Move heavy deps into runtime imports the bundler can tree-shake.
2. Switch to `mcify deploy fly` or `railway` — both run a real Node process with no size cap.
## Custom routes
After your first deploy, edit `wrangler.toml`:
```toml
routes = [
{ pattern = "mcp.example.com/*", zone_name = "example.com" }
]
```
Then `mcify deploy cloudflare` again. mcify won’t overwrite the file.
## CI/CD
Drop-in workflow: [.github/workflows-templates/deploy-cloudflare.yml](https://github.com/Lelemon-studio/mcify/blob/main/.github/workflows-templates/deploy-cloudflare.yml)
Required repo secrets:
* `CLOUDFLARE_API_TOKEN`
* `CLOUDFLARE_ACCOUNT_ID`
## Troubleshooting
**`Authentication error [code: 10000]`** — your `CLOUDFLARE_API_TOKEN` is missing the right permissions. The token needs at least: `Account.Workers Scripts:Edit` and `Account.Workers Routes:Edit`.
**`bundle is larger than 1 MB`** — see the bundle size section above.
**Worker boots but `/mcp` returns 500** — check `wrangler tail` while hitting the endpoint. Usually a missing secret (`MCIFY_AUTH_TOKEN`) or a tool that imports a Node-only module (use `nodejs_compat` or swap the dep).
**Cold start feels slow** — Workers cold start is \~1ms; if it feels slower it’s almost always your tool’s first outbound call (DNS, TLS to a third-party API).
# Deploy with Docker
> Build a multi-arch Docker image of your mcify server. Push to GHCR, ECR, or any registry.
## TL;DR
```bash
# Build the image
mcify deploy docker --tag your-org/your-mcp:v1
# Build and push
mcify deploy docker --tag ghcr.io/your-org/your-mcp:v1 --push
# Build a multi-arch image (Buildx required)
mcify deploy docker --platform linux/amd64,linux/arm64 \
--tag ghcr.io/your-org/your-mcp:v1 --push
```
The CLI bundles your config for Node, generates `Dockerfile.mcify`, then runs `docker build`. With `--push` it also runs `docker push`.
## Prerequisites
* `docker` on PATH (Docker Desktop, Docker Engine, or Buildx).
* For `--push`: registry credentials (`docker login `).
* For `--platform `: Docker Buildx + QEMU set up.
## Command
```bash
mcify deploy docker [options]
```
| Flag | Default | What it does |
| ------------------- | --------------------- | ------------------------------------------------------------------ |
| `--config ` | `./mcify.config.ts` | Path to your mcify config. |
| `--tag ` | `mcify-server:latest` | Image tag. Use a fully-qualified one (`ghcr.io/...`) when pushing. |
| `--platform ` | host arch | Comma-separated platforms, e.g. `linux/amd64,linux/arm64`. |
| `--port ` | `8888` | EXPOSEd port inside the image. |
| `--push` | off | `docker push` after build. |
| `--dry-run` | off | Generate Dockerfile + bundle, skip `docker build`. |
## What gets generated
```plaintext
.
├── Dockerfile.mcify # multi-stage Alpine, non-root, prod deps only
└── dist/ # compiled Node bundle (referenced by Dockerfile)
```
If `Dockerfile.mcify` already exists it is **left untouched**. Want a custom base image, extra apt packages, a healthcheck — edit it once and every redeploy honors your changes.
## Default image
```dockerfile
FROM node:20-alpine AS deps
# install prod deps from npm/pnpm/yarn lockfile
FROM node:20-alpine
USER node # non-root, uid 1000
COPY --from=deps /app/node_modules ./node_modules
COPY .
ENV NODE_ENV=production PORT=8888
EXPOSE 8888
CMD ["node", ""]
```
Two stages: deps install, then a clean runtime layer. Final image is typically <100 MB.
## Multi-arch builds
```bash
mcify deploy docker \
--platform linux/amd64,linux/arm64 \
--tag ghcr.io/you/your-mcp:v1 \
--push
```
Requires Buildx — Docker Desktop ships it; on Linux:
```bash
docker buildx create --use
docker run --privileged --rm tonistiigi/binfmt --install all
```
## Pushing to a registry
| Registry | Tag format | Login |
| ------------------------ | ------------------------------------------------------- | --------------------------------------------------------------------- |
| Docker Hub | `your-user/your-mcp:tag` | `docker login` |
| GHCR | `ghcr.io/your-org/your-mcp:tag` | `docker login ghcr.io` (PAT with `write:packages`) |
| AWS ECR | `.dkr.ecr..amazonaws.com/your-mcp:tag` | `aws ecr get-login-password ... \| docker login --password-stdin ...` |
| Google Artifact Registry | `-docker.pkg.dev///your-mcp:tag` | `gcloud auth configure-docker` |
## Running the image
```bash
docker run --rm -p 8888:8888 \
-e MCIFY_AUTH_TOKEN=... \
-e KHIPU_API_KEY=... \
ghcr.io/your-org/your-mcp:v1
```
Then `curl http://localhost:8888/mcp -X POST -H 'authorization: Bearer ...' ...`.
## CI/CD
Drop-in workflow: [.github/workflows-templates/deploy-docker.yml](https://github.com/Lelemon-studio/mcify/blob/main/.github/workflows-templates/deploy-docker.yml)
This template builds + pushes a multi-arch image to **GHCR** on every push to `main` and on every `v*` tag. **No extra secrets needed** — it uses the auto-provided `GITHUB_TOKEN`.
The image lands at `ghcr.io//:`. Tags include:
* `sha-` — every commit.
* `` — branch tip.
* `` — for git tags.
* `latest` — only on default branch.
## Troubleshooting
**`exec format error` when running on a different arch** — the image was built for the wrong platform. Pass `--platform linux/amd64` (or the right one) when building, or `--platform` when running.
**`unauthorized: authentication required`** — `docker login ` first. For GHCR, use a PAT with `write:packages` scope.
**Image works locally, fails on the host** — usually a missing env var. The Dockerfile inherits `process.env` from the runtime, so set secrets via `-e` (or compose / k8s).
**Image is too big** — check that you didn’t accidentally bundle dev deps. The default Dockerfile installs with `--prod` / `--omit=dev`, but if you wrote a custom one, double-check.
**Health check fails on Cloud Run / ECS** — point the platform’s health check at `GET /` (returns 200 from the runtime) on the port you EXPOSEd.
# Deploy to Fly.io
> Deploy your mcify server to Fly.io. Long-running Node, region pinning, default scl.
## TL;DR
```bash
# One-time: install flyctl + log in
curl -L https://fly.io/install.sh | sh
flyctl auth login
# First time on a new app
mcify deploy fly --launch
# Every deploy after that
mcify deploy fly
```
The CLI bundles your config for Node, generates `Dockerfile.mcify` + `fly.toml`, then runs `flyctl deploy`. Result is a Fly app at `https://.fly.dev/mcp`, scaling on demand.
## Prerequisites
* A Fly.io account.
* `flyctl` (or `fly`) on PATH. mcify accepts either name.
* `flyctl auth login` once.
## Command
```bash
mcify deploy fly [options]
```
| Flag | Default | What it does |
| ----------------- | ------------------- | -------------------------------------------------------------- |
| `--config ` | `./mcify.config.ts` | Path to your mcify config. |
| `--app ` | `config.name` | Fly app name. |
| `--region ` | `scl` (Santiago) | Primary region — change to `iad`, `cdg`, `nrt`, etc. |
| `--port ` | `8888` | Internal port the app listens on. |
| `--launch` | off | Run `flyctl launch` (first-time setup) instead of deploy. |
| `--dry-run` | off | Generate Dockerfile + fly.toml + bundle, skip `flyctl deploy`. |
## What gets generated
```plaintext
.
├── Dockerfile.mcify # multi-stage Alpine, non-root, prod deps only
├── fly.toml # app, primary_region, http_service, vm sizing
└── dist/ # compiled Node bundle (entry referenced by Dockerfile)
```
If either file already exists it is **left untouched** — your edits (custom build args, extra processes, autoscaling tweaks) survive.
## Default `fly.toml` settings
| Setting | Default | Why |
| ---------------------- | -------- | ------------------------------------------------------------------------------------- |
| `primary_region` | `scl` | Santiago — closest to Lelemon and most LATAM users. Override per project. |
| `auto_stop_machines` | `stop` | Scale to zero when idle, costs \~nothing. |
| `min_machines_running` | `0` | Same — pure on-demand. |
| `[[vm]]` cpu | shared 1 | Smallest tier. Bump to `dedicated` if you need predictable latency. |
| `memory_mb` | 256 | Enough for the runtime + Hono + your handlers. Bump if your tools hold lots of state. |
## Setting secrets
```bash
flyctl secrets set MCIFY_AUTH_TOKEN=...
flyctl secrets set KHIPU_API_KEY=...
```
Secrets are encrypted, mounted as env vars, and trigger a redeploy when changed.
## First-time vs subsequent deploys
The first time on a new app you need `flyctl launch` to create the app, attach a VM, and pick a region. mcify wraps that:
```bash
mcify deploy fly --launch
```
This calls `flyctl launch --copy-config --no-deploy` so the generated `fly.toml` is honored and no traffic flows yet. After that:
```bash
mcify deploy fly
```
is the normal redeploy.
## Custom regions
Override the default `scl`:
```bash
mcify deploy fly --launch --region iad # us-east
mcify deploy fly --launch --region cdg # paris
```
After the first launch, region edits go directly in `fly.toml`. Add secondary regions:
```toml
[[vm]]
cpus = 1
memory_mb = 256
region = "iad"
```
Then `flyctl deploy`.
## CI/CD
Drop-in workflow: [.github/workflows-templates/deploy-fly.yml](https://github.com/Lelemon-studio/mcify/blob/main/.github/workflows-templates/deploy-fly.yml)
Required repo secret:
* `FLY_API_TOKEN` — get one with `flyctl auth token`.
## Troubleshooting
**`Error: app not found`** — first-time setup wasn’t run. Use `--launch`.
**`Error: not authorized`** — `flyctl auth login`, or set `FLY_API_TOKEN` if you’re scripting.
**App boots, then exits with code 0** — you probably forgot to bind to `0.0.0.0:$PORT`. The runtime does this for you with `serveNode({ port })`, but if you wrote a custom entry, double-check.
**Heavy bundle** — Fly has no hard size cap; bigger images take longer to pull. The default Dockerfile uses Alpine + multi-stage so final images are typically <100 MB.
**Cold starts** — `auto_stop_machines = "stop"` saves money but the first request after idle takes \~1s. Set `min_machines_running = 1` to keep one warm.
# Deploy to Kubernetes (Helm)
> Install your mcify server on Kubernetes via the official Helm chart.
## TL;DR
```bash
# 1. Build + push your image (one time per release)
mcify deploy docker --tag ghcr.io/your-org/your-mcp:v1 --push
# 2. Install the chart pointing at that image
helm install my-mcp ./charts/mcify \
--set image.repository=ghcr.io/your-org/your-mcp \
--set image.tag=v1 \
--set secret.existing=my-mcp-secrets
# 3. Port-forward to verify locally
kubectl port-forward svc/my-mcp-mcify 8888:80
```
The Helm chart lives at [`charts/mcify/`](https://github.com/Lelemon-studio/mcify/tree/main/charts/mcify). It runs the same Node bundle as `mcify deploy docker`, wrapped in a Deployment + Service + (optional) Ingress + (optional) HPA.
## Prerequisites
* Kubernetes ≥ 1.27 (the chart uses `autoscaling/v2` and `networking.k8s.io/v1`).
* Helm 3.
* An image already pushed somewhere your cluster can pull from — see [docker.md](./docker.md).
* A Kubernetes Secret holding your runtime env vars (recommended for prod) — see “Secrets” below.
## Recommended values
my-values.yaml
```yaml
image:
repository: ghcr.io/your-org/your-mcp
tag: v1.2.3
pullPolicy: IfNotPresent
replicaCount: 2
env:
NODE_ENV: production
secret:
existing: my-mcp-secrets # pre-created Kubernetes Secret
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: mcp.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: mcp-example-com-tls
hosts:
- mcp.example.com
```
```bash
helm upgrade --install my-mcp ./charts/mcify -f my-values.yaml
```
## What ships in the chart
| Resource | Purpose |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `Deployment` | Runs the mcify container. Probes hit `GET /`. SecurityContext: non-root user 1000, drop ALL caps, read-only root + `/tmp` emptyDir. |
| `Service` | ClusterIP by default, switchable to LoadBalancer. Routes port 80 → container `http` port. |
| `Ingress` | Optional. `networking.k8s.io/v1`, supports TLS. |
| `ServiceAccount` | Optional. `automountServiceAccountToken: false` by default — the runtime doesn’t talk to the K8s API. |
| `HorizontalPodAutoscaler` | Optional. CPU + memory targets. |
| `Secret` | **Dev only.** For production use `secret.existing: ` and manage the secret with sops / external-secrets / Vault. |
## Secrets
Three modes via the `secret` block:
```yaml
# (a) Dev — chart creates the Secret with values you embed.
secret:
create: true
values:
MCIFY_AUTH_TOKEN: "dev-token"
# (b) Production — point at an existing Secret you manage elsewhere.
secret:
create: false
existing: my-mcp-secrets
# (c) None — no env Secret. Tools needing auth fail.
secret:
create: false
existing: ""
```
The Secret is mounted as env vars via `envFrom: secretRef` — every key on the Secret becomes a `process.env` entry inside the container.
## Probes
Both probes hit `GET /` — the runtime’s built-in health endpoint served by `createHttpApp`. Disable individually if your routing doesn’t include `/`:
```yaml
probes:
liveness:
enabled: false
```
## Local validation
```bash
helm lint ./charts/mcify
helm template ./charts/mcify -f my-values.yaml \
| kubectl apply --dry-run=client -f -
```
## CI/CD
There’s no Helm-specific workflow template — the typical pattern is:
1. Push image with [`deploy-docker.yml`](https://github.com/Lelemon-studio/mcify/blob/main/.github/workflows-templates/deploy-docker.yml) (multi-arch, GHCR).
2. From your deploy job (Argo CD, Flux, GitHub Actions with kubectl), `helm upgrade --install` pointing at the new tag.
If you want the Helm release tied to git tags, parameterize `image.tag` from `${{ github.ref_name }}`.
## Troubleshooting
**Pods crash-loop on startup** — `kubectl logs ` usually shows a missing env var. Check that your Secret is mounted (`kubectl describe pod ` → Mounts) and includes `MCIFY_AUTH_TOKEN`.
**Probes failing** — confirm the container actually listens on the port the chart expects (`service.port` → `containerPort`). The runtime uses `PORT` env var; the chart sets it for you.
**Read-only root filesystem errors** — your tool writes somewhere other than `/tmp`. Either make it write to `/tmp` (it’s mounted as emptyDir) or extend the chart’s `volumes` / `volumeMounts`.
**Ingress works but cert-manager doesn’t issue** — usually a missing HTTP-01 reachability path. Check that `mcp.example.com` resolves to the ingress controller’s IP and that port 80 is open.
**HPA doesn’t scale up under load** — verify the metrics-server is installed (`kubectl get apiservice v1beta1.metrics.k8s.io`). Without it, CPU/memory targets resolve to `` and the HPA stays at `minReplicas`.
# Deploy overview
> One CLI command per supported target. Pick the one that matches your infra.
import { LinkCard, CardGrid } from ‘@astrojs/starlight/components’;
mcify ships one CLI command per supported target. Pick whichever fits your infra:
| Target | Command | When to use it |
| ------------------ | ----------------------------- | ------------------------------------------------------- |
| Cloudflare Workers | `mcify deploy cloudflare` | Edge runtime, scale-to-zero, lowest cold start. |
| Vercel Edge | `mcify deploy vercel` | Already on Vercel, want preview deploys per branch. |
| Fly.io | `mcify deploy fly` | Real long-running Node, region pinning (default `scl`). |
| Railway | `mcify deploy railway` | Push and go, no Dockerfile to maintain. |
| Docker | `mcify deploy docker` | Self-host, ECS, Cloud Run, push to GHCR/ECR. |
| Kubernetes | `helm install ./charts/mcify` | Already on K8s — pair with `mcify deploy docker`. |
## The shape of every deploy
Every target follows the same three-step flow:
1. **Build** — `buildServer({ target })` for the right runtime (Workers, Node, etc.).
2. **Generate config** — `wrangler.toml` / `fly.toml` / `railway.json` / `vercel.json` / `Dockerfile.mcify`. If the file already exists it’s left alone — your edits survive.
3. **Invoke the target’s CLI** — `wrangler deploy`, `flyctl deploy`, `railway up`, `vercel deploy`, `docker build`. mcify shells out; it doesn’t re-implement the API.
Every command supports `--dry-run` so you can inspect what would be pushed without actually pushing.
## Per-target guides
## CI/CD
Drop-in workflow templates live at `.github/workflows-templates/` in the repo. Copy whichever target you use into your project’s `.github/workflows/` and fill in the secrets each one’s header lists.
## Choosing
If you don’t know:
* **Just want it running, free, fast** → Cloudflare Workers.
* **Need WebSocket / long-lived connections** → Fly or Railway.
* **Already on Vercel** → Vercel.
* **Already on K8s** → Docker → GHCR + Helm chart.
* **Self-host on a single VPS** → Docker.
Every target serves the same `POST /mcp` endpoint, so MCP clients don’t care which one you picked.
# Deploy to Railway
> Deploy your mcify server to Railway. Nixpacks build, no Dockerfile needed.
## TL;DR
```bash
# One-time: install + log in
npm i -g @railway/cli
railway login
railway link # pick or create the project + service
# Every deploy
mcify deploy railway
```
The CLI bundles your config for Node, generates `railway.json` with a Nixpacks plan (Node 20 + pnpm 9), then runs `railway up`. Railway builds + deploys the service.
## Prerequisites
* A Railway account.
* `railway` CLI on PATH (`npm i -g @railway/cli`).
* `railway login` once.
* The project linked once: `railway link`.
## Command
```bash
mcify deploy railway [options]
```
| Flag | Default | What it does |
| --------------------- | ------------------- | ---------------------------------------------------- |
| `--config ` | `./mcify.config.ts` | Path to your mcify config. |
| `--service ` | last linked | Specific service inside the project. |
| `--environment ` | `production` | Target environment. |
| `--port ` | `8888` | App port (passed as `PORT` env var). |
| `--dry-run` | off | Generate `railway.json` + bundle, skip `railway up`. |
## What gets generated
```plaintext
.
├── railway.json # Nixpacks plan, start command, healthcheck on /
└── dist/ # compiled Node bundle (referenced by railway.json)
```
If `railway.json` already exists it is **left untouched** — your edits stick.
## Default `railway.json`
```json
{
"build": {
"builder": "NIXPACKS",
"nixpacksPlan": {
"phases": {
"setup": { "nixPkgs": ["nodejs_20", "pnpm-9_x"] }
}
}
},
"deploy": {
"startCommand": "node dist/your-entry.js",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 3,
"healthcheckPath": "/",
"healthcheckTimeout": 30
}
}
```
The healthcheck hits `GET /` — the runtime’s built-in health endpoint. If you mount the MCP under a custom prefix and `/` is no longer served, point the healthcheck somewhere that returns 200.
## Setting secrets
```bash
railway variables set MCIFY_AUTH_TOKEN=...
railway variables set KHIPU_API_KEY=...
```
Or use the Railway dashboard. Variables are scoped per environment (production / preview / etc.).
## CI/CD
Drop-in workflow: [.github/workflows-templates/deploy-railway.yml](https://github.com/Lelemon-studio/mcify/blob/main/.github/workflows-templates/deploy-railway.yml)
Required repo secret:
* `RAILWAY_TOKEN` — generate from Railway → Account Settings → Tokens.
## Troubleshooting
**`Error: not logged in`** — run `railway login` (or set `RAILWAY_TOKEN` if scripting).
**`Error: no service linked`** — run `railway link` once in the project directory. If you have multiple services, pass `--service ` to `mcify deploy railway`.
**Build fails on missing pnpm** — the generated Nixpacks plan pins `pnpm-9_x`. If your repo uses npm or yarn, edit `railway.json` to swap the `nixPkgs` and the start command.
**Healthcheck failing** — Railway considers the service down if `GET /` doesn’t return 200 within 30s. Check `railway logs` for boot errors (usually a missing env var).
**No public URL** — Railway services are private by default. Click “Generate Domain” in the dashboard, or `railway domain` from the CLI.
# Deploy to Vercel Edge
> Deploy your mcify server to Vercel Edge Functions, with preview deploys per branch.
## TL;DR
```bash
# One-time: log in + link the project
npx vercel login
npx vercel link
# Preview deploy
mcify deploy vercel
# Production deploy
mcify deploy vercel --prod
```
The CLI bundles your config as a Vercel Edge Function, generates `api/mcp.mjs` + `vercel.json`, then runs `vercel deploy`. Result: a preview URL (or your prod domain) with `/mcp` wired up.
## Prerequisites
* A Vercel account.
* Either `npx vercel login` once on your machine, or `VERCEL_TOKEN` set in your shell.
* The project linked once: `npx vercel link`.
## Command
```bash
mcify deploy vercel [options]
```
| Flag | Default | What it does |
| ----------------- | ------------------- | ---------------------------------------------------------------------- |
| `--config ` | `./mcify.config.ts` | Path to your mcify config. |
| `--prod` | off (preview) | Promote to production instead of a preview deploy. |
| `--project ` | from `vercel link` | Project name override. |
| `--dry-run` | off | Generate `api/mcp.mjs` + `vercel.json` + bundle, skip `vercel deploy`. |
## What gets generated
```plaintext
.
├── api/
│ └── mcp.mjs # re-exports the bundled edge function
├── dist/vercel/
│ └── (bundle)
└── vercel.json # rewrite /(.*) → /api/mcp
```
If `vercel.json` already exists it is **left untouched** — your custom routing wins.
## Setting secrets
Set them in the Vercel dashboard (Project → Settings → Environment Variables) **or** via CLI:
```bash
npx vercel env add MCIFY_AUTH_TOKEN production
npx vercel env add KHIPU_API_KEY production
```
The runtime reads them at boot through `process.env`.
## Bundle size limits
Vercel Edge Functions cap at **4 MB compressed**. mcify warns you proactively if you cross that. If you hit it, you can:
1. Switch to Vercel’s serverless Node target (not yet first-class in mcify — open an issue if you need it).
2. Move to `mcify deploy fly` or `railway` for a non-edge runtime.
## Preview deploys per branch
Vercel automatically deploys a preview for every push when the project is linked. The included CI template runs `mcify deploy vercel --prod` on `main`; for previews you can drop `--prod` or just push to a branch and let Vercel’s GitHub integration handle it.
## CI/CD
Drop-in workflow: [.github/workflows-templates/deploy-vercel.yml](https://github.com/Lelemon-studio/mcify/blob/main/.github/workflows-templates/deploy-vercel.yml)
Required repo secrets:
* `VERCEL_TOKEN`
* `VERCEL_ORG_ID`
* `VERCEL_PROJECT_ID`
Get the org/project ids with `npx vercel link` then read `.vercel/project.json`.
## Troubleshooting
**`Error: No existing credentials`** — run `npx vercel login` or set `VERCEL_TOKEN`.
**`Error: Project not linked`** — run `npx vercel link` once in your project root.
**`/mcp` returns 404** — check that `vercel.json` has the rewrite. If you customized it, make sure `/(.*)` (or at least `/mcp`) routes to `/api/mcp`.
**`Function exceeded 50 MB`** — that’s the deploy artifact cap (not the runtime cap). Trim dev dependencies you accidentally bundled.
**Edge runtime complains about `process.env`** — Vercel’s edge runtime exposes a limited subset of Node APIs. mcify’s bundle uses Web-standard Request/Response, so `process.env` works for env vars but Node-only modules (e.g. `fs`) won’t load. Move that work to a tool that runs at request time only (no top-level `import 'fs'`).
# From OpenAPI / microservices
> Generate Zod-typed tools from one or more OpenAPI specs. Single command, multi-spec for microservice fleets.
`mcify generate from-openapi` reads an OpenAPI 3.x spec (JSON or YAML, URL or local file) and emits a TypeScript file with one `defineTool` per operation, ready to wire into your `mcify.config.ts`.
## Single spec
```bash
mcify generate from-openapi ./openapi.yaml
mcify generate from-openapi https://api.example.com/openapi.json
```
The command writes `src/generated/.ts` where `` is derived from the spec’s `info.title`. The file contains:
* `create__client(opts)` — fetch wrapper with auth detection and base URL from `servers[0]`.
* One `defineTool(...)` per operation, with input schemas built from `parameters` + JSON request bodies, output schemas from the first 2xx response.
* Component schemas (`#/components/schemas/*`) hoisted as Zod consts.
* `_tools(client)` factory returning the array of tools — drop into `tools[]` in `mcify.config.ts`.
## Multi-spec (microservices)
This is the headline use case for service fleets. Repeat `--spec` to combine N services into one MCP server with namespaced tool names:
```bash
mcify generate from-openapi \
--spec users=https://api.tuempresa.com/users/openapi.json \
--spec billing=https://api.tuempresa.com/billing/openapi.json \
--spec inventory=./inventory.yaml
```
Output:
```plaintext
src/generated/
├── users.ts # users_create_user, users_list_users, ...
├── billing.ts # billing_emit_invoice, billing_list_invoices, ...
└── inventory.ts # inventory_check_stock, inventory_list_skus, ...
```
Wire all three into `mcify.config.ts`:
```ts
import { bearer, defineConfig } from '@mcify/core';
import { create_users_client, users_tools } from './src/generated/users.js';
import { create_billing_client, billing_tools } from './src/generated/billing.js';
import { create_inventory_client, inventory_tools } from './src/generated/inventory.js';
const usersClient = create_users_client({ token: process.env.USERS_API_TOKEN });
const billingClient = create_billing_client({ token: process.env.BILLING_API_TOKEN });
const inventoryClient = create_inventory_client({ token: process.env.INVENTORY_API_TOKEN });
export default defineConfig({
name: 'company-aggregator',
version: '0.1.0',
auth: bearer({ env: 'MCIFY_AUTH_TOKEN' }),
tools: [
...users_tools(usersClient),
...billing_tools(billingClient),
...inventory_tools(inventoryClient),
],
});
```
The agent sees one unified catalog of (e.g.) 47 tools with stable, prefixed names. You deploy one binary.
## What gets mapped
| OpenAPI | Zod | Notes |
| ------------------------------------------------------- | ------------------------------ | ------------------------------------------------- |
| `type: string`, format `email`/`uri`/`uuid`/`date-time` | `z.string().email()` etc. | Format hints honored |
| `type: integer` with `minimum`/`maximum` | `z.number().int().min().max()` | |
| `type: boolean` | `z.boolean()` | |
| `type: array`, `items` | `z.array(...)` | |
| `type: object` with `properties`, `required` | `z.object({...})` | Optional fields get `.optional()` |
| `enum` | `z.enum([...])` | String enums; mixed-type → `z.union(z.literal())` |
| `oneOf` / `anyOf` | `z.union([...])` | |
| `allOf` | `z.intersection(...)` | Folded for >2 parts |
| `nullable: true` | `.nullable()` | OpenAPI 3.0 |
| `additionalProperties: ` | `z.record(...)` | When no fixed `properties` |
| `$ref: '#/components/schemas/X'` | identifier | Hoisted as a top-level Zod const |
Anything the generator can’t model emits `z.unknown()` with a `// TODO` comment. Open an issue if you hit one — most are easy to add.
## Auth detection
The generator reads the spec’s `securitySchemes` and configures the client:
| OpenAPI | Generated client behavior |
| -------------------------- | ------------------------------------------- |
| `http`, `scheme: bearer` | `Authorization: Bearer ${opts.token}` |
| `http`, `scheme: basic` | `Authorization: Basic ${opts.token}` |
| `apiKey`, `in: header` | The configured header name → `opts.token` |
| `apiKey`, `in: query` | TODO comment — append to URL manually |
| `oauth2` / `openIdConnect` | TODO comment — wire your own token issuance |
Pass `token` when constructing the client; the generated code mutates a per-request copy of headers (never the caller’s object).
## Common workflows
### Regenerate when the spec changes
The output is deterministic. Re-run the generator and diff:
```bash
mcify generate from-openapi --spec users=https://...
git diff src/generated/users.ts
```
Anything in `src/generated/` is fine to commit — it’s reviewable and gives you a real diff when the upstream changes.
### Hand-edit a generated tool’s description
OpenAPI summaries are often vague. The generator copies them verbatim, but you can edit the descriptions in `src/generated/.ts` directly. They survive regeneration only if the source spec doesn’t change the operation; otherwise you’re back to the upstream description (or you put your edits in a separate wrapper tool that calls the generated one).
The cleaner pattern: write a thin wrapper in `src/tools/` that calls the generated client directly and exposes a tool with your description:
src/tools/users-find-by-email.ts
```ts
import { defineTool } from '@mcify/core';
import { z } from 'zod';
import { create_users_client } from '../generated/users.js';
const client = create_users_client({ token: process.env.USERS_API_TOKEN });
export const usersFindByEmail = defineTool({
name: 'users_find_by_email',
description: 'Find a single user by exact email match. Returns null if not found.',
input: z.object({ email: z.string().email() }),
output: z.object({ id: z.string(), email: z.string(), fullName: z.string() }).nullable(),
handler: async ({ email }) => {
const result = await client.request({
method: 'GET',
url: `/users?email=${encodeURIComponent(email)}`,
headers: { accept: 'application/json' },
});
const list = result as { users: Array<{ id: string; email: string; fullName: string }> };
return list.users[0] ?? null;
},
});
```
That tool isn’t generated and won’t be overwritten; it composes the generated client.
## Limits
* The generator skips `deprecated: true` operations.
* Non-JSON request bodies (multipart, form-encoded) emit a `// TODO` comment in the handler.
* It reads only `servers[0]` for the base URL.
* It does not generate documentation pages — only the TypeScript file. Pair with the [Wrap-an-API prompt](/prompts/wrap-api/) if you want a full connector with README and tests.
## See also
* [AI prompt → Wrap an API](/prompts/wrap-api/) — when you want a hand-tuned connector instead of generated tools.
* [AI prompt → Migrate to multi-spec](/prompts/migrate-multispec/) — adding more services to an existing server.
# Observability and logging
> Event bus + Pino logger for production visibility.
The runtime emits structured events for every tool call, resource read, prompt render, and config reload. You can observe them in three places:
1. **The inspector** — calls log + event stream when running `mcify dev`.
2. **Programmatic event bus subscribers** — register a listener for production telemetry.
3. **Pino logger** — opt-in JSON logger compatible with BetterStack, Datadog, Logtail, etc.
## The event bus
Every runtime ships an `EventBus` instance. Subscribe to it from `defineConfig`:
```ts
import { EventBus, defineConfig } from '@mcify/core';
const bus = new EventBus();
bus.on((event) => {
if (event.type === 'tool:called') {
console.log(event.toolName, event.durationMs, event.error ?? 'ok');
}
});
export default defineConfig({
...,
eventBus: bus,
});
```
Events emitted:
| Type | When | Payload |
| ----------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `tool:called` | After every `tools/call` (success or failure) | `toolName`, `args`, `result` or `error`, `durationMs` |
| `resource:read` | After every `resources/read` | `uri`, `params`, `durationMs`, `error?` |
| `prompt:rendered` | After every `prompts/get` | `promptName`, `args`, `durationMs`, `error?` |
| `config:loaded` | When the config reloads (`mcify dev` hot reload, programmatic `setConfig`) | `serverName`, `serverVersion`, `toolCount`, `resourceCount`, `promptCount` |
The bus is in-memory and per-process. For distributed deployments, fan out from a single subscriber to your queue / log aggregator.
## Pino logger
By default the runtime uses a console-based JSON logger that’s safe for stdio transports and Workers. For production Node deploys, opt in to Pino:
```ts
import { createPinoLogger } from '@mcify/runtime';
import pinoLogtail from '@logtail/pino';
const logger = createPinoLogger({
level: 'info',
bindings: { service: 'khipu-mcp', env: process.env.NODE_ENV },
// Pipe to BetterStack / Logtail.
pino: {
transport: {
target: '@logtail/pino',
options: { sourceToken: process.env.LOGTAIL_TOKEN },
},
},
});
defineConfig({
...,
logger,
});
```
Inside a handler, use `ctx.logger`:
```ts
handler: async (input, ctx) => {
ctx.logger.info('khipu_payment_requested', { amount: input.amount, currency: input.currency });
const res = await ctx.fetch(...);
if (!res.ok) {
ctx.logger.warn('khipu_upstream_error', { status: res.status });
throw new Error('Khipu request failed');
}
return ...;
},
```
The `bindings` are static fields attached to every line. The runtime adds `tool`, `requestId`, and request metadata automatically.
## Why the indirection?
Two reasons we don’t just expose Pino directly:
1. **Workers compatibility.** Pino’s stream transport doesn’t run on edge workers. The default `createConsoleLogger` does. Opt-in keeps the runtime importable everywhere.
2. **Test injection.** `createTestClient` defaults to a no-op logger so test output isn’t noisy. You can pass `logger: createConsoleLogger({ level: 'debug' })` when you want chatty tests.
## Connecting to BetterStack
```ts
const logger = createPinoLogger({
level: 'info',
bindings: { service: 'khipu-mcp' },
pino: {
transport: {
target: '@logtail/pino',
options: { sourceToken: process.env.BETTERSTACK_TOKEN },
},
},
});
```
Add `@logtail/pino` to your deps (`pnpm add @logtail/pino`). Set `BETTERSTACK_TOKEN` as a secret on your deploy target. Done.
The default Pino schema works with BetterStack’s “JSON Lines” parser without configuration.
# Testing without the network
> Use createTestClient + a mocked fetch to verify tools without hitting upstream APIs.
The runtime ships a test helper that exercises the same dispatch path as production HTTP — but in-process and with `fetch` you can mock.
## The pattern
```ts
import { describe, it, expect, vi } from 'vitest';
import { createTestClient } from '@mcify/runtime/test';
import config from '../mcify.config.js';
describe('khipu_create_payment', () => {
it('returns the upstream payment URL', async () => {
const fetchMock = vi
.fn()
.mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({ payment_id: 'p_abc', payment_url: 'https://khipu.com/pay/abc' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
),
);
const client = createTestClient(config, {
auth: { type: 'bearer', token: 'test' },
fetch: fetchMock,
});
const result = await client.callTool('khipu_create_payment', {
subject: 'Order #1',
currency: 'CLP',
amount: 50000,
});
expect(result.paymentId).toBe('p_abc');
expect(fetchMock).toHaveBeenCalledOnce();
});
});
```
`createTestClient` wires:
* The auth state your handler sees (`ctx.auth`).
* The `fetch` your handler calls via `ctx.fetch`.
* The same input/output validation, middleware chain, and error mapping as the HTTP path.
## Why `mockImplementation` and not `mockResolvedValue`
`Response.text()` (and `.json()`) can be read **once**. If the same Response object comes back from a mock that reuses a single instance, the second test call gets an empty body. Use `mockImplementation` so each call constructs a fresh Response:
```ts
// Bad — second call reads an empty body
vi.fn().mockResolvedValue(ok({ ... }));
// Good — fresh Response each call
vi.fn().mockImplementation(() => Promise.resolve(ok({ ... })));
```
This is the most common gotcha when porting tests from other frameworks.
## Asserting on the request
The mock captures every call. You can check the URL, method, and body:
```ts
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('https://payment-api.khipu.com/v3/payments');
expect((init as RequestInit).method).toBe('POST');
const body = JSON.parse((init as RequestInit).body as string);
expect(body.subject).toBe('Order #1');
```
## Errors
When the mocked upstream returns non-2xx, your handler should throw. The runtime wraps the thrown error into the MCP `CallToolResult` shape; from the test’s perspective `client.callTool` rejects:
```ts
fetchMock.mockImplementation(() =>
Promise.resolve(new Response('{"error":"Invalid"}', { status: 400 })),
);
await expect(
client.callTool('khipu_create_payment', { ... }),
).rejects.toThrow(/Invalid/);
```
## When to mock vs hit the network
| Mock the upstream | Hit a sandbox |
| ------------------------------------- | ------------------------------------------- |
| Unit tests (per-tool, per-error-path) | Integration test that runs once per release |
| Handler logic (mapping, branching) | Auth flow with real signatures |
| CI runs (no network credentials) | Pre-deploy smoke check |
The connector packages in `packages/examples/*` follow this split: every commit’s tests are mocked; the dogfooding loop in `lelemon-app` exercises real Khipu sandbox calls before ship.
# mcify
> Wrap any API as an MCP server in minutes. CLI-first. Type-safe end-to-end. Deploy to the edge with one command.
## What you can build
A connector to a payment API
Wrap Khipu / Stripe / your in-house billing as MCP tools any agent (Claude, Cursor, Lelemon Agentes) can call.
A microservice gateway for agents
`mcify generate from-openapi --spec users=... --spec billing=...` — combines N services into one MCP server with namespaced tools.
Tools your agent runs autonomously
Bearer auth + per-tool rate limits + timeouts. Your agent can call them in production loops, not just chat.
Edge-deployed in one command
`mcify deploy cloudflare` (or vercel / fly / railway / docker). Workers cold start in \~1ms.
## Use mcify with AI
These docs are built so AI assistants can read them. Three things to know:
1. **`docs.mcify.dev/llms.txt`** — index of every page, formatted for LLM ingestion. Paste the URL into Claude / ChatGPT / Cursor and it can navigate the whole site.
2. **`docs.mcify.dev/llms-full.txt`** — every page inlined into a single file, \~50–80KB. Paste the URL and the model has the entire docs in context.
3. **[AI prompts](/prompts/how-to-use/)** — copy-paste prompts for Claude Code, Cursor, Windsurf to add a tool, wrap an API, or debug your config.
[Add a tool to my server (Claude Code prompt) ](/prompts/add-tool/)Copy-paste prompt that walks Claude through the canonical pattern with all middleware wiring.
## Quick start
```bash
npx @mcify/cli@alpha init my-mcp
cd my-mcp
pnpm install
pnpm dev
```
Inspector at , MCP endpoint at `http://localhost:8888/mcp`.
[Connect from Claude Desktop or Cursor →](/start/connect-clients/)
# Add a tool to my server
> Copy-paste prompt that walks Claude / Cursor through scaffolding a new mcify tool with the canonical pattern.
Use this when you have an existing mcify project and want to add one new tool that wraps an API endpoint or a local operation.
## Prompt
````markdown
You are helping me add a new tool to my mcify MCP server. Follow the
project's conventions exactly — don't invent shortcuts.
Read these docs first:
- https://docs.mcify.dev/llms-full.txt
- https://docs.mcify.dev/concepts/tools/
- https://docs.mcify.dev/guides/creating-effective-tools/
- https://docs.mcify.dev/guides/antipatterns/
What I want the tool to do:
<<<
REPLACE THIS BLOCK with a plain-language description of the API call
or operation you want exposed. Include:
- The upstream API URL and HTTP method (if any).
- The auth model upstream (API key in header? bearer? OAuth?).
- The shape of the input the agent will pass.
- The shape of what gets returned.
- Any side effects (sends an email, writes audit log, charges money).
> > >
Now, end-to-end:
1. Pick a tool name — snake*case, prefixed by the service domain
(`*\_`).
2. Create `src/tools/-.ts` with `defineTool` from
`@mcify/core`. Use the canonical structure:
```ts
import { defineTool } from '@mcify/core';
import { rateLimit, requireAuth, withTimeout } from '@mcify/core/middleware';
import { z } from 'zod';
export const = defineTool({
name: '__',
description: '',
middlewares: [
requireAuth(),
rateLimit({ max: , windowMs: 60_000 }),
withTimeout({ ms: }),
],
input: z.object({ /* fields with .describe() each */ }),
output: z.object({ /* fields with .describe() each */ }),
handler: async (input, ctx) => {
const res = await ctx.fetch('', { /* ... */ });
return /* parse to the output shape */;
},
});
```
3. Use `ctx.fetch` (NOT `globalThis.fetch`) so tests can swap it.
4. Wire the tool into `mcify.config.ts` — import + add to `tools[]`.
Don't touch the rest of the config.
5. Add a unit test at `src/tools/-.test.ts` using
`createTestClient` from `@mcify/runtime/test`. The test should mock
`fetch` against a fixed JSON response and assert the returned
object's shape, not the internal call sequence.
6. Run `pnpm typecheck && pnpm test && pnpm lint`. Fix anything that
fails. Don't disable rules to make warnings go away.
Conventions to honor:
- Every input field gets a `.describe()` with format hints (e.g.
"ISO 8601 in UTC", "amount in CLP, no decimals").
- Don't return `string`. Return a structured object.
- Errors should be actionable — include what the agent should do next
(e.g. "Use users_list to find the right id").
- See https://docs.mcify.dev/guides/antipatterns/ before naming things.
When you're done, summarize:
- Tool name + signature (input → output).
- Where it goes in mcify.config.ts.
- The test command that should now pass.
````
## How to use it
1. Copy the entire block above (including the triple backticks if you’re saving it as a slash command).
2. Replace the `<<<...>>>` block with your specific request.
3. Paste into Claude Code, Cursor, or Claude.ai chat.
For Claude Code, save it as `.claude/commands/add-mcp-tool.md` and trigger with `/add-mcp-tool` followed by your request.
## Example invocations
> Add a tool that calls `POST https://api.stripe.com/v1/customers` to create a Stripe customer. Auth is `Authorization: Bearer ${STRIPE_SECRET}`. Input is `email`, optional `name`, optional `phone`. Returns the new customer id and creation timestamp. Side effect: visible in the Stripe dashboard. Service prefix: `stripe`.
> Add a tool that reads from our local Postgres `SELECT * FROM users WHERE id = $1`. No upstream auth (uses the `pg` pool we already have on `ctx.deps`). Input is one UUID. Output: id, email, created\_at. No side effects. Service prefix: `users`.
The prompt will produce different code for each, but with the same shape and the same middleware stack.
# Debug a misbehaving tool
> Copy-paste prompt that finds the bug — schema mismatch, auth, timeout, or side effect.
Use this when an agent is calling your tool and something’s wrong: the response is empty, args are validating but the upstream rejects them, the call times out, or the agent is picking the wrong tool entirely.
## Prompt
```markdown
You are debugging a misbehaving mcify MCP tool. Be methodical.
Read these docs first:
- https://docs.mcify.dev/llms-full.txt
- https://docs.mcify.dev/concepts/tools/
- https://docs.mcify.dev/guides/antipatterns/
- https://docs.mcify.dev/reference/runtime/#errors
What I'm seeing:
<<<
REPLACE THIS BLOCK with:
- The tool name.
- What I expected to happen.
- What actually happened (paste the error, the agent transcript, or
describe the behavior).
- The relevant calls log entries from the inspector if you have them.
- The minimal reproduction (args that trigger the bug).
> > >
Step through this checklist in order. Stop at the first one that
matches and fix it. Don't bulk-rewrite the tool.
1. **Schema mismatch — input.**
- Did the args fail validation? The runtime throws `McifyValidationError`
with `phase: 'input'` and the offending field. Read the `issues[]`
array carefully — Zod tells you exactly what was wrong.
- Common cause: too-strict `regex`, wrong `enum` values, missing
`.optional()` on a field that the upstream marks optional.
2. **Schema mismatch — output.**
- The handler returned, but the runtime threw `McifyValidationError`
with `phase: 'output'`. Your output schema doesn't match the upstream
response. Either widen the schema or map the response before returning.
3. **Auth — server level.**
- 401 from `/mcp`? The agent's bearer token doesn't match
`MCIFY_AUTH_TOKEN`. Check the env var on the deployed instance.
- 401 inside the handler? Your upstream API key is wrong/missing.
Check `process.env._API_KEY`.
4. **Auth — per-tool middleware.**
- 403? `requireAuth({ check: ... })` rejected the scope. Check
`ctx.auth.claims.scopes` against what your `check` predicate
wants.
5. **Rate limit.**
- 429? `rateLimit` is buckets-per-token; if the agent burst, it
trips. For development, raise the limits or remove the middleware
temporarily.
6. **Timeout.**
- Handler hung past `withTimeout({ ms })`? Either raise the timeout
or add upstream pagination/streaming so each call returns faster.
7. **Description / naming bug.**
- The agent picked the wrong tool, or didn't pick yours when it
should have. Fix the description (what it does, _when_ to use it).
See https://docs.mcify.dev/guides/creating-effective-tools/ for
the format.
8. **The agent is passing wrong args repeatedly.**
- A field's `.describe()` is missing or unclear. Add format hints
("UUID, e.g. ...", "ISO 8601 in UTC", "amount in CLP").
- Or the schema has overlap (two enums with similar names; a
`string` that should be an `enum`).
9. **Side effect happening unexpectedly.**
- Document the side effect in the tool's description. Then optionally
add a `dry_run: boolean` input the agent can pass to preview.
10. **Nothing above matches.**
- Reproduce locally with `mcify dev` + the inspector. Switch to the
Calls Log tab; click the failing call; the detail panel shows
raw args, response, and stack trace.
When you've found the bug:
- Output a one-paragraph diagnosis.
- Show the diff (git-style: `-` removed, `+` added).
- Update the relevant test to cover the case so it can't regress.
- Don't speculate — only ship a change you can demonstrate fixes the
bug under reproduction.
```
## How to use
Copy the prompt, replace the `<<<...>>>` block with the symptoms, paste into Claude Code or Cursor with the project open.
The 10-step checklist is ordered by frequency: schema mismatches account for \~60% of MCP tool bugs, auth is \~20%, the rest is descriptions/timing/side-effects. The model should land on the right diagnosis in steps 1–5 most of the time.
## Faster path: ask the inspector first
Before invoking this prompt, open the [calls log in the inspector](/concepts/tools/#anatomy). Click the failing call. The detail panel shows:
* **ARGS**: the exact args the agent sent.
* **RESULT**: the exact response (or the error and its `phase`).
* **DURATION**: useful for diagnosing timeouts.
* **STACK**: when the handler threw.
Most bugs visible to a human are visible in that panel without needing the full prompt.
# Migrate to multi-spec
> Add N microservices behind a single MCP server using the from-openapi generator.
Use this when you already have one mcify server and want to fold in additional services (microservices, vendor APIs, internal tools) without spinning up another MCP server per service.
## Prompt
````markdown
You are migrating my single-service mcify server into a multi-spec
setup that combines several APIs behind one MCP endpoint.
Read these docs first:
- https://docs.mcify.dev/llms-full.txt
- https://docs.mcify.dev/guides/from-openapi/
- https://docs.mcify.dev/guides/creating-effective-tools/
The new services I want behind the same MCP server:
<<<
REPLACE this with one entry per service:
- Prefix (becomes the tool name prefix, e.g. "users", "billing").
- OpenAPI spec source (URL or local path).
- Auth model (bearer / API key in header / API key in query / none).
- Any operations to skip (sometimes you don't want every endpoint).
> > >
Execute:
1. **Run the generator** for each spec:
```bash
pnpm exec mcify generate from-openapi \
--spec = \
--spec =
```
That writes `src/generated/.ts` per service with all the
`defineTool` calls.
2. **Wire each generated bundle into `mcify.config.ts`.** For every
spec, import its `_tools` factory and its
`create__client`, then mix into `tools[]`:
```ts
import { users_tools, create_users_client } from './src/generated/users.js';
import { billing_tools, create_billing_client } from './src/generated/billing.js';
const usersClient = create_users_client({ token: process.env.USERS_API_TOKEN });
const billingClient = create_billing_client({ token: process.env.BILLING_API_TOKEN });
export default defineConfig({
name: 'my-aggregator',
version: '0.1.0',
auth: bearer({ env: 'MCIFY_AUTH_TOKEN' }),
tools: [...existingTools, ...users_tools(usersClient), ...billing_tools(billingClient)],
});
```
3. **Audit the generated tool descriptions.** The generator copies the
OpenAPI `summary` + `description`. If those upstream descriptions
are vague, edit them — agents will read them. The generator wrote
normal `defineTool` calls, so you can hand-edit any of them.
4. **Add per-service env vars to your deploy.** Each `create__client`
takes a `token`. Wire each one to its env var:
- Cloudflare Workers: `wrangler secret put USERS_API_TOKEN`
- Fly: `flyctl secrets set USERS_API_TOKEN=...`
- Railway: `railway variables set USERS_API_TOKEN=...`
- Docker: `-e USERS_API_TOKEN=...`
5. **Restart `mcify dev`** to pick up the new tools. Open the inspector;
the Tools tab now shows the unified catalog with prefixed names
(`users_*`, `billing_*`, plus your existing tools).
6. **Test from the inspector's Chat tab.** Ask the model to do something
that requires combining services ("list the user with email X and
then find their open invoices"). It should chain calls across the
prefixes.
7. **Update CI / deploy config.**
- The generated files go in git (you can commit them; they're
deterministic).
- Add a `pnpm exec mcify generate from-openapi ...` step to your
CI if you want fresh tools on every spec change.
- Re-run `pnpm typecheck` and `pnpm test` — both should still pass.
Conventions to honor:
- Don't disable lint rules in generated files.
- Don't manually edit `src/generated/*.ts` — re-run the generator
instead. If the generator output is wrong, fix the source spec or
the generator (PR welcome).
- Keep upstream API tokens in env vars, not in source.
When done, summarize:
- The list of tools now exposed (per prefix).
- The env vars the deployed instance needs.
- Whether any operations were skipped and why.
````
## When this is the right move
* You’re running 2+ MCP servers and the agent has to register each separately. Consolidating saves config + auth surface area.
* A team owns multiple internal services and you want one tool catalog per team, not per service.
* You want to ship a single deployable artifact (one Cloudflare Worker, one Fly app) instead of N.
## When *not* to consolidate
* The services have wildly different SLAs (one is 200ms, one is 5s). Mixing them means the slow service drags down concurrency for the fast one.
* The auth models conflict (one needs a per-user link token, the other is a static org key). Keep separate.
* The services are owned by different teams with separate deploy cadences. Coupling their MCP surface area makes one team’s outage block the other.
If any of those apply, run multiple mcify servers and let the agent register both.
# Wrap an API as MCP
> Copy-paste prompt for turning an existing REST/GraphQL API into a full mcify connector.
Use this when you have a service (yours or a third-party’s) and want a complete MCP connector — multiple tools, an auth model, tests, and a deploy story — not just a single tool.
## Prompt
````markdown
You are building a full mcify MCP connector that wraps an existing API.
Follow project conventions; don't invent shortcuts.
Read these docs first:
- https://docs.mcify.dev/llms-full.txt
- https://docs.mcify.dev/concepts/tools/
- https://docs.mcify.dev/concepts/auth/
- https://docs.mcify.dev/guides/creating-effective-tools/
- https://docs.mcify.dev/guides/antipatterns/
The API I want to wrap:
<<<
REPLACE this with:
- API name + a one-line description.
- Base URL.
- Auth: header name and shape (API key? bearer?) + how a user gets it.
- The 3–7 endpoints I want exposed as MCP tools, with method, path, and a
one-line summary each.
- Any URL or filename for an OpenAPI spec, if you have one.
> > >
Plan, then execute:
1. **Plan the connector layout.** Output a short tree like this:
```
packages/example-/
├── src/
│ ├── client.ts
│ ├── client.test.ts
│ └── tools/
│ ├── -.ts
│ └── ...
├── mcify.config.ts
├── package.json
├── tsconfig.json
└── README.md
```
Confirm with me before writing files.
2. **`src/client.ts`** — a small typed REST wrapper.
- Class `Client` with a constructor that accepts
`{ apiKey, baseUrl?, fetch? }`.
- One typed method per tool you'll expose.
- A `ApiError` class extending `Error` with `status` and `body`.
- Use the spread-conditional pattern for optional fields, NOT
`if (x) obj.x = ...`.
- Inject `fetch` so tests can mock.
- No SDK dependency — keep the bundle tight.
3. **`src/client.test.ts`** — vitest with `fetch` mocked via
`vi.fn().mockImplementation(() => Promise.resolve(ok(...)))`.
- Use `mockImplementation`, NOT `mockResolvedValue`, because
Response.text() can only be read once and tests reuse the mock
across calls.
- Cover happy path and the error wrapper for each method.
4. **One file per tool in `src/tools/`** — `defineTool` with:
- Snake-case name prefixed by the service.
- Description: "what it does. when to use it."
- Middlewares: `requireAuth`, `rateLimit` (lower for writes,
higher for reads), `withTimeout` (5–15s).
- Per-field `.describe()` on every input.
- Output: structured object (no `z.string()` outputs).
5. **`mcify.config.ts`** — `defineConfig` wiring all tools, with
`auth: bearer({ env: 'MCIFY_AUTH_TOKEN' })`. The upstream API key
stays on the server (`_API_KEY` env), the bearer token
gates the agent.
6. **`package.json`** — `@mcify/core` as a dependency, `@mcify/runtime`
in devDependencies, `zod` as a dependency, `private: true`. Scripts:
`build`, `dev`, `test`, `typecheck`, `clean`.
7. **`tsconfig.json`** — extends `../../../tsconfig.base.json`, includes
`src/**/*` and `mcify.config.ts`, excludes `**/*.test.ts`.
8. **`README.md`** — bilingual is bonus (EN + `README.es.md` if it's a
LATAM API). Include:
- What the connector does + table of tools.
- "Run locally" with env vars.
- "Connect from Claude Desktop" snippet.
- Disclaimer that the connector isn't affiliated with the upstream.
Conventions to honor:
- TypeScript strict, ES modules, Node ≥ 20.
- No silent catches. Wrap errors with context.
- Don't disable lint rules. Fix the underlying code.
- `pnpm typecheck && pnpm test && pnpm lint` must pass.
When done, summarize:
- The path to the new package.
- The list of tools you exposed.
- The `pnpm install` + `pnpm dev` commands the user needs to run.
````
## How to use
Pick one of these starting points and replace the `<<<...>>>` block:
**You have an OpenAPI spec.** Skip this prompt and use the [from-openapi generator](/guides/from-openapi/) instead — it produces the same shape automatically. Come back to this prompt only if you want to hand-tune the descriptions afterward.
**You don’t have a spec, just docs.** Paste the API’s URL, the auth model, and a list of “I want a tool that does X” lines. The assistant will read the API docs, design the connector, and execute step by step.
**You want a quick spike.** Tell it: “Skip step 8 (README), focus on getting tools 1, 3, and 5 from the list above working end-to-end.” It’ll narrow scope.
## Example
> The API I want to wrap: Resend (transactional email, ).
>
> * Base URL: `https://api.resend.com`
>
> * Auth: `Authorization: Bearer ${RESEND_API_KEY}`. Get it from the Resend dashboard.
>
> * Endpoints I want:
>
> * `POST /emails` → `resend_send_email`
> * `GET /emails/:id` → `resend_get_email`
> * `POST /domains` → `resend_create_domain`
> * `GET /domains` → `resend_list_domains`
>
> Service prefix: `resend`.
The assistant outputs the full connector — client, tests, four tools, config, README — following the structure above.
# CLI reference
> Every mcify command and its flags.
```plaintext
mcify [options]
```
Run `mcify --help` to see this from the binary.
## init
Scaffold a new project from a template.
```bash
mcify init [--template ] [--dir ]
```
| Flag | Default | What it does |
| ------------------- | -------------- | ------------------------------------------------------------------------ |
| `` | (required) | Project name. Becomes `package.json`’s `name` and the default directory. |
| `--template ` | `from-scratch` | Template to use: `from-scratch`, `from-zod`, or `example-khipu`. |
| `--dir ` | `./` | Override the target directory. |
## dev
Run the MCP server locally with hot reload + the inspector.
```bash
mcify dev [--port ] [--inspector-port ] [--no-inspector] [--no-watch] [--config ]
```
| Flag | Default | What it does |
| ---------------------- | ------------------- | ------------------------------- |
| `--port ` | `8888` | MCP HTTP port. |
| `--inspector-port ` | `3001` | Inspector UI port. |
| `--no-inspector` | off | Disable the inspector entirely. |
| `--no-watch` | off | Disable file-watching restarts. |
| `--config ` | `./mcify.config.ts` | Path to your config. |
## build
Compile your config + tools into a deployable artifact.
```bash
mcify build [--target ] [--out ] [--bundle-deps]
```
| Flag | Default | What it does |
| --------------- | ------- | --------------------------------------------------------------- |
| `--target` | `node` | Runtime target. Picks the right adapter and bundler config. |
| `--out ` | `dist/` | Output directory. |
| `--bundle-deps` | off | Inline `node_modules` into the bundle (otherwise externalized). |
## generate
Two subcommands.
### `mcify generate` (no subcommand)
Emit a typed client SDK from your local config.
```bash
mcify generate [--config ] [--out ]
```
### `mcify generate from-openapi`
Generate Zod-typed tools from one or more OpenAPI specs. See [the from-openapi guide](/guides/from-openapi/) for the full workflow.
```bash
mcify generate from-openapi [--out ]
mcify generate from-openapi --spec = [--spec ...] [--out ]
```
| Flag | Default | What it does |
| ----------------------- | --------------- | ------------------------------------------------- |
| `` | — | URL or file path. Single-spec form. |
| `--spec =` | — | Repeatable. Each entry is `=`. |
| `--out ` | `src/generated` | Where the per-spec files land. |
## deploy
```bash
mcify deploy [options]
```
Targets: `cloudflare` (alias `workers`), `vercel`, `fly`, `railway`, `docker`.
Per-target flags live in the [Deploy guides](/deploy/overview/). Common options:
| Flag | Default | What it does |
| ----------------- | ------------------- | ----------------------------------------------------- |
| `--config ` | `./mcify.config.ts` | Path to your config. |
| `--dry-run` | off | Generate config + bundle, skip the actual deploy CLI. |
Each target also supports its own flags — see the per-target page.
# @mcify/core
> defineTool / Resource / Prompt / Config builders + auth helpers + middleware.
The `@mcify/core` package exposes builders, type definitions, auth helpers, schema helpers, and the middleware used by the runtime. It has zero runtime dependencies beyond Zod and the JSON-Schema converter.
## Builders
### `defineTool(spec)`
```ts
import { defineTool } from '@mcify/core';
defineTool({
name: string,
description: string,
middlewares?: Middleware[],
input: ZodSchema,
output: ZodSchema,
handler: (input, ctx) => Promise