Rustango docs
← Guides

MCP server

The Model Context Protocol (MCP) is the open standard for letting an AI agent — Claude, an IDE assistant, your own LLM app — securely call your application's tools, read its resources, and use its prompts. Rustango ships a production MCP server: register a tool with one macro, mount a router, and any MCP client can discover and call it over the standard JSON-RPC transport — with per-agent, fail-closed authorization and OAuth 2.1 built in.

MCP server in Rustango: an LLM agent connects over JSON-RPC + SSE; the server authenticates the agent's JWT, lists only the tools its granted skills allow, and runs the tool handler against your app's pool

New to a term here? MCP, JSON-RPC, tool/resource/prompt, agent, JWT, OAuth — see the glossary.

Source: rustango::mcp (router, tenant_router, secure_tenant_router, secure_tenant_router_from_settings, register_mcp_tool!, register_mcp_resource!, McpContext, issue_agent_token) and the rustango::tenancy agent/skill helpers — behind the mcp feature (OFF by default; pulls tenancy, sse, serializer, openapi, jwt).

Runnable version: every snippet is copied from mcp_doc.rs (cargo test -p rustango --features sqlite,mcp --test mcp_doc); the full protocol surface is dogfooded by the crates/rustango/tests/mcp_*.rs suite, and a runnable server lives in examples/mcp_demo.

Table of contents


What MCP gives you

A Rustango MCP server exposes three things an agent can use, all hand-registered for explicit control (nothing is auto-exposed):

PrimitiveWhat it isHow it's declared
Toola function the agent calls (with typed JSON args)register_mcp_tool!
Resourcereadable content the agent fetches by URIregister_mcp_resource! + skill-attached
Prompta reusable instruction templatederived from a granted skill

Every call is authorized per agent: an agent's JWT carries the skills (and the tools they unlock) it was granted, and tools/list / tools/call fail closed — an agent never sees or runs a tool it wasn't granted.


Step 1 — Enable the feature

MCP is the optional mcp feature (off by default). Turn it on:

# Cargo.toml
rustango = { version = "0.44", features = ["mcp"] }

It pulls in tenancy (agents/skills), sse (the notification stream), serializer + openapi (tool input schemas), and jwt (agent tokens). A build without the feature compiles none of the MCP module — see Optional vs default build.


Step 2 — Define a tool

