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.
CrateStack can serve parts of a schema to AI agents over the Model Context Protocol, revision 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 the mcp 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.
Then declare what to expose:
A complete, runnable version of this is the mcp-operator example.

The schema rules

  • mcp { } takes expose = [tools], [resources] or both. name is required when resources is exposed, and refused otherwise.
  • name is 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 in xn--);
    • it must contain a letter, so 127 is refused.
  • @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; @deny alone is not enough.
  • @@mcp(resource: "segment") exposes a model. It must have a read allow, @@allow("read", ...) or @@allow("all", ...). An optional max_page_size: (1 to 200) lowers the page-size cap.
Every malformed or contradictory MCP declaration is a compile error, never silently ignored. Until the 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 generates cratestack_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.
Send 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 same AuthProvider your REST routes use:
The allowed browser origins and the provider are required. An empty origins list is refused. Before any MCP handling: 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 needs Authorization: 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_headers names the headers that carry the credential. They are removed before the MCP server sees the request, as Authorization is, and their values are cut out of your provider’s logged error detail.
A request found without a credential still gets the 401 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.
Your AuthProvider must check the token’s audience against the resource identifier. MCP requires it, and CrateStack ships no generic OAuth provider. The example’s src/token.rs shows an audience check.

What a tool call goes through

  1. The tool name is looked up. An unknown name is JSON-RPC -32602.
  2. The arguments are decoded into the procedure’s Args. A failure is an isError result that names the field.
  3. If you passed an OpExecutor with with_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 the StoreErrorPolicy you pass to with_store_error_policy, the same type RateLimitLayer uses 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.
  4. The procedure’s generated invoke_with_db runs @allow / @deny and any @authorize(...), then your implementation. Its ORM calls carry @@allow in 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 an isError result with code TRANSACTION_ABORTED, and the idempotency reservation is released rather than recorded, so the same key runs the call again. In 0.14.0 and earlier, @isolation has no effect on MCP either.
Errors are 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’s GET /posts/{id}. @server_only fields are absent and @computed fields are resolved.
  • cratestack://blog/posts{?limit,cursor}: a page, {"items": [...], "nextCursor": "..."}, in primary-key order. limit defaults to 50 and is clamped, not refused, at 200 or at the model’s max_page_size. Pass nextCursor back as cursor.
Reads use the caller’s context, so @@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 protocol 2026-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/list by 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.