Autumn already makes it fast to ship a typed JSON API. The mcp feature lets
an AI agent use that API by projecting your existing routes into a
Model Context Protocol (MCP) server — the
same way Autumn projects them into an OpenAPI document. You tag the endpoints
you want to expose and mount one server; Autumn derives the tool schemas,
speaks JSON-RPC over Streamable HTTP, and dispatches each tool call through
your real, authenticated handler pipeline.
For the agent in the user's own browser, see WebMCP. It uses
the same derivation, with a separate #[api_doc(webmcp)] opt-in.
No second app. No hand-written protocol, transport, or tool schemas. The tool
catalog is derived from the same ApiDoc metadata that drives generate_spec,
so it cannot drift from your handlers — change a handler's types and the
tool schema changes with it, with no extra edit.
1. Enable the feature
The mcp feature builds on the OpenAPI schema machinery, so it implies the
openapi feature.
# Cargo.toml
[dependencies]
autumn-web = { version = "0.10", features = ["mcp"] }
2. Tag endpoints and mount the server
Opt in per endpoint with #[api_doc(mcp)], then mount the endpoint once:
use autumn_web::prelude::*;
#[derive(serde::Serialize, serde::Deserialize)]
struct Todo { id: u32, title: String }
#[derive(serde::Serialize, serde::Deserialize)]
struct NewTodo { title: String }
#[get("/api/todos")]
#[api_doc(mcp, summary = "List all todos")]
async fn list_todos() -> AutumnResult<Json<Vec<Todo>>> {
Ok(Json(vec![Todo { id: 1, title: "first".into() }]))
}
#[post("/api/todos")]
#[api_doc(mcp, summary = "Create a todo")]
async fn create_todo(Json(body): Json<NewTodo>) -> AutumnResult<Json<Todo>> {
Ok(Json(Todo { id: 42, title: body.title }))
}
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![list_todos, create_todo])
.mount_mcp("/mcp")
.run()
.await;
}
That's it. POST /mcp now speaks MCP and exposes list_todos and
create_todo as agent-callable tools.
Opt-in is the default; nothing is exposed implicitly. A route with no
#[api_doc(mcp)] tag never becomes a tool.
3. What the agent sees
mount_mcp serves a single Streamable-HTTP endpoint that handles the three
methods an MCP client needs:
| Method | Purpose |
|---|---|
initialize | Handshake; returns serverInfo, capabilities.tools and the optional instructions. |
tools/list | The derived tool catalog. |
tools/call | Invoke a tool by name; dispatched through the real pipeline. |
ping and JSON-RPC notifications (messages with no id, e.g.
notifications/initialized) are handled too — notifications get an empty
202 Accepted, per the spec.
Protocol versions
The endpoint serves two protocol eras on the same path:
- Legacy (
2025-06-18,2025-03-26,2024-11-05). The client opens withinitialize. The methods above apply. - Modern (
2026-07-28). There is no handshake. Each request carries its version and client capabilities inparams._meta, and theMCP-Protocol-Version,Mcp-MethodandMcp-Nameheaders mirror the body.initializeandpingare not part of this revision.
A request whose _meta has io.modelcontextprotocol/protocolVersion uses
the modern era:
// POST /mcp
// MCP-Protocol-Version: 2026-07-28
// Mcp-Method: tools/call
// Mcp-Name: create_todo
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "create_todo", "arguments": { "body": { "title": "buy milk" } },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {} } } }
In the modern era:
| Method | Result |
|---|---|
server/discover | supportedVersions, capabilities, the optional instructions, and the caching hints ttlMs and cacheScope. |
tools/list | The catalog, with ttlMs and cacheScope. |
tools/call | As in the legacy era. |
Each result has resultType ("complete", or "task" for a task handle)
and _meta["io.modelcontextprotocol/serverInfo"]. cacheScope is always
"private": the endpoint can sit behind auth, so a shared cache must not
serve the catalog to another caller.
Errors:
| Code | HTTP | When |
|---|---|---|
-32602 | 400 | _meta has no protocolVersion or no clientCapabilities. |
-32020 | 400 | A standard header is missing, repeated, or does not match the body. Mcp-Name can use the =?base64?…?= form. |
-32022 | 400 | The version is not served. data.supported lists the served versions. |
-32601 | 404 | The method is not served (for example ping, resources/list, subscriptions/listen). |
-32600 | 400 | The body is a JSON-RPC batch, or the id is not a string or an integer. |
-32700 | 400 | The body is not JSON (with a modern MCP-Protocol-Version header). |
An error with no known id has no id field. An initialize request always
uses the legacy era, also with a modern header.
A tools/list entry looks like this — name, description, inputSchema,
and annotations are all derived from the handler's ApiDoc:
{
"name": "create_todo",
"description": "Create a todo",
"inputSchema": {
"type": "object",
"properties": {
"body": { "$ref": "#/$defs/NewTodo" }
},
"required": ["body"],
"$defs": { "NewTodo": { "type": "object", "title": "NewTodo" } }
},
"annotations": { "title": "Create a todo", "readOnlyHint": false }
}
How inputSchema is built
Autumn merges the handler's typed contract into one object schema:
- Path parameters (
/api/todos/{id}) become requiredstringproperties named after each capture (id). - A
Query<T>extractor becomes aqueryobject property. - A JSON request body (
Json<T>) becomes a requiredbodyproperty. - Named component schemas are inlined under
$defsso the schema is self-contained.
Because every piece comes from the same SchemaEntry data the OpenAPI
generator uses, there is no second schema to maintain and no way for the
tool catalog to drift from the handler.
The per-tool inputSchema is generated from the request types via the OpenApiSchema derive, so there is no second schema to hand-maintain; serde renames are honoured and tool identity is collision-proof.
Structured query arguments round-trip
A Query<T> field does not have to be a scalar. Query<T> decodes a
superset of the flat key=value form — a bracketed dialect whose
items[0][sku] shape matches the rows NestedChangesetForm renders,
generalized to arbitrary objects, sequences and depths — and tools/call
dispatch renders the tool's query object into exactly that format:
| Tool argument | Decoded query keys | Handler field |
|---|---|---|
{"page": 2} | page=2 | u32 |
{"tags": ["a","b"]} | tags=a&tags=b | Vec<String> |
{"filter": {"status":"open"}} | filter[status]=open | a nested struct |
{"items": [{"sku":"A"}]} | items[0][sku]=A | Vec<Item> |
(The keys are percent-encoded on the wire — filter%5Bstatus%5D=open — and
decoded before the brackets are parsed, so the two forms are equivalent.)
So an agent can pass structured arguments directly, instead of the comma-separated strings and JSON-in-a-string fields the flat form used to force. What the encoding cannot carry, dispatch refuses rather than quietly altering:
- A
nullfield renders no query parameter — a query string has no null, so an explicitly-null optional argument arrives as absent (None) rather than as the literal textnull. That is the one silent conversion, and it matches what the caller meant. - An empty array or object, a
nullarray element, and an object field name that is empty or contains[/]are all invalid-params errors: each would otherwise vanish a field, shorten a sequence, or invent a nesting level. A field that must distinguish "empty" from "absent" belongs in a JSON body. - Nesting is depth-capped (
autumn_web::query_string::MAX_DEPTH) and one call's query expansion is bounded, on both the encode and decode side.
For genuinely large or deeply structured input, a JSON body (Json<T>) is
still the better contract: it round-trips losslessly through the tool's body
property and carries real JSON types rather than coerced text.
Build-time warning
When assembling /mcp, Autumn emits a build-time tracing::warn for a tool
whose query or body resolves to a bare {"type":"object"} placeholder —
the arg type has no OpenApiSchema, so its fields aren't advertised. Fix it by
deriving (#[derive(OpenApiSchema)]) or implementing OpenApiSchema on the
arg type.
It is a warning, not an error: the app still builds and the tool is still exposed.
Safety annotations
The HTTP method maps to MCP safety hints so agents and UIs can reason about side effects:
| Verb | readOnlyHint | destructiveHint |
|---|---|---|
GET | true | — |
POST / PUT / PATCH | false | — |
DELETE | false | true |
The verb is a floor. A handler that declares an authority envelope can raise
destructiveHint, never clear it — see below.
Proving what an agent can do
A tool description says what an endpoint is for. It says nothing about what
the call is allowed to do, and an MCP tool is an action an agent takes with
no human in the loop. #[agent_operable(grant = ...)] closes that gap at build
time:
use autumn_web::prelude::*;
authority_grant! {
pub RefundDrafter {
writes: [Refund],
tenant_scope: scoped,
outbound: ["https://api.stripe.com/v1/refunds"],
jobs: [NotifyFinanceJob],
reversibility: compensable,
}
}
#[post("/api/refunds")]
#[api_doc(mcp, summary = "Draft a refund")]
#[agent_operable(grant = RefundDrafter)]
pub async fn draft_refund(/* … */) -> AutumnResult<Json<Refund>> { /* … */ }
The macro walks the body, derives the effects it can prove — row writes,
unbounded writes, cross-tenant access, outbound hosts, webhook topics, jobs —
and fails cargo build when the grant does not cover one of them. Three things
follow for MCP specifically:
destructiveHintgets a proved input. When a tool carries a grant, acompensableorirreversiblereversibility sets the hint, raising aPOST/PATCHthe verb alone says nothing about. It only ever adds: aDELETEstaystruewhatever the grant declares, becausereversiblemeans the effect set is bounded writes — not that the application can put the row back — and a client skips its confirmation prompt onfalse. Ungoverned tools keep the table above exactly as it is.- Every
tools/callis audited, with no per-handler wiring: anagent.tool.<name>.attemptevent before dispatch and anagent.tool.<name>event after it, sharing one correlation id and carrying the tool, the grant, the reversibility, the proved effect set and the argument names (never their values — only the keys the tool declares, with any others counted as+N unknown). Each carries aphaseofattempt,outcomeorrefused. An ungoverned tool is audited too, withreversibility = "unknown". Every write is bounded by a 2-second timeout; if the attempt record cannot be written or times out and the action is notreversible, the call fails closed, the handler never runs, and a best-effortagent.tool.<name>.refused(status Failure, with arefused_reasonand nohttp_status) records the refusal. Note the attempt is written before the request is dispatched, and so before route-level authorization — once a sink is installed, gate the endpoint withsecure_mcp(...)(§7) and rate-limit it. autumn agents manifest --checklists every MCP-exposed tool: governed ones with their envelope, and every mutating tool that has no envelope — the one gap the compiler cannot catch, because a tool with no grant has no assertion to fail. Generated#[repository(api, mcp)]CRUD tools (§10) have no annotation site in this slice, so they surface there.
See The Agent Authority Envelope for the grant grammar, the effect table, the escape hatches, the manifest and the audit record.
Large catalogs: core tools and search
Each tool in tools/list costs the agent context before it does any work.
Tool choice also gets worse past about 30 to 50 tools. Three settings help.
Core tools. Mark the tools that most tasks need:
#[get("/api/todos")]
#[api_doc(mcp, core, summary = "List all todos")]
async fn list_todos() -> AutumnResult<Json<Vec<Todo>>> { /* ... */ }
A core tool carries _meta: {"anthropic/alwaysLoad": true}. A client that
defers tools (Claude Code, for example) loads a core tool before any search.
Route::mcp_core() does the same for a route built in code. core does not
opt a route in. Use it with mcp, or under the whole-API hatch.
Instructions. Tell the model what the server does and when to search:
autumn_web::app()
.routes(routes![list_todos])
.mcp_instructions("Todo and calendar tools. Search for calendar tools.")
.mount_mcp("/mcp")
initialize and server/discover return the text as instructions. Do not
repeat the tool descriptions.
The progressive catalog. For a large catalog, send only the core tools:
use autumn_web::mcp::McpCatalog;
autumn_web::app()
.routes(routes![list_todos, create_todo])
.mcp_catalog(McpCatalog::Progressive)
.mount_mcp("/mcp")
Then tools/list holds two meta-tools and the core tools:
| Tool | Arguments | Result |
|---|---|---|
search_tools | query, detail (name, summary or full), limit (1 to 50, default 10), offset (default 0) | total hits, and tools, one page of the best hits. full adds each inputSchema. |
call_tool | name, arguments | The result of the named tool. |
search_toolsmatches words against tool names, descriptions, tags and argument names. An exact name word counts most. Equal scores keep the catalog order. An empty query lists the tools in catalog order. Useoffsetto get the next page. A query has at most 128 characters and 16 distinct words.- The
search_toolsdescription gives the tool count and the route tags, so the model knows what it can find. call_toolruns the tool through the same path as a directtools/call. Auth, guards, rate caps, the audit record, tasks and streaming all apply. The audit record names the inner tool and addsvia = "call_tool".call_toolrefuses an unknown name and a meta-tool name with-32602.- A direct
tools/callof a tool that the list does not show still works. - The server adds a short guidance text to the instructions.
- The catalog does not change during a session, as revision
2026-07-28requires.McpCatalog::Full, the default, lists every tool. - In progressive mode, the server skips a route tool named
search_toolsorcall_tool, and logs a warning. Give the route a differentoperation_id. Theautumn agents manifestlisting does not apply this rule.
The progressive catalog is not access control. An agent can find and call
each tool. Know these limits of call_tool:
- A client approves a tool call by its tool name. A rule that allows
call_toolallows each tool behind it. call_toolhasdestructiveHint: true, because it can run any tool. A client can then ask the user before the call. The annotations of the inner tool do not reach the client.- A gateway that routes or authorizes on the
Mcp-Nameheader seescall_tool, not the inner tool. Such a gateway must readparams.arguments.name, or refusecall_tool.
Mark a destructive tool core. It then keeps its own name and annotations in
tools/list, and the client approves it on its own.
4. Calling a tool
tools/call takes a tool name and an arguments object whose shape mirrors
the inputSchema: path parameters at the top level, query fields under
query, and the JSON body under body.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_todo",
"arguments": { "body": { "title": "buy milk" } }
}
}
The handler's JSON response comes back as the tool result's text content:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "{\"id\":42,\"title\":\"buy milk\"}" }],
"isError": false
}
}
A non-2xx handler response is returned as isError: true with the status and
body, rather than a transport-level failure — so an agent can read and recover
from a validation error the same way a human-written client would.
You can drive it from the command line:
curl -s http://127.0.0.1:3000/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
5. Streaming progressive results over SSE
A long-running tool — a code search over a large graph, a multi-step report, a
slow scan — feels broken if the agent waits in silence for the whole result.
The MCP Streamable-HTTP transport lets a tools/call emit
notifications/progress messages (and partial content) over the response's SSE
channel before the final result lands. Autumn already ships a first-class SSE
primitive (sse.rs: Sse/Event/keep_alive) — the exact transport MCP
streaming rides — so a streaming tool is just a normal Autumn Sse stream
wearing an MCP hat. You write zero JSON-RPC or SSE framing.
Opt in with stream
Add the stream flag to #[api_doc(mcp, stream)] and return an Sse stream
of Events. Because an Sse handler has no JSON response schema, stream also
exempts the tool from the JSON-out eligibility gate (see §9):
use std::convert::Infallible;
use autumn_web::prelude::*;
use autumn_web::sse::{Event, Sse};
use futures::stream::{self, Stream};
#[get("/api/search")]
#[api_doc(mcp, stream, summary = "Streaming code search")]
async fn search() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
// A plain Autumn stream — each event is one incremental chunk of work.
let stream = stream::iter(vec![
Ok(Event::default().data("match src/a.rs:12")),
Ok(Event::default().data("match src/b.rs:48")),
]);
Sse::new(stream)
}
What rides the wire
When a client calls a streaming tool and advertises it can read SSE
(Accept: application/json, text/event-stream), Autumn answers the POST /mcp
with Content-Type: text/event-stream and projects your stream onto it:
- Each
Eventyou yield becomes anotifications/progressmessage — but only when the client supplied a progress token inparams._meta.progressToken(per spec, progress requires a token). The event's text is the progressmessage;progressauto-increments per event. - The stream is terminated by the final id-correlated
tools/callresult, whose content is the joined text of the streamed events.
// client → server
{
"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {
"name": "search", "arguments": {},
"_meta": { "progressToken": "tok-1" }
}
}
// server → client, as SSE frames (one JSON-RPC message per `data:` frame)
data: {"jsonrpc":"2.0","method":"notifications/progress",
"params":{"progressToken":"tok-1","progress":1,"message":"match src/a.rs:12"}}
data: {"jsonrpc":"2.0","method":"notifications/progress",
"params":{"progressToken":"tok-1","progress":2,"message":"match src/b.rs:48"}}
data: {"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text",
"text":"match src/a.rs:12\nmatch src/b.rs:48"}],"isError":false}}
The time-to-first-signal is decoupled from total duration: frames are forwarded as your stream produces them, so the first progress notification reaches the agent immediately even when the whole tool takes seconds.
Structured progress and an explicit final payload
Two optional conventions, still framing-free:
- Structured progress — yield an
Eventwhose data is a JSON object with a numericprogress(and optionaltotal/message); those fields are forwarded verbatim into the notification's params instead of the auto-incrementing counter. - An explicit final result — yield
Event::default().event("result").data(…)to set the terminating result's content directly. Frames typedresultare not surfaced as progress; everything else is.
Buffered tools and non-SSE clients are unaffected
Streaming is strictly opt-in per tool. A tool without stream follows the
exact buffered path from §4 — nothing about it changes. And a streaming tool
called by a client that does not accept text/event-stream is served a
buffered JSON result (its streamed events collapsed into one tool result), so a
plain JSON client is never handed a body it can't read.
Back-pressure and disconnect
The projection reuses the same lifecycle sse.rs uses for a dropped subscriber:
if the agent disconnects mid-stream, axum drops the response and Autumn drops
the underlying handler stream — the handler's task unwinds with no leaked task
and no panic on the closed stream. A keep_alive comment is sent on idle so
proxies don't drop a slow stream.
Server-initiated
GETstreams are not supported. Streaming rides thetools/callPOSTresponse; a bareGET /mcp(for unsolicited server→client messages) returns405.
6. Long-running tools as tasks
A blocking tools/call keeps the POST /mcp open until the handler returns,
and the global request deadline can stop it. A task tool can return a task
handle instead. The client polls for the result. This is the MCP
Tasks extension
(io.modelcontextprotocol/tasks).
Opt in with task
#[post("/api/reports", timeout = "off")]
#[api_doc(mcp, task, summary = "Build the monthly report")]
async fn build_report(Json(req): Json<ReportRequest>) -> AutumnResult<Json<Report>> {
// Slow work.
}
Route::mcp_task() does the same for a route that a plugin builds. A task
tool must return JSON, like other tools. task without mcp does nothing.
You can use stream and task together: a task buffers the stream into its
final result.
When one or more tools are task tools, server/discover (modern) and
initialize (legacy) advertise the extension:
{ "capabilities": { "tools": { "listChanged": false },
"extensions": { "io.modelcontextprotocol/tasks": {} } } }
The flow
The server decides per request. A call becomes a task only when the tool is a task tool and the request declares the extension:
// client → server
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "build_report", "arguments": { "body": { "month": "2026-09" } },
"_meta": { "io.modelcontextprotocol/clientCapabilities":
{ "extensions": { "io.modelcontextprotocol/tasks": {} } } } } }
// server → client, at once
{ "jsonrpc": "2.0", "id": 1, "result": {
"resultType": "task", "taskId": "9f0c…e1", "status": "working",
"createdAt": "2026-10-11T10:30:00.000Z", "lastUpdatedAt": "2026-10-11T10:30:00.000Z",
"ttlMs": 3600000, "pollIntervalMs": 1000 } }
The client then calls tasks/get with the taskId (and the same _meta).
When the status is completed, result holds the CallToolResult that a
blocking call gives, with "resultType": "complete" added. Other methods:
| Method | Result |
|---|---|
tasks/get | The task. result when completed, error when failed. |
tasks/cancel | { "resultType": "complete" }. A running task stops and becomes cancelled. A terminal task does not change. |
tasks/update | { "resultType": "complete" }. Autumn never asks for input, so it ignores inputResponses. |
Status rules:
- A handler error (4xx/5xx) is
completedwithisError: true, as the spec requires. - A handler panic gives HTTP 500, as for a blocking call. The task is
completedwithisError: true. - A panic outside the handler pipeline is
failedwith error-32603. - When the TTL ends, a running task is
failedwith error-32603. - Bad arguments or an unknown tool give the normal JSON-RPC error. The server does not make a task.
Errors on tasks/*:
| Code | When |
|---|---|
-32021 | The request does not declare the extension. |
-32602 | Unknown task, expired task, or a task of another caller. |
-32601 | The app has no task tool. |
-32600 | tasks/* is in a JSON-RPC batch. |
Blocking fallbacks
A task tool runs as a normal blocking call when:
- the client does not declare the extension, or
- the task store is full (
max_tasks), or the client hasmax_tasks_per_callertasks. The server logs a warning.
Timeouts and limits
A task runs outside the POST /mcp request, so the global request deadline
does not stop it. The route's own timeout applies. Set timeout = "off" or
timeout_ms = … on the route for long work.
use std::time::Duration;
use autumn_web::mcp::McpTaskConfig;
autumn_web::app()
.routes(routes![build_report])
.mount_mcp("/mcp")
.mcp_tasks(
McpTaskConfig::default()
.ttl(Duration::from_secs(600)) // default: 1 hour
.poll_interval(Duration::from_secs(2)) // default: 1 second
.max_tasks(256) // default: 1024
.max_tasks_per_caller(16) // default: 64
.max_stored_bytes(64 * 1024 * 1024), // default: 256 MiB
)
.run()
.await;
The TTL starts when the server makes the task. The minimum is 1 second. When the TTL ends, the server stops a running task and deletes the task.
Security
- Task IDs are 256 bits from the app's entropy source.
- Owner binding. Each task keeps a SHA-256 digest of the caller's
credentials:
Authorization, the session cookie, the tenant header and the mTLS certificate fingerprint.tasks/*with other credentials gets-32602, the same error as an unknown ID. A caller with no credentials is anonymous. Only the task ID protects such a task. - Token refresh. The digest uses the raw credential. If a client changes its token while a task runs, it cannot read the task.
- Authentication.
tasks/*does not run the route's auth again. Usesecure_mcp(...)(§7) to check the credential on eachtasks/*request, for example to stop a revoked token. - Quota. One client can hold at most
max_tasks_per_callertasks. The key is the client IP that the trusted-proxy policy resolves. A caller cannot get a new quota with a new credential. Clients behind one NAT share a quota. - Memory. All stored results together use at most
max_stored_bytes. If a result does not fit, the task completes with a tool error. The same budget limits how many task results buffer at one time (one per 10 MiB, at least one). - Audit. A task call writes the same
attemptand outcome records as a blocking call (see "Proving what an agent can do"). A cancelled or expired task writes an outcome withresult = task_aborted. A panic outside the pipeline writestask_panicked. - Load shedding. A task takes its own slot while it runs. With a very low
server.max_concurrent_requests, a task can start while itsPOST /mcpstill holds a slot. Then the task is shed and completes with an HTTP 503 tool error.
Limits of this version
- The store is in memory and per process. Tasks do not survive a restart.
With more than one replica, route
tasks/*on theMcp-Nameheader (the client sets it to the task ID) so that polls reach the same replica. - A task has no HTTP response. The server drops
Set-CookieandServer-Timingfrom the handler. - The server does not send
notifications/tasks. Clients poll. - Autumn does not serve the
2025-11-25core tasks (taskrequest parameter,tasks/result,tasks/list).
7. Authentication: reuse your bearer tokens
tools/call runs through the real handler pipeline — the same in-process
path Autumn's test client uses. That means #[secured],
authorization, tenancy, rate limits, and validation all apply identically to
an agent call and an ordinary HTTP call. There is no separate auth subsystem.
Agents authenticate exactly like any other API client: with a bearer token
verified by RequireApiToken. The Authorization
header an agent sends to /mcp is forwarded into the dispatched request,
so the call runs as that verified principal. The Cookie and X-CSRF-Token
headers are forwarded too, so session-based #[secured] routes and
CSRF-protected writes behave identically to a direct call.
The example below uses InMemoryApiTokenStore, which keeps tokens in the
process and seeds them in code. To manage an agent's token from the CLI
instead — autumn token issue <principal> --scope <scope>, and revoke /
rotate to take it back — the app must read the same api_tokens table the
CLI writes, which means mounting
DbApiTokenStore in place of the in-memory one.
A token issued by the CLI is invisible to an in-memory store, so verification
answers 401. See
API tokens.
To put a tool behind token auth, register the route inside a scoped group
carrying the RequireApiToken layer. The scope keeps the route in the
registry (so MCP can derive the tool) and applies the layer (so every call —
agent or HTTP — is checked):
use std::sync::Arc;
use autumn_web::auth::{InMemoryApiTokenStore, RequireApiToken};
let store = Arc::new(InMemoryApiTokenStore::default());
autumn_web::app()
.scoped(
"/api",
RequireApiToken::new(store.clone()),
routes![list_todos, create_todo], // handlers declared at "/todos"
)
.mount_mcp("/mcp")
.run()
.await;
The store is seedable for tests and local development: InMemoryApiTokenStore::default().with_token("dev-token", "user:dev") (or .with_scoped_token(raw, principal, &scopes)), and InMemoryApiTokenStore::from_env("AUTUMN_API_TOKEN", "user:dev") reads the raw token from an environment variable (erroring if it is unset or empty). Both are for tests/local runs — not production token storage.
A tools/call with no token is rejected by RequireApiToken and surfaces as
isError: true; the same call with a valid Authorization: Bearer <token>
header on the /mcp request succeeds. This protects the tools — but
initialize/tools/list (the catalog) are still reachable, since the
per-route layer only wraps the dispatched call, not the /mcp envelope.
Gating the whole endpoint
To require a credential for the entire endpoint — catalog included — wrap it
with secure_mcp, passing any tower layer (e.g. RequireApiToken):
autumn_web::app()
.routes(routes![list_todos, create_todo])
.mount_mcp("/mcp")
.secure_mcp(RequireApiToken::new(store.clone())) // gates initialize/tools/list too
.run()
.await;
Why the /mcp endpoint sits outside the global middleware stack
The /mcp envelope (initialize/tools/list) is mounted outside the
app's global middleware — your AppBuilder::layer(...) layers and the
framework's CSRF/session middleware do not wrap it. This is deliberate and
matches how the MCP SDKs work:
- Forcing CSRF/session middleware onto a JSON-RPC
POSTwould reject legitimate agent calls — MCP authenticates with bearer tokens/OAuth, not browser form tokens. No MCP SDK wraps the endpoint in browser middleware. - The protections that do matter are provided the MCP-native way:
Originvalidation lives in the MCP layer (below), everytools/callruns the full per-route pipeline (so per-route auth/validation always applies), andsecure_mcp(...)is the explicit opt-in to gate the whole endpoint — the analogue offastapi-mcp'sAuthConfig.
If you want a global concern (auth, logging) to cover the envelope too, pass it
to secure_mcp(layer) rather than relying on AppBuilder::layer.
Origin validation (DNS-rebinding protection)
The MCP Streamable-HTTP transport requires servers to validate the
Origin header so a malicious web page can't use a browser to reach a local
MCP server via DNS rebinding. Autumn enforces this automatically against your
CORS allowed_origins:
- A request with no
Originheader (curl, SDKs, server-side agents) is allowed — non-browser callers aren't subject to DNS rebinding. - A request whose
Originis the same origin as the request's own host, where that host is a trusted host, is allowed without an allowlist entry — a browser client served by the app itself is already covered. - Otherwise the
Originmust be incors.allowed_origins(or the list must hold*); anything else gets 403 Forbidden before any parsing or dispatch.
So it is specifically a cross-origin browser MCP client — one served from somewhere other than the app — that needs its origin added to your CORS config (CORS and Cross-Origin Requests); a same-origin browser client and an agent client both need no configuration.
8. The whole-API hatch
For internal tools or trusted agents you can expose every eligible read
endpoint at once, without tagging each one, via expose_all_as_mcp():
autumn_web::app()
.routes(routes![/* ... */])
.expose_all_as_mcp() // mounts at /mcp; chain mount_mcp("/path") to change it
.run()
.await;
This is an explicit, separate opt-in — never the default. It is deliberately conservative:
GETendpoints are auto-included, but mutating verbs (POST/PUT/PATCH/DELETE) still require an explicit#[api_doc(mcp)]opt-in. A write is never exposed implicitly, even under the hatch.- Per-endpoint exclusions are always honored. Mark a route
#[api_doc(mcp = false)]to keep it out, even underexpose_all_as_mcp().
9. Eligibility: JSON in, JSON out
Only JSON endpoints are eligible. Autumn detects this structurally: a route is
eligible when its handler has a JSON response schema (it returns
Json<T>). HTML/Maud routes have no response schema and are auto-excluded.
If you tag an HTML route with #[api_doc(mcp)], it is skipped with a
build-time log note rather than a runtime surprise:
WARN skipping MCP exposure: endpoint has no JSON response schema;
eligible tools return Json<T>, declare an empty-body status (204/205),
or opt in as streaming (`stream`/`Route::mcp_stream`)
— HTML/Maud routes are not eligible
operation_id="dashboard" method="GET" path="/dashboard"
Exception: empty-body statuses (204, 205). A route whose success
status is 204 No Content or 205 Reset Content has no response schema by
contract — a deliberate empty success (like the repository macro's generated
DELETE), structurally distinct from an HTML route's schema-less 200. Such
routes stay eligible under the same rules as any schema'd route: an explicit
opt-in exposes any verb, and the expose_all_as_mcp() hatch auto-includes
untagged read-only ones. The tool result of a successful call is empty text —
enforced at dispatch, so a route that mislabels its status (say, an HTML
handler tagged status = 204) cannot leak its response body to agents.
10. Repository CRUD tools
The #[repository(Model, api = "/path")] macro generates five CRUD routes.
Add the mcp key to expose them as tools — no hand-written handlers or
attributes needed:
// list/get/create/update/delete all become tools:
#[autumn_web::repository(Post, api = "/api/posts", policy = PostPolicy, mcp)]
pub trait PostRepository {}
// reads only — list and get:
#[autumn_web::repository(Post, api = "/api/posts", policy = PostPolicy, mcp = "read")]
pub trait PostRepository {}
- Bare
mcpopts in all five operations. The usual safety annotations apply:list/getcarryreadOnlyHint: true, anddeletecarriesdestructiveHint: true, so agents and UIs can warn before destructive calls. mcp = "read"opts in onlylistandget.mcprequiresapi = "/path"(there are no routes to derive tools from otherwise) — the macro rejects it at compile time.grant = Xgoverns the generated tools with an authority grant. It needsmcp. The build fails if the grant does not list the model inwrites.- Every tool call still dispatches through the real pipeline, so a
policy = ...repository enforces the same record-level checks for an agent as for any HTTP client.
The create/update tools take a body argument referencing the generated
New<Model>/Update<Model> component schemas. By default those resolve to
placeholder object schemas; register the real schemas on your
OpenApiConfig (the same registration the OpenAPI document uses) to give
agents fully-typed inputs.
11. Plugins and route-level opt-in
Typed routes registered by a plugin — via
AppBuilder::routes() or scoped() inside Plugin::build — flow into the
same route registry as your own, so a plugin route tagged #[api_doc(mcp)]
becomes a tool exactly like a user route.
More often, a plugin author doesn't want to hard-wire the decision. The
chainable Route toggles — Route::mcp(), Route::mcp_exclude(),
Route::mcp_stream(), Route::mcp_task() and Route::mcp_core() — mirror the attribute forms at registration time, so
a plugin can offer a fluent switch and let the host decide at install
time:
use autumn_web::Route;
pub struct HarvestPlugin {
expose_mcp: bool,
}
impl HarvestPlugin {
#[must_use]
pub fn expose_mcp(mut self) -> Self {
self.expose_mcp = true;
self
}
}
impl Plugin for HarvestPlugin {
fn build(self, app: AppBuilder) -> AppBuilder {
let mut rs = routes![list_runs, create_run, signal_run];
if self.expose_mcp {
rs = rs.into_iter().map(Route::mcp).collect();
}
app.routes(rs)
}
}
// Host app: the management API becomes MCP tools only because the host said so.
autumn_web::app()
.plugin(HarvestPlugin::new().expose_mcp())
.mount_mcp("/mcp")
.run()
.await;
The toggles follow the same semantics as the attributes: an explicit
mcp() exposes any verb, mcp_exclude() always wins (even over
expose_all_as_mcp()), mcp_stream() implies the opt-in while
exempting an Sse route from the JSON-out gate, and mcp_task() implies the
opt-in and makes the tool a task tool (§6). mcp_core() implies the opt-in
and marks a core tool (§3). The flags are plain
ApiDoc metadata, so a plugin crate can set them while compiling against
base autumn-web — they take effect only when the host enables the mcp
feature and calls mount_mcp.
Mixed route sets: don't map
mcp()uniformly over a set that containsSsehandlers — a streaming route has no JSON response schema, so plainmcp()leaves it ineligible and it is skipped with the build-time warning above. Applymcp_stream()to the streaming routes instead:Rustrs = rs .into_iter() .map(|r| { // `Route.name` is the handler fn name; pick out the SSE routes. if r.name == "watch_run_events" { r.mcp_stream() } else { r.mcp() } }) .collect();
Limitation: raw routers mounted via
nest()/merge()are opaque to the route registry — Autumn cannot derive tools from them. Register typed routes if a plugin's endpoints should be MCP-exposable.
12. End-to-end example
examples/todo-app ships an /mcp endpoint. Its bearer-token JSON API is
mounted in a scoped("/api", RequireApiToken, …) group and tagged
#[api_doc(mcp)], exposing a read tool (list_json), an explicitly-opted-in
write tool (create_json), and a streaming tool (scan_json, tagged
#[api_doc(mcp, stream)]) — all behind the same token auth a mobile client uses:
.scoped(
"/api",
RequireApiToken::new(Arc::new(deferred.clone())),
routes![
routes::api::list_json,
routes::api::create_json,
routes::api::scan_json, // streaming: Sse → notifications/progress
],
)
.mount_mcp("/mcp")
Run it, issue a token via POST /api/tokens, then tools/list and
tools/call against /mcp with that token in the Authorization header. Call
scan_json with a _meta.progressToken and an Accept: text/event-stream
header to watch progress frames arrive as the scan runs.
13. How a tool call is dispatched (and why it can't loop)
When a tools/call arrives, the MCP handler reconstructs an ordinary HTTP
request — filling the path template, building the query string, and attaching
the JSON body — forwards the Authorization header, and replays it through a
clone of the fully-assembled application router. Because it traverses the same
routes, layers, and middleware an external request would, security and
validation are shared, not re-implemented.
The dispatch target is a snapshot of the router taken before the /mcp
route is merged in. So the routing graph is acyclic by construction:
agent → POST /mcp → serve_mcp → dispatch (a router that has no /mcp route)
→ your handler → JSON → tool result
A tool call resolves to a normal handler in one hop and returns; it can never
re-enter the MCP endpoint. (Tool paths are derived only from your route
registry and are never /mcp to begin with — the pre-merge snapshot upgrades
that from convention to a structural guarantee.)
Caveat — mount path collisions. Autumn does not yet pre-check that your chosen
mount_mcppath is free. If a real handler is already mounted at the same path,axumpanics at startup on the duplicate route (loud and early, not a silent failure). Pick a path you don't otherwise serve —/mcpis the convention. Relatedly, a user-defined route at an auto-mounted probe path (GET /health,/live,/ready,/startup) now wins over the built-in probe and logs an INFO override rather than panicking at startup.
14. Scope and roadmap
This slice is tools-only. Tool results are buffered by default, with opt-in progressive streaming over SSE (§5) and opt-in tasks (§6). The following remain out of scope and are tracked as follow-ups:
- Daemon lifetime for long-running, session-surviving work — #1119.
- Durable workflow tools — exposing Harvest
#[workflow]s as start/status/signal MCP tools on top of this layer (autumn-harvest#597); the management API itself can already be exposed via the plugin route toggles (§11). - Tool declarations for raw
nest()/merge()routers — opaque routers carry noApiDoc, so plugins must register typed routes to be MCP-exposable (§11). - MCP resources, prompts, and sampling — this slice is tools-only.
- A catalog per client —
mcp_catalogsets one catalog for the endpoint. The endpoint does not servetools/listpagination ornotifications/tools/list_changed. - stdio transport — agents target deployed apps, so HTTP only for v1.
- Non-JSON endpoints (file upload/download, HTML).
- LLM-assisted tool descriptions — descriptions come from
#[api_doc]; garbage-in/garbage-out is the author's call.
For the typed JSON-Schema derivation this builds on, see the
OpenAPI guide (and
openapi.rs); for the in-process dispatch
path, see the Testing guide.