A tool is a typed input struct + an async handler, registered at compile time with register_mcp_tool!. The input type derives serde::Deserialize and implements OpenApiSchema (which becomes the tool's published JSON Schema):

use rustango::mcp::{McpContext, McpError};
use serde_json::json;

rustango::register_mcp_tool!(
    "add",
    "Add two integers",
    AddInput,
    |_ctx: McpContext, input: AddInput| async move {
        Ok::<_, McpError>(json!({ "sum": input.a + input.b }))
    },
);

#[derive(serde::Deserialize)]
struct AddInput { a: i64, b: i64 }

impl rustango::openapi::OpenApiSchema for AddInput {
    fn openapi_schema() -> rustango::openapi::Schema {
        rustango::openapi::Schema::object()
            .property("a", rustango::openapi::Schema::integer())
            .property("b", rustango::openapi::Schema::integer())
            .required(["a", "b"])
    }
}

The handler gets an McpContext { pool, agent, progress, cancel } — the tenant DB pool, the authenticated agent, a progress reporter, and a cancellation token — so a tool can query your models, report progress on long work, and bail on cancel. Return any serde_json::Value (it's surfaced as the tool's structuredContent) or an McpError.

Resources are static content registered the same way:

rustango::register_mcp_resource!(
    "rustango://about", "About", "text/plain",
    || "This server exposes the demo tools.".to_string(),
);

Prompts come from skills (next step) — a skill's instructions become a prompt the agent can fetch.


Step 3 — Mount the server

Pick a mount to match your deployment; all return an axum::Router you nest under a prefix (conventionally /mcp):

MountTenancyAuthUse for
mcp::router(pool)single-tenantnonetransport only (initialize/ping)
mcp::tenant_router()multi-tenantnonetransport only (per-request pool)
mcp::secure_tenant_router()multi-tenantagent JWTthe real thing
mcp::secure_tenant_router_from_settings(&s)multi-tenantagent JWTproduction (CORS, rate-limit, SSE, body cap from [mcp])

Tools require the authed path (an agent context), so production servers use secure_tenant_router*:

use rustango::mcp;

let api = axum::Router::new()
    .nest("/mcp", mcp::secure_tenant_router_from_settings(&settings.mcp));
// hand `api` to your tenancy Cli/Builder as usual

The authed router mounts: POST {prefix} (JSON-RPC), GET {prefix} (SSE notifications), POST {prefix}/token (credential → JWT), POST {prefix}/oauth/token (OAuth 2.1), and the two .well-known/* discovery documents. It signs agent tokens with RUSTANGO_SESSION_SECRET.

The initialize handshake is a plain JSON-RPC POST and works on any mount:

// → POST /mcp
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
  "params": { "protocolVersion": "2025-06-18", "capabilities": {},
              "clientInfo": { "name": "my-client", "version": "0" } } }

// ← 200
{ "jsonrpc": "2.0", "id": 1, "result": {
    "protocolVersion": "2025-06-18",
    "serverInfo": { "name": "rustango", "version": "0.44.0" },
    "capabilities": { "tools": { "listChanged": true }, "prompts": {}, "resources": {} } } }

Step 4 — Authorize agents

Authorization is skill-based and fail-closed. You provision an agent (which gets a one-time secret), define a skill that bundles tools (and resources/prompt), then grant the skill to the agent in a tenant:

use rustango::tenancy::{create_agent_pool, create_skill_pool, grant_skill_pool};

// 1. Provision an agent — returns a one-time `name`.`secret` credential.
let issued = create_agent_pool(&pool, "calc-bot").await?;

// 2. A skill bundles tools (here, the `add` tool) + a prompt body.
create_skill_pool(&pool, "calculator", "Calculator", "does arithmetic",
                  "You are a precise calculator.", &["add".into()]).await?;

// 3. Grant it to the agent in tenant "acme".
grant_skill_pool(&pool, "acme", "calc-bot", "calculator").await?;

The client exchanges its credential for a tenant-pinned, scoped JWT at POST /mcp/token (or the OAuth client_credentials flow at /mcp/oauth/token). The server resolves the grant into the token's skills + tools claims; every request re-verifies it. The effect, verified end to end:

// tools/list returns ONLY the granted tool, with its JSON Schema:
let listed = list_tools(&agent);                       // → { "tools": [ { "name": "add", … } ] }

// tools/call runs the handler and returns a structured result:
let out = call_tool(ctx, json!({ "name": "add", "arguments": { "a": 2, "b": 3 } })).await?;
assert_eq!(out["structuredContent"]["sum"], 5);

// An agent WITHOUT the grant sees an empty list and is refused:
//   list_tools(&ungranted) → { "tools": [] }
//   call_tool(ungranted, "add") → Err(code = TOOL_FORBIDDEN)

Tokens are tenant-pinned: a token minted for acme is rejected against any other tenant (cross-tenant replay → 401). Revoke an agent and its JTI is blacklisted.

User-owned keys (permission-driven capabilities)

The agents above are standalone machine identities. A member can instead generate a personal key — a user-owned agent — so an LLM acts on their behalf, with capabilities that follow the tenant's existing RBAC instead of a list pinned onto the key.

Two pieces wire it up:

use rustango::tenancy::{create_user_key_pool, create_skill_pool, map_skill_to_permission_pool};

// 1. Map a skill to a permission codename. Any user-owned key whose owner
//    holds `mcp.coach` is then granted this skill's tools + prompt + resources.
create_skill_pool(&pool, "coach", "Coach", "logs workouts",
                  "You are the member's coach.", &["log_set".into()]).await?;
map_skill_to_permission_pool(&pool, "coach", "mcp.coach").await?;

// 2. The member generates a key — a one-time `name`.`secret`, shown once.
//    `&[]` = a FULL key: everything the owner is entitled to. Pass skill
//    codenames to SCOPE the key to a single skill or a skillset instead —
//    always bounded by the owner's entitlement (you can't exceed your perms):
let issued = create_user_key_pool(&pool, user_id, "Alice's phone", &[]).await?;
// scoped: create_user_key_pool(&pool, user_id, "coach bot", &["coach".into()]).await?;
println!("copy once: {}", issued.token);

At token-issue the server calls resolve_user_agent_grants_pool — the owner's effective permissions (user_permissions_pool, i.e. roles + direct grants − denials) select the mapped skills, whose tools/prompts/resources are flattened into the JWT's skills/tools claims. So tools/list, tools/call, prompts/get, and resources/read are all gated by RBAC, with no change to those handlers. The owning user rides in the token's uid claim; a tool handler reads it as ctx.agent.user_id to scope work to that member. Revoke fresh capabilities by changing the user's permissions (they re-resolve on the next token); revoke the key itself with revoke_user_key_pool(&pool, user_id, agent_id). List a member's keys with list_user_keys_pool(&pool, user_id).

Per-key scope vs per-user entitlement. Skills reach a key along two axes: the owner's entitlement (superuser → every skill; otherwise the skills mapped to a permission they hold) and the key's scope (skills pinned at creation). An unscoped key (skills = &[]) gets the owner's full entitlement; a scoped key (skills = &["coach", …]) is limited to those. Resolution always re-intersects scope with the current entitlement — so a key can never exceed the owner's permissions, and losing a permission narrows every key on the next mint. Scoping to a skill the owner isn't entitled to is refused at creation.

Standalone agents are unaffected — a machine agent (user_id = None) still uses only its explicit grant_skill_pool grants.

From the CLI (manage verbs)

Everything above is also available out of the box through the tenancy-aware manage dispatcher (these verbs compile in with the mcp feature). Each is tenant-scoped and takes a <slug>:

VerbWhat it does
create-agent <slug> <name>Provision a machine agent; prints its prefix.secret once.
rotate-agent-secret <slug> <name>Issue a fresh secret, invalidating the old one.
list-agents <slug>List a tenant's agents (id, name, status, prefix).
create-skill <slug> <codename> [--name ..] [--description ..] [--tools a,b] [--instructions ..]Define a skill (a bundle of tools + prompt).
grant-skill <slug> <agent> <skill>Grant a skill to an agent.
revoke-skill <slug> <agent> <skill>Revoke a skill from an agent.
list-skills <slug>List a tenant's skills.
create-user-key <slug> <username> [--label <l>] [--skill <codename>]…Issue a user-owned key; prints its token once. Repeat --skill to scope the key to a single skill or a skillset; omit for a full key (default label = username).
list-user-keys <slug> <username>List a user's personal keys (id, label, created-at).
revoke-user-key <slug> <username> <key_id>Revoke one of a user's personal keys by id (ownership-verified).
map-skill-permission <slug> <skill> <permission>Map a skill to a permission codename. Idempotent — any user key whose owner holds <permission> gains the skill.
unmap-skill-permission <slug> <skill> <permission>Remove a skill↔permission mapping.

The permission → skill → tools flow end to end:

# 1. Define the skill and map it to a permission codename.
$ cargo run -- create-skill acme coach --tools log_set --instructions "You are the member's coach."
$ cargo run -- map-skill-permission acme coach mcp.coach

# 2. Grant the permission to the member (roles or direct — see grant-perm),
#    then issue their personal key.
$ cargo run -- grant-perm acme alice mcp.coach
$ cargo run -- create-user-key acme alice --label "Alice's phone"
created key #7 for user `alice` in tenant `acme` (label `Alice's phone`, scope full (owner's permissions))
  token: 3f9c1a2b.7d…            # copy once — never shown again
  store this safely — it won't be shown again

# …or scope the key to a single skill / skillset (repeat --skill):
$ cargo run -- create-user-key acme alice --label "coach bot" --skill coach

Alice's key now resolves the coach skill's tools at every token-issue because she holds mcp.coach. Change her permissions and the capabilities re-resolve on her next token; revoke the key itself with revoke-user-key. The same key id is distinguishable from machine agents in the tenant admin (the Agent list shows user_id).

The auto-admin surfaces these too: Agent, AgentSkill, AgentSkillPermission (and AgentGrant) each render an auto-CRUD table in the tenant admin, so the skill↔permission mappings can be reviewed and edited without the CLI.


The protocol

JSON-RPC 2.0 (protocol version 2025-06-18) over HTTP POST, with an optional SSE stream (GET {prefix}) for server→client notifications. Methods:

MethodAuthPurpose
initialize · pingnohandshake + liveness
tools/list · tools/callyesdiscover + invoke tools (granted only)
prompts/list · prompts/getyesskill-derived prompts
resources/list · resources/read · resources/templates/listyesstatic + skill resources
logging/setLevel · completion/completeyeslog level + prefix completion
notifications/progress · notifications/*/list_changedserver→client over SSE
notifications/cancelledclient cancels an in-flight call

A failed tool handler returns a normal result with isError: true (the agent can react); protocol-level problems (unknown/forbidden tool, bad params) return a JSON-RPC error with codes like -32002 (TOOL_NOT_FOUND), -32003 (TOOL_FORBIDDEN), -32602 (INVALID_PARAMS). Long tools report progress and honor cancellation via the McpContext.


Settings

The [mcp] section (read by secure_tenant_router_from_settings):

[mcp]
prefix                = "/mcp"   # URL prefix the router mounts under
token_ttl_secs        = 900      # agent access-token lifetime (15 min)
enable_sse            = true     # serve the GET {prefix} SSE stream
allowed_origins       = []       # CORS allow-list (empty = same-origin only)
rate_limit_per_minute = 0        # per-IP cap (0/unset = unlimited)
max_tools_listed      = 0        # tools/list page size (0/unset = unlimited)

How to test

(a) The test suite

The whole protocol is covered by crates/rustango/tests/mcp_*.rs + the doc's backing test. Run them with the feature on:

# The doc's headline flow (register → initialize → grant → list → call → fail-closed):
cargo test -p rustango --features sqlite,mcp --test mcp_doc

# Slices + end-to-end + OAuth + settings:
cargo test -p rustango --features sqlite,mcp,config --test 'mcp_*'

(b) curl the JSON-RPC

Boot the demo (next section) and talk to it directly. The demo guards every method behind an agent token (an unauthed call returns 401), so mint one first — the demo prints the agent secret on boot:

TOKEN=$(curl -sX POST http://localhost:8090/mcp/token \
  -H 'content-type: application/json' -d '{"name":"demo-bot","secret":"<printed-secret>"}' \
  | jq -r .access_token)

# initialize:
curl -sX POST http://localhost:8090/mcp -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'

# tools/call — only the granted `add` tool is callable:
curl -sX POST http://localhost:8090/mcp -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}'
# → { ... "result": { "structuredContent": { "sum": 5 }, "isError": false } }

(c) Test it visually with the MCP Inspector

The MCP Inspector is the official visual client — connect it to your server and click through tools, resources, and prompts. Run the demo, then the Inspector:

# 1. Start the demo MCP server (seeds an `acme` tenant + `demo-bot` agent + the `add` tool):
cd crates/rustango/examples/mcp_demo && cargo run   # serves on http://localhost:8090/mcp

# 2. Launch the Inspector (opens a browser UI on http://localhost:6274):
npx @modelcontextprotocol/inspector

In the Inspector: set the transport to Streamable HTTP and the URL to http://localhost:8090/mcp. Open Authentication → Custom Headers, add a header Authorization with value Bearer <token> (mint the token with the /mcp/token call above), flip the row on, then Connect.

Switch to the Tools tab and click List Tools — you'll see only the add tool the agent's skill grants, with its JSON Schema. Select it, enter a = 2, b = 3, and Run Tool:

The MCP Inspector connected to the Rustango demo over Streamable HTTP, showing the granted add tool and its a/b input schema

The call returns a structured result — { "sum": 5 } — and the request shows up in the History pane (initializetools/listtools/call):

The same Inspector after running the tool: Tool Result Success with structured content { sum: 5 }, and the JSON-RPC call history

(d) Connect a real MCP client

Point Claude Code (or any MCP client) at the running server, passing the agent token as a header (mint it with the /mcp/token call above):

claude mcp add --transport http rustango-demo http://localhost:8090/mcp \
  --header "Authorization: Bearer $TOKEN"

Then ask the agent to add two numbers — it discovers and calls the add tool over the same protocol the Inspector used.


Optional vs default build

The feature is fully gated — the entire rustango::mcp module is behind #[cfg(feature = "mcp")], so it never affects apps that don't opt in:

cargo build -p rustango                 # default — MCP module NOT compiled
cargo build -p rustango --features mcp  # MCP server compiled + linked

A default app carries zero MCP code, dependencies, or routes; enabling the feature is the only thing that turns it on.


See also