CARLOS rastrillo docs

🤖 MCP

An app's operations can also be MCP tools at /api/v1/mcp. Claude Code, Codex and other MCP clients connect with the same token they use for the JSON API. They see one tool per operation and can call those tools. Each call goes through the API's token check, policy and operation, so an agent can do nothing over MCP that its token couldn't do over the API.

Mounting

A new app from rastrillo new already serves MCP. newDoors, in internal/<app>/api.go, builds the API and MCP from one api.Config, and Routes mounts both. To add MCP to an app that already serves the API:

mcpHandler, err := mcp.New(mcp.Config{
	Gate:      apiConfig.Gate(),
	Ctx:       apiConfig.Ctx,
	Authorize: apiConfig.Authorize,
	Name:      "notes",
	Version:   rastrillo.BuildVersion,
}, gen.ExposedOperations()...)
if err != nil {
	return nil, err
}
mux.Handle("/api/v1/mcp", mcpHandler)

Build it from the api.Config you pass to api.New. Gate() copies out the token check, the logger, the body cap, Before, After, RequireAgentSession and the redactor, so the API and MCP endpoints admit the same callers. gen.ExposedOperations() is every operation whose resource is on the API.

On a route table, mount it beside the API with routes.Mount("/api/v1/mcp", app.mcp, "mcp"), outside your page group. Like the API, it takes a bearer token and nothing else, so CSRF and sessions have no part in it. Add a line to .rastrillo/budgets.txt so the perf walk skips it:

