Skip to content

AI AgentsMCP Server

MCP Server

Offer your API's endpoints to AI agents as MCP tools — from the definition your typed client is built from, through the same middleware, schemas and Studio as your app.

10 min readboth modes

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:

api/orders.ts
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-mcp serves your project's courier.config.ts over stdio, for the agent you code with. See In Your Editor.
  • In your app — createMcpHandler mounts an MCP endpoint next to your routes, for agents your users or your services run, behind an authorize you write. See In Your App.
bash
npm install @noverax/courier-mcp

.agent()

optionwhat it says
descriptionWhat 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.
nameThe tool's name, when its call in the client is not the one to give it: letters, digits, _ and -, at most 64.
titleA name for people, which a client shows in place of the tool's.
limitHow 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:

ts
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:

courier.config.ts
export default defineConfig({
  sources: { shop, billing },
  agents: {
    offer: "reads",
    allow: ["shop.orders.cancel"],
    deny: ["billing.**"],
    access: "write",
    confirm: "write",
  },
})
optionwhat it says
offerWhich endpoints are offered without .agent(): "marked" — none, the default; "reads" — every read as well; "all" — every endpoint
allowEndpoints to offer although offer leaves them out, by pattern
denyEndpoints no agent sees, whatever offers them — .agent() included
accessThe most a tool may do: "read", "write" or "destructive", the default. An endpoint that does more is offered to no agent, marked or not
confirmFrom 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 --list prints 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 diff reports, 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 definitionIn the tool
The endpoint's call in the client, orders.getIts 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 schemaA required argument, id
.response()Its output schema, and the answer as structured data beside its text
accessThe hints a client asks a person by: readOnlyHint, destructiveHint
titleIts title
limitA line in its description: Takes at most 3 calls an hour from each caller
deprecatedA 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:

lib/sources.ts
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:

api/orders.ts
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:

api/middleware/agents.ts
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-282025 handshake, stdio2025 handshake, HTTP
Tools and their schemas✓✓✓
Structured answersany JSONan object, from 2025-06-18an object, from 2025-06-18
Confirming a destructive callasked inside the result, signedasked as a request of its ownnot possible: refused
Told when the tools changeon the stream subscriptions/listen opensnotifications/tools/list_changedasks again once ttlMs has passed
Session between requestsnonethe processnone