One definition, one more client
The definition your typed client is built from also describes your API to an AI agent. Mark an endpoint .agent() and it becomes a tool of an MCP server — the protocol Claude Code, Cursor, Codex, VS Code and every other MCP client speak — with the schemas the endpoint already has:
import { courier, defineGroup } from "@noverax/courier/define"
import { z } from "zod"
const { endpoint } = courier()
export const orders = defineGroup({
prefix: "orders",
endpoints: {
get: endpoint
.get(":id")
.variables(z.object({ id: z.string() }))
.response(Order)
.agent({ description: "One order by its id: its items, total and status", access: "read" }),
cancel: endpoint
.post(":id/cancel")
.variables(z.object({ id: z.string() }))
.response(Order)
.agent({ description: "Cancel an order that has not shipped yet", access: "destructive" }),
export: endpoint.get("export"),
},
})orders.get and orders.cancel are tools; orders.export is out of every agent's reach. By default nothing is offered unless it is marked — the project's agents policy can offer more in one line, or keep a marked endpoint out: see What agents may call.
The server comes in two forms, from the same package:
- In your editor —
npx @noverax/courier-mcpserves your project'scourier.config.tsover stdio, for the agent you code with. See In Your Editor. - In your app —
createMcpHandlermounts an MCP endpoint next to your routes, for agents your users or your services run, behind anauthorizeyou write. See In Your App.
npm install @noverax/courier-mcp.agent()
| option | what it says |
|---|---|
description | What the tool does, for the model deciding when to call it: what it needs, what it answers with, what it changes. Required. |
access | "read" — changes nothing; "write" — changes what it addresses; "destructive" — removes or overwrites what cannot be restored, so a person confirms each call. Required. |
name | The tool's name, when its call in the client is not the one to give it: letters, digits, _ and -, at most 64. |
title | A name for people, which a client shows in place of the tool's. |
limit | How often a caller may call it: { maxCalls, window }, at most maxCalls calls in any window milliseconds. See How often an agent may call. |
An endpoint that writes cannot be offered as a read: the type of access excludes "read" after .post(), .put(), .patch(), .del() and .via(), and the server refuses to start on one written in plain JavaScript. A read sent over POST says so with .reads() first. .agent() is named on an HTTP endpoint, after its verb — it describes that one endpoint, so a base made with courier() does not pass it on.
.agent(false) keeps an endpoint from every agent, whatever the project's policy offers — a payout, an export of every customer:
import { courier } from "@noverax/courier/define"
const { endpoint } = courier()
export const payouts = endpoint.post("payouts").agent(false)What agents may call
The agents block of courier.config.ts says what the project offers, once, for the server in your editor, Studio and the pull request report:
export default defineConfig({
sources: { shop, billing },
agents: {
offer: "reads",
allow: ["shop.orders.cancel"],
deny: ["billing.**"],
access: "write",
confirm: "write",
},
})| option | what it says |
|---|---|
offer | Which endpoints are offered without .agent(): "marked" — none, the default; "reads" — every read as well; "all" — every endpoint |
allow | Endpoints to offer although offer leaves them out, by pattern |
deny | Endpoints no agent sees, whatever offers them — .agent() included |
access | The most a tool may do: "read", "write" or "destructive", the default. An endpoint that does more is offered to no agent, marked or not |
confirm | From which access a person confirms each call: "destructive", the default, or "write" for every call that changes something |
Each endpoint is decided by the first rule that speaks, in this order: a deny pattern it matches; its .agent(false); access beyond access; its .agent(); an allow pattern; offer. Nothing else offers it. Streams and channels are never tools.
A pattern names an endpoint by its call under its source — shop.orders.cancel — where * stands for any part of one name and ** for any number of names: shop.orders.* is every endpoint of the group, billing.** everything in billing, shop.*.list the list of each group of shop.
An endpoint offered without .agent() takes the access its verb implies — a read for a query, "destructive" for DELETE, "write" for the rest — and describes itself by its definition's .meta({ description }), which an imported specification's summary becomes, or else by its verb, path and call: GET orders/:id — shop.orders.get. Its tool's name follows its call, as a marked one's does; one whose name a marked tool holds, or one longer than the 64 characters a client takes, is left out with a warning, since nobody chose it by name. .agent() stays the way to give a tool a description written for the model.
What is offered, and why, is never a guess:
courier-mcp --listprints every tool with its access and what offered it, every endpoint kept out and what kept it — see In Your Editor.- Studio marks each offered endpoint agent in its Explorer, and its panel says the tool, the access and the reason.
courier-studio diffreports, on the pull request, the tools a change adds, removes or offers differently — see Breaking Changes.
A deny or allow pattern that names no endpoint is warned of where the server starts — a misspelt deny protects nothing — and so are more than 40 tools, past which some clients slow down or leave tools out.
The server in your editor follows the project: a save to courier.config.ts, or to a module it reaches, loads it again — only what changed is transformed — and the client is told when the tools it sees change, in about a tenth of a second; see In Your Editor. An application's own endpoint takes the policy as createMcpHandler({ agents: config.agents }) — see In Your App.
What becomes what
| In the definition | In the tool |
|---|---|
The endpoint's call in the client, orders.get | Its name, orders_get — led by the source's name, shop_orders_get, when the server offers several sources |
.variables() | Its input schema, as JSON Schema; variables that are not an object — a string, a list — go as { value } |
A path's :id, with no variables schema | A required argument, id |
.response() | Its output schema, and the answer as structured data beside its text |
access | The hints a client asks a person by: readOnlyHint, destructiveHint |
title | Its title |
limit | A line in its description: Takes at most 3 calls an hour from each caller |
deprecated | A line in its description |
Schemas are written as Standard JSON Schema: Zod 4 and ArkType describe their own, and createMcpHandler({ jsonSchema }) takes a converter for any other library, as the OpenAPI export does.
A field's description goes with it into the tool's input schema, where the model reads what to pass — with Zod 4, .describe() or .meta({ description }). Give one to every field whose name does not say it all:
import { z } from "zod"
export const Forecast = z.object({
city: z.string().describe("The city, by its name in English"),
days: z.number().int().min(1).max(14).meta({ description: "How many days ahead, today counted" }),
})How a call runs
An agent's call goes where your app's own calls go: through the source's middleware and the endpoint's, its variables validated by its schema and its answer by its response schema, and into Studio's timeline marked agent, with the tool and the client that called it. There is no second path to forget to protect.
What the agent is told:
- An answer — as JSON text, and as structured data where the client takes it.
- Arguments its schema refuses — which fields, and why, so the model corrects them:
- qty: Too big: expected number to be <=9. - An error status — the status and the body, cut to 2 000 characters; never a response header, which may carry a credential.
- An answer too long for a model — more than 100 000 characters is refused rather than cut, with a request to ask for less: half a JSON document reads as a whole one.
An agent's call carries no browser session: whatever a browser's cookies would give a user, an agent gets only what your middleware gives the principal it runs for.
A person confirms what cannot be undone
A destructive tool runs once the person at the client has confirmed that exact call — the tool, what it does and its arguments — and so does every write where the policy says confirm: "write":
An agent asks to run orders_cancel: Cancel an order that has not shipped yet
POST shop.orders.cancel with {"id":"o_7f2a"}
This cannot be undone. Run it?A write the policy has a person confirm is asked the same way, ending This changes data. Run it?. The question goes to the person, not the model: a prompt injected into the conversation cannot answer it. A call whose arguments the schema refuses is never put to anyone. A declined call does not run, and shows in Studio as a call that did not.
A client that cannot ask a person — one that supports no MCP elicitation, or the 2025 handshake over HTTP, which cannot carry the question — gets the call refused, with a sentence saying why. --allow-destructive on the command and allowDestructive on the handler leave the decision to that client's own approval prompt instead; a client that can ask a person is asked all the same.
How often an agent may call
An agent in a loop calls the same tool again and again. limit bounds it, per tool:
import { courier } from "@noverax/courier/define"
import { z } from "zod"
const { endpoint } = courier()
export const refund = endpoint
.post("orders/:id/refund")
.variables(z.object({ id: z.string() }))
.agent({
description: "Refund an order in full",
access: "destructive",
limit: { maxCalls: 3, window: 3_600_000 },
})A call beyond it is refused at once, before anything runs and before a person is asked to confirm it, and the agent is told when it may call again:
Not run: shop_orders_refund takes 3 calls an hour from each caller, and this caller made them; it may call again in 41 min.The model is told the limit with the tool, so it can plan around it, and a refused call shows in Studio as one that did not run. Over stdio there is one caller, the agent the command serves. Over HTTP every caller shares one count unless the handler's limitPer tells them apart — see In Your App. A limit counts the calls one process takes; a policy across replicas, or one that waits rather than refuses, belongs in your middleware, which sees ctx.agent below.
Who may do what: middleware
Every call an agent makes carries ctx.agent through your middleware — the tool, its access, the principal authorize let in, and the client as it named itself. Absent on every call your app makes itself, it is how a policy tells the two apart:
import { defineMiddleware } from "@noverax/courier/middleware"
export const agents = defineMiddleware("agents", "wrap", async (ctx, next) => {
if (!ctx.agent) return next()
if (ctx.agent.access !== "read" && !canWrite(ctx.agent.principal)) {
return { status: 403, data: { message: "This agent reads only" }, headers: new Headers() }
}
return next()
})A refusal fails the call as an upstream's error would: the agent is told the status and the message, and Studio shows the failed call. To hide tools from a caller altogether — absent from its list, unknown to its calls — use the handler's allow.
ctx.agent.client is what the client says it is. Show it, log it — never decide by it.
Protocol versions
The server speaks both eras of the Model Context Protocol: the 2026-07-28 revision, whose every request names its version and capabilities, and the 2025 revisions (2025-11-25, 2025-06-18, 2025-03-26) a client opens with initialize — what most clients speak by default today. Each is tested against the official MCP clients of both SDK generations.
| 2026-07-28 | 2025 handshake, stdio | 2025 handshake, HTTP | |
|---|---|---|---|
| Tools and their schemas | ✓ | ✓ | ✓ |
| Structured answers | any JSON | an object, from 2025-06-18 | an object, from 2025-06-18 |
| Confirming a destructive call | asked inside the result, signed | asked as a request of its own | not possible: refused |
| Told when the tools change | on the stream subscriptions/listen opens | notifications/tools/list_changed | asks again once ttlMs has passed |
| Session between requests | none | the process | none |