perf  GET /api/v1/mcp/*  skip  the MCP endpoint

mcp.New checks everything when the app starts. It fails if Ctx, Authorize or Gate.Verify is missing, if a descriptor is malformed or fails the fence, or if two operations would get the same tool name. The mcp reference has the full list.

Connecting

Make a token on the app's token page, then give the client the endpoint and the token. In Claude Code:

claude mcp add --transport http notes https://notes.example.com/api/v1/mcp \
  --header "Authorization: Bearer $TOKEN"

In Codex, add the server to ~/.codex/config.toml and keep the token in an environment variable:

[mcp_servers.notes]
url = "https://notes.example.com/api/v1/mcp"
bearer_token_env_var = "NOTES_TOKEN"

If the app sets RequireAgentSession and the token was made for an agent, the client must also send an Agent-Session header. Without one, the agent still sees the write tools, but each write call is refused as invalid, with a message telling it to send the header. In Claude Code, add --header "Agent-Session: <id>".

ChatGPT and claude.ai connect through OAuth, which Rastrillo doesn't serve yet. A request with no token gets 401 with WWW-Authenticate: Bearer.

What an agent sees

When a client connects, the server sends it instructions: how to send money and times, to take IDs from results rather than guess them, to page with next_after, to ask the person before a call whose tool says so, and to read a write back rather than retry it blindly. Your Config.Instructions comes after them. Use it for what is true of your app and not of every app.

Each operation is one tool. The tool's name is the operation's ID with . replaced by _, so bookmarks.create is bookmarks_create. Its title is the first sentence of the descriptor's Description, and its description is the whole of it. When the descriptor has a Confirm sentence, the description gains a line: "Before calling, ask the person to confirm:" and the sentence, with placeholders such as {id} left for the agent to fill in. The input and output schemas are the descriptor's Input and Result. An operation whose Result is {"type":"null"}, such as every generated delete, has no output schema.

The tool list is per caller. A tool is listed only when op.Check and the gate allow this caller to use it, so a read-only token sees no write tools. api/v1.json lists each operation's id, method, path, access, destructive and idempotent flags, and input and result field kinds. It does not list tool names or JSON schemas. A tool's name is its operation id with . replaced by _. A call to a tool the list left out is refused the way the API would refuse it, usually with forbidden.

The flags

Each tool tells the client what calling it does, with MCP's hints:

Hint Comes from
readOnlyHint Access is rastrillo.Read
destructiveHint Descriptor.Destructive: a call may change or remove what is already there
idempotentHint Descriptor.Idempotent: two calls with the same input leave the state one call would

openWorldHint is left unset, so clients read it as true. Many writes reach real people, and saying which ones don't would be a claim to make for every operation.

rastrillo generate sets the flags. A create is neither destructive nor idempotent. An update is destructive, and not idempotent, because a repeat moves updated_at again. A delete is both. A list and a get are reads, and idempotent. When you replace a generated create, update or delete with your own file, the generator can't tell what your code does, such as whether it sends email, so it marks the operation destructive and not idempotent. If you know better, pass mcp.New a descriptor of your own instead of the generated one.

Whether to ask the person first is a separate question, and Confirm answers it. Generated updates and deletes carry a sentence such as "Delete a bookmark {id}?", and their tools tell the agent to ask with it. Destructive alone doesn't.

A write whose ID, after its last ., starts with a verb that undoes or sends something, such as cancel, refund, delete, send or purge, must be Destructive. A read can't be. mcp.New refuses a descriptor that fails this check, and op.FenceVerbs lists every verb. Set the flag, or rename the operation if the verb is wrong.

Writing descriptors by hand

mcp.New serves exactly the descriptors you give it, so an app without manifests can write its own:

search := op.Descriptor{
	Operation: rastrillo.Operation{
		ID: "notes.search", Resource: "notes", Access: rastrillo.Read,
	},
	Description: "Search your notes. Matches titles and bodies.",
	Idempotent:  true,
	Input: json.RawMessage(`{"type": "object",
		"properties": {"query": {"type": "string"}},
		"required": ["query"], "additionalProperties": false}`),
	Result: json.RawMessage(`{"type": "object",
		"properties": {"notes": {"type": "array"}}}`),
	Invoke: func(ctx context.Context, rc *rastrillo.Ctx,
		input json.RawMessage) (any, error) {
		return searchNotes(ctx, rc, input)
	},
}

MCP passes the arguments to Invoke as they were sent. It doesn't check them against Input, because the API doesn't either: a generated Invoke decodes with op.DecodeInput, under the API's rules, so both endpoints accept and refuse the same things. A hand-written Invoke validates its own input. MCP refuses arguments that aren't valid UTF-8, or that hold an unpaired surrogate escape, before Invoke sees them.

Before Invoke runs, MCP runs the gate's Admit and op.Check for the operation, so your policy decides every call, even one to a hand-written Invoke that skips op.Check. Still begin the operation with op.Check, as a generated one does. The API and your pages call it without that step.

Put a check that depends on the input inside Invoke, before the operation does anything. Authorize sees the operation but not its input, so it can check a grant's scope but not which team the input names. Check the team in Invoke, then again in your guard as the answer goes out. The guard is never the first check, because by the time it runs a write has already happened.

A result must be a JSON object, unless Result is {"type":"null"}, in which case it may be null. If a result isn't an object and Result isn't exactly {"type":"null"}, MCP returns an internal tool error with a ref. A result goes out as structuredContent and as JSON in a text block. Numbers come out exactly as the operation returned them, never through a float64, and an encoding/json json.Number is written as the number it holds. A result over Config.MaxResult, 2 MiB unless you set it, is replaced with a response_too_large tool error that tells the agent to ask for less.

Refusals

A refusal is a tool error, not a protocol error, so the model can read it and fix the call. Its text is the API's error envelope, with the API's code and message:

{"error":{"code":"forbidden","message":"This token can't do that."}}

The codes are the API's: invalid with its fields, forbidden, not_found, conflict, response_too_large, and internal with a ref that the log line shares. A panic anywhere in a call answers an internal tool error, and the app keeps running. A call to a tool that doesn't exist is the SDK's protocol error.

Each call logs one line, mcp: call, with the tool name and the actor, redacted like the API's api: request line.

Checking again as the answer goes out

A token can be revoked, or its grant narrowed, while a call is running. When Gate.Verify is an api.Rechecker, as apitoken's store is, MCP checks again before the answer's headers and before each 64 KiB of its body, the same way the API does.

For a tool call, each check is the token, then the gate and op.Check for the operation that ran. The gate rechecks them on every answer. Your Config.Guard runs last only when the call returns a successful result. For the tool list, each check covers every tool that was listed. If any of them is no longer the caller's to use, the whole list is refused rather than quietly shortened.

Guard is an op.ResponseGuard. It gets the fresh actor, the descriptor, the arguments as sent and the value Invoke returned, and checks that the actor may still see the result: for example, that each note in a search result is still in a team they belong to. For the tool list, the descriptor and the arguments are nil, and the value is the []op.Descriptor that was listed. A guard must not run the operation again or change anything.

Put a slow check against another service first in your guard, and your own revision and link checks after it. The guard is the last check before each chunk, and the last thing it checks should be the closest to the bytes going out.

mcp.New refuses a Guard when the verifier isn't a Rechecker: a guard that never runs would look like protection and give none. If your answers need an item-level check, set Guarded, and New refuses to start without both.

Who decides what an agent may do

Only the token says who is calling. The app's verifier turns it into a rastrillo.Actor, and MCP sees that actor and nothing else, whoever issued the token.

An app can issue its own tokens, as apitoken's personal tokens do. Or several apps can trust one connection service. There, a person signs in with an identity provider, which says who they are, and consents at the connection service, which records what they let an agent do. The two are separate parties. The service issues tokens limited to one app, and each carries a rastrillo.Grant: the grant's ID, who issued it, who vouches for the person, the app it is for, and any teams and scopes it names.

A verifier for a connection service's tokens has three checks to make before it returns an actor:

  1. The token's audience is this app.
  2. Its identity issuer is exactly the one the app is configured for.
  3. Its subject is an existing local account.

Your app decides how to apply the grant. The framework records it but never reads its Teams or Scopes, because what they mean is yours. Your Authorize checks the scope for each operation, Invoke checks a team the input names, and your guard checks it again as the answer goes out. A grant can only narrow what the person could already do. Rastrillo ships no verifier for grants yet.

What it doesn't serve

This is tools only: no resources, prompts or completions. Every request is one JSON-RPC message in an HTTP POST, answered with plain JSON. Each request builds its own server, so nothing is kept between requests and no open stream keeps an instance awake. A GET, a batch or a subscriptions/listen is refused with "Send one JSON-RPC message per HTTP POST. Do not send batches, use GET, or request subscriptions."

The SDK's DNS-rebinding check is off. Every request must carry a bearer token before the SDK sees it, and a rebinding page has none. With the check on, an app behind a proxy on the same machine, such as tailscale serve, would refuse every request.

Read this page as markdown — exact, unstyled, and cheap for an agent to fetch.