MCP: tools and resources for agents
Since CrateStack 0.13.0. Before 0.13.0 there is no MCP runtime. A few behaviours changed in
0.14.0 and are marked (since 0.14.0) below. 0.13.x is yanked on crates.io, deprecated on npm and
retracted on pub.dev: 0.13.1 shipped breaking changes as a patch release, and 0.14.0 carries them.
Install 0.14 or later. The design is ADR 0002; the tracking epic is
cratestack#1033.
2026-07-28. Procedures you
annotate become MCP tools. Models you annotate become read-only MCP resources. Every call
goes through the same generated policy checks as REST and RPC: an agent can do exactly what the
same caller could do over REST, and nothing more.
MCP is opt-in twice. You enable a Cargo feature, and you annotate each declaration you want to
expose. Nothing without an annotation reaches an agent.
Enable it
Turn on themcp feature of the facade you already use. Tools work on cratestack-pg and
cratestack-api. Resources need cratestack-pg. cratestack-sqlite has no mcp feature (the
embedded role enforces no policy), and cratestack-client serves nothing.
mcp-operator example.
The schema rules
mcp { }takesexpose = [tools],[resources]or both.nameis required whenresourcesis exposed, and refused otherwise.nameis the host of every resource URI (cratestack://blog/...), so it is a DNS label:- lowercase letters, digits and
-, 1 to 63 characters, not starting or ending with-; - it may not have
--as its 3rd and 4th characters (IDNA’s reserved form, as inxn--); - it must contain a letter, so
127is refused.
- lowercase letters, digits and
@mcp(tool: "name")exposes a procedure. A bare@mcp(tool)uses the procedure’s own name.description:is optional. The procedure must have an@allow;@denyalone is not enough.@@mcp(resource: "segment")exposes a model. It must have a read allow,@@allow("read", ...)or@@allow("all", ...). An optionalmax_page_size:(1 to 200) lowers the page-size cap.
mcp feature is on, any MCP declaration is a compile error that tells you to enable it.
Some procedures can’t be tools, and saying @mcp(tool) on them is a compile error too:
@stream procedures (a tool result is one response), and procedures whose input or output reaches a
type with no faithful JSON Schema: Json, FindMany, Vector, Geography and Geometry. A
Decimal needs the schema’s decimal = RustDecimal | BigDecimal argument.
Serve it over stdio
The schema macro generatescratestack_schema::mcp. Its tools(db, registry, resolvers) value is
the tool table. Over stdio you name the caller explicitly. There is no default identity, and an
anonymous context is refused (since 0.14.0): StdioServer::new returns
StdioConfigError::AnonymousContext for a context that isn’t authenticated, the same rule the HTTP
guard applies to your AuthProvider. On 0.13.0 it accepted one and served every call as nobody.
tracing to stderr, because stdout carries only MCP messages:
tracing_subscriber::fmt().with_writer(std::io::stderr). The server exits when stdin closes.
Serve it over Streamable HTTP
Mount it on your axum router, behind the sameAuthProvider your REST routes use:
The token is removed from the request once your provider has run. Every call then runs under the
CratestackContext your provider built.
A provider that does not use bearer tokens
(Unreleased, cratestack#1243.) The table above is the default rule: a request needsAuthorization: Bearer before your provider runs. A
provider that authenticates another way (a device signature, a COSE envelope, mTLS in the request’s
extensions) sets the rule on the builder:
CredentialPresence::bearer()is the default, unchanged.CredentialPresence::provider_decides()sends every request to your provider.CredentialPresence::when(|parts| ...)tests the request head (method, URI, headers and extensions, never the body).with_credential_headersnames the headers that carry the credential. They are removed before the MCP server sees the request, asAuthorizationis, and their values are cut out of your provider’s logged error detail.
resource_metadata challenge. An
AuthRefusal from your provider keeps its status, its envelope
members and details, and its WWW-Authenticate challenges follow the guard’s own.
What a tool call goes through
- The tool name is looked up. An unknown name is JSON-RPC
-32602. - The arguments are decoded into the procedure’s
Args. A failure is anisErrorresult that names the field. - If you passed an
OpExecutorwithwith_executor, L3 admission runs: rate limiting, then idempotency. An idempotency key travels in_meta["dev.cratestack/idempotencyKey"]; without one, nothing is reserved. A failing rate-limit store follows theStoreErrorPolicyyou pass towith_store_error_policy, the same typeRateLimitLayeruses on HTTP. The budget is per caller and per transport: a caller’s MCP calls and its REST calls are counted separately. (since 0.14.0) The MCP key holds a SHA-256 of the caller’s id, not the id itself. After upgrading from 0.13.0 with a shared store, MCP idempotency records written by 0.13.0 no longer replay and MCP rate-limit buckets start fresh. - The procedure’s generated
invoke_with_dbruns@allow/@denyand any@authorize(...), then your implementation. Its ORM calls carry@@allowin their SQL. (Since 0.14.1.) For a procedure that declares@isolation, all of this runs in one transaction at the declared level and is retried on a serialization failure or deadlock, as on REST and RPC. When the retries run out, the result is anisErrorresult with codeTRANSACTION_ABORTED, and the idempotency reservation is released rather than recorded, so the same key runs the call again. In 0.14.0 and earlier,@isolationhas no effect on MCP either.
isError: true results whose text is REST’s error envelope (code, message), so MCP
reveals nothing REST doesn’t.
Resources
A model annotated@@mcp(resource: "posts") is readable at:
cratestack://blog/posts/{id}: one record, shaped exactly like REST’sGET /posts/{id}.@server_onlyfields are absent and@computedfields are resolved.cratestack://blog/posts{?limit,cursor}: a page,{"items": [...], "nextCursor": "..."}, in primary-key order.limitdefaults to 50 and is clamped, not refused, at 200 or at the model’smax_page_size. PassnextCursorback ascursor.
@@allow("read", ...) is in the SQL. A row the caller may not
read and a row that doesn’t exist both return the same -32602 “resource not found”. So do a
malformed id and an id with a raw character that isn’t allowed in a URI path segment:
percent-encode those (a%20b reads id a b). The scheme is case-insensitive; the name, segment and
id are matched exactly.
Connecting a client
The client must speak protocol2026-07-28. The server doesn’t negotiate down, so a client that
opens with the older initialize handshake gets -32022. With the official
MCP Inspector CLI, pass --protocol-era modern.
The mcp-operator example’s README has Inspector commands and an mcpServers config for both
transports.
Not in v1
- CRUD-derived tools. Only procedures are tools, and models are read-only resources.
- Filtering
tools/listby the caller’s authorization. Every caller sees the whole table, and each call is still policy-checked. - MCP on the embedded role (
cratestack-sqlite). - A generic OAuth
AuthProvider. You bring your own, and it must check the audience.