navree
Documentation

MCP servers

Expose your app's tools, resources and prompts to Claude, ChatGPT, Cursor and any MCP client, over streamable HTTP or stdio, with Navree auth or OAuth and per-tool permissions.

On this page

Introduction

Featuremcp The Model Context Protocol (MCP) is how AI clients reach outside themselves. With an MCP server in your app, a sales rep can ask Claude "Log a call with Dana on the Acme deal: she approved the budget" and the call lands in your CRM, written by your own service, checked by your own access rules and recorded in your audit log.

navree-mcp turns methods on your services into an MCP server. It is built on the official Rust MCP SDK (rmcp), behind Navree's own macros:

Rust

An MCP server offers three kinds of things:

KindWhat it isNavree
ToolsActions the model can take, with typed arguments#[mcp_tool]
ResourcesData the client can read and attach to a conversation, by URI#[mcp_resource]
PromptsTemplates the user picks from a menu, like "Draft a follow-up email"#[mcp_prompt]

Your app serves them over streamable HTTP at /mcp, for remote clients such as Claude, ChatGPT and Cursor, and over stdio, for clients that start a local process. Every request is authenticated, every tool declares who may use it, and a client only sees what its user may do. Navree's packages add servers of their own to the same endpoint: with Lighthouse in the app, agents read and resolve its issues.

Installation

Turn on the mcp feature. It is opt-in: full (the default) doesn't include it, so name it next to the defaults. Tool and prompt arguments derive utoipa's ToSchema, so add utoipa too:

Cargo.toml

Add McpModule to the app, next to the HTTP and auth modules:

src/app.rs

McpModule needs HttpModule for the HTTP transport and an authenticator, usually AuthModule, because the endpoint is never public as a whole. Items that declare roles, permissions or abilities need AccessModule, like routes do: without it the boot fails, naming the first such item. Tools that write need DatabaseModule, because each call runs in its own unit of work. With CacheModule, sessions are kept in the cache.

Configuration

config/app.toml
KeyDefaultMeaning
mcp.path/mcpWhere the HTTP transport is served. Absolute: no API prefix, no version.
mcp.nameapp.name, else the executable's nameThe server name clients show.
mcp.titlemcp.nameA display title.
mcp.versionapp.version, else 0.0.0The server version clients see.
mcp.instructionsnoneText the client gives the model about your server: what it is for and how its data is shaped.
mcp.authbearerbearer or oauth. See authentication.
mcp.allowed_origins[]Browser origins allowed to call the endpoint. Requests with any other Origin header get a 403; requests without one (MCP clients aren't browsers) pass. A list, or a comma-separated string.
mcp.session_ttl_secs3600How long an idle session lives.
mcp.max_request_bytes1048576The largest JSON-RPC message accepted; a larger one gets a 413.
mcp.oauth.resourceapp.url + mcp.pathWith auth = "oauth": the resource identifier clients request tokens for.
mcp.oauth.authorization_servers[app.url]With auth = "oauth": the issuers that grant tokens for it.
mcp.oauth.scopesthe names of oauth.scopes, else []With auth = "oauth": the scopes listed as scopes_supported. By default, the scopes of your app's OAuth server.

Invalid values fail the boot, naming the key. From the environment: APP_MCP__AUTH=oauth, APP_MCP__ALLOWED_ORIGINS=https://crm.example.com,https://admin.example.com. In code, McpModule::for_root(McpOptions { name: Some("navree-crm".into()), ..McpOptions::default() }). The resolved settings are the McpSettings provider.

Defining a server

An MCP server is an #[injectable] struct whose impl block is marked #[mcp_server]. Its methods marked #[mcp_tool], #[mcp_resource] and #[mcp_prompt] become what the server offers, the way #[controller] turns methods into routes:

crates/crm-deals/src/mcp.rs

Register it in its module. Servers registered by several modules merge into the app's one endpoint, so each feature module contributes its own tools. The activities module adds log_activity the same way:

crates/crm-deals/src/lib.rs
crates/crm-activities/src/mcp.rs

m.mcp_server::<S>() registers S as a provider of the module (its Arc<T> fields are injected) and offers its items. Two tools, resources or prompts with the same name fail the boot, naming both modules. An access attribute on the impl block applies to every item that has none of its own.

navree make:mcp deals crm generates mcp/crm.rs in the deals module, a CrmMcp server with one tool (crm_echo), and registers it at the module's // navree:wire marker. It then prints what it leaves to you: the mcp feature, utoipa and McpModule.

Tools

A tool method takes &self, optionally the &McpContext, at most one arguments value and, with a from check, the record it loaded, and returns a Result of anything that serializes.

  • Description. The method's doc comment is the tool's description, which is how the model decides to call it. Say what it does, what it returns and its limits.
  • Name. The method name (list_deals). Set another with #[mcp_tool(name = "deals_list")], and a display name with title = "List deals". A name is 1 to 128 letters, digits, _, - or .; anything else doesn't compile.
  • Arguments are one struct deriving Deserialize, ToSchema and garde::Validate, like a validated request body. The tool's input schema comes from ToSchema, with field doc comments as descriptions; a tool without arguments has an empty object schema. Invalid arguments never reach your method: the client gets a tool error with the field messages (a 400 body).
  • Records. #[can(Update, Deal, from = "deal_id")] loads the record whose id is the deal_id argument and checks the action on it before the method runs, as from = "id" does on a route; the method takes it as an Authorized<Deal, Update> parameter. A missing id is a 400, a record that doesn't exist a 404 and a refusal a 403, all in the tool result. Inside a method, cx.authorize::<E, A>(id) does the same for any id, as move_deal does above.
  • Results. A value that serializes to a JSON object is sent as structured content and as its JSON text. A String is sent as plain text. Any other value (a list, a number) is sent as JSON text, with structured content {"result": <value>}, since MCP's structured content is always an object.
crates/crm-deals/src/mcp.rs

Agent tools and MCP tools take the same kind of arguments and call the same services, so one service method can back both an agent's tool and an MCP tool.

Hints

MCP clients use hints to decide what to confirm with the user. Set them on the attribute:

AttributeHint sentMeans
#[mcp_tool(read_only)]readOnlyHint: trueChanges nothing. Clients may run it without asking.
#[mcp_tool(destructive)]destructiveHint: trueMay overwrite or remove data. Clients ask first.
#[mcp_tool(idempotent)]idempotentHint: trueCalling it twice with the same arguments does no more than once.
#[mcp_tool(open_world)]openWorldHint: trueReaches outside your app, such as sending mail.

Every tool sends all four hints, false unless set. A tool without read_only is treated as a write by clients. Hints describe the tool to the client; they don't replace access checks.

Errors

A tool that returns Err answers with a tool result marked isError, whose text (and structured content) is Navree's error body, so the model can read it and recover:

Tool result

The app's error mappers apply, as they do on a response. Server errors (5xx) read "Internal server error"; their details go to the log with the request id. Protocol problems are JSON-RPC errors: an unknown tool (or one the user may not see) is -32602 Unknown tool: <name>, and a malformed message is refused by the SDK. Failed resource reads and prompts are JSON-RPC errors too, with Navree's error body as data: a 404 is -32002 (resource not found), a 400 is -32602 (invalid params) and anything else -32603.

Long-running tools

cx.progress(done, total, message) (f64, Option<f64>, text) sends a progress notification when the client asked for them with a progress token, and does nothing otherwise (cx.wants_progress() tells you which). Over HTTP, the first notification turns the response into an SSE stream that carries the notifications and then the result. Anything longer than a minute belongs in a queued job: start it, return its id, and offer a tool or resource that reports its status.

Resources

A resource is data a client can read by URI, show to the user and attach to a conversation. URI templates name their parameters in braces, and from = "id" loads and authorizes the record exactly as it does on a route:

crates/crm-deals/src/mcp.rs
  • Fixed URIs (crm://pipeline) are listed in resources/list. Templates (crm://deals/{id}) are listed in resources/templates/list, and clients fill them in. A URI without a scheme doesn't compile, and neither does a from that names no URI variable.
  • A template's other variables reach the method as parameters with the same name, of any FromStr type (slug: String, n: u32); a value that doesn't parse is a 404.
  • A method returns anything that serializes (sent as JSON, with the declared mime, application/json by default), a String (sent as text, text/plain by default), or ResourceContents (or a Vec of them): ResourceContents::blob(bytes, mime) for binary data such as a PDF from storage, ResourceContents::text(text, mime) and ResourceContents::json(&value) (which returns a Result).
  • The resource's name is the method name; set name = ".." and title = ".." on the attribute.
  • Clients can subscribe to a resource they may read. Inject Arc<McpNotifier> into any service and call notifier.resource_updated("crm://deals/0199…").await, for example next to the deal's real-time broadcast: subscribed sessions are told to read it again. It returns the number of sessions told.

Prompts

A prompt is a template the user picks in the client, with arguments it fills in. It returns the messages that start the conversation:

crates/crm-deals/src/mcp.rs
  • The arguments struct's fields become the prompt's arguments, with their doc comments and whether they're required. Clients send every argument as a string; they're converted to the field types (numbers, booleans, ...) and validated before the method runs.
  • PromptMessage::user(text), PromptMessage::assistant(text) and PromptMessage::resource(uri) build the messages. A resource message embeds the resource's current contents, read with the user's access: a resource they can't read fails the prompt.
  • A prompt may also return one PromptMessage or a String (one user message).

Authentication

The endpoint authenticates every request; there is no anonymous MCP session. A tool, resource or prompt marked #[public] can still be listed and used by any signed-in client.

Bearer tokens

With mcp.auth = "bearer" (the default), clients send Authorization: Bearer <token>, and the token goes through your app's authenticators like any API request: a navree-auth access token, or a personal access token from the OAuth server. A request without a valid token gets a 401 with WWW-Authenticate: Bearer (an authenticator's own challenge, such as error="invalid_token", is kept).

Personal access tokens suit MCP well: they are long-lived, revocable, and limited to the scopes you give them. A user creates one in your app's settings, limited to scopes such as deals:read and activities:write (which grant the permissions deals.read and activities.create), and pastes it into their client:

Shell

OAuth

With mcp.auth = "oauth", MCP clients sign users in themselves, the way the MCP authorization spec describes. McpModule is the resource server: it serves the protected resource metadata (RFC 9728) and points clients at it. The authorization server is your app's OAuthServerModule by default, or any issuer you list in mcp.oauth.authorization_servers:

  1. The client calls /mcp without a token and gets a 401 with WWW-Authenticate: Bearer resource_metadata="https://crm.example.com/.well-known/oauth-protected-resource/mcp".
  2. It reads that document (also served at /.well-known/oauth-protected-resource): the resource, its authorization_servers, bearer_methods_supported, the resource_name (mcp.title) and scopes_supported (mcp.oauth.scopes, by default the OAuth server's scopes such as deals:read). Then it reads the authorization server's metadata and registers itself (dynamic client registration, when oauth.dynamic_registration is on).
  3. It sends the user to the sign-in and consent screen with PKCE. The user approves the scopes the client asked for.
  4. It calls /mcp with the access token it received, which your app's authenticators check like any other bearer token.

OAuth mode needs the app.url setting (or mcp.oauth.resource and mcp.oauth.authorization_servers); without it the boot fails. A token is limited to its scopes' permissions, OAuth access tokens and personal access tokens alike: a client granted only deals:read sees list_deals and the deal resources, and not log_activity, even when the user could log activities in the app. Users see and revoke connected clients in your app, like any other OAuth client.

Per-tool permissions

Every tool, resource and prompt declares who may use it, with the same attributes as a route: #[public], #[authenticated], #[roles(..)], #[permissions(..)] or #[can(..)], on the method or on the impl block. One without a declaration doesn't compile, the same rule that keeps a route from being exposed by accident (routes deny until you allow them).

  • Lists are filtered. tools/list, resources/list, resources/templates/list and prompts/list return only what the session's user, and its token's abilities, allow. A client never shows a tool its user can't use.
  • Calls are checked. Calling a hidden tool is answered as an unknown tool, and reading a hidden resource as a resource that doesn't exist. Record-level checks (from = "id", cx.authorize) run on every call, and a refusal is a 403 or 404 in the tool result. A refusal is logged at navree::security and, with an audit sink, recorded as access.denied.
  • Lists change with access. When a user's roles or permissions change, the session is sent notifications/tools/list_changed (and the resource and prompt equivalents) with its next request, and the client fetches the lists again. To tell every open session at once, for example after you deploy new tools behind a flag, call McpNotifier::tools_changed() (or resources_changed(), prompts_changed()).

What runs inside a tool is your service code, so the rest of your security model applies as it is: list filters through cx.accessible, writes in the call's own unit of work (committed when the tool returns Ok, rolled back on Err), audit entries with the user as the actor, and jobs and events queued only on commit.

McpContext has the same helpers as an agent's AgentRun: user() (loaded through AuthService), user_id(), access(), ability(), accessible::<E, A>(), authorize::<E, A>(id), uow() and now(), plus principal(), session_id(), client() (the client's name and version), request_id(), app(), wants_progress() and progress(..).

Transports

Streamable HTTP

The HTTP transport is mounted at mcp.path on your app's own server, as three authenticated routes:

RequestRoute nameDoes
POST /mcpmcp.messageSends a JSON-RPC message. The answer is JSON, or an SSE stream when the server has progress or notifications to send first.
GET /mcpmcp.streamOpens an SSE stream for messages the server starts, such as resource updates and list changes.
DELETE /mcpmcp.closeEnds the session.

With mcp.auth = "oauth", the metadata routes are mcp.metadata and mcp.metadata.endpoint. All of them appear in the OpenAPI document under the tag MCP, with their names as operation ids.

The first initialize call creates a session; its id comes back in the Mcp-Session-Id header, and the client sends it with every later request. A session belongs to the user who opened it: another user sending its id gets a 404. With CacheModule, sessions live in the app's cache, so any process behind your load balancer can serve the next request; without it, in the process. Requests go through the whole HTTP stack: request ids, the per-IP rate limit, the app's CORS settings, logs and metrics, plus the Origin check against mcp.allowed_origins.

Sessions are how protocol 2025-11-25 and earlier work. Clients on the stateless protocol, 2026-07-28, skip the handshake: they ask server/discover for the server's versions and capabilities, send no session id, and put the protocol version, client info and capabilities in every request's _meta (a request without its protocol version gets a 400 with -32602). Each request is authenticated and checked on its own, and resource updates and list changes reach them on a subscriptions/listen stream instead of GET /mcp. The endpoint serves both protocols; you configure nothing.

stdio

The mcp:serve command serves the same tools over standard input and output, for clients that start a local process:

Shell

It reads JSON-RPC messages on stdin, writes them on stdout and logs to stderr (LoggerModule writes there while a command owns stdout). There is no token: the process acts as the user named by --user, an email address (which needs AuthModule) or a user id, with that user's roles and permissions. Give it to people who already have shell access to a machine that can reach the database. A client configures it as a command:

Client configuration

navree::mcp::serve_stdio(ctx, user, reader, writer) runs the same server on any pair of streams, for tests and custom launchers.

Connecting clients

Remote clients connect to the HTTP endpoint. With mcp.auth = "oauth" they sign the user in themselves; with bearer tokens, give them a personal access token.

Shell

The MCP Inspector (npx @modelcontextprotocol/inspector) connects to either transport and lets you list and call everything by hand, which is the fastest way to see what a client sees.

Observability

Each tools/call, resources/read and prompts/get writes one log line, mcp call, at target navree::mcp with the method, the tool, resource or prompt name, user_id, the client name, duration_ms, outcome (ok, error, or unknown for a name the user can't see) and the request_id; arguments and results are never logged. The metrics mcp_requests_total (labels method, name, outcome) and mcp_request_duration_seconds (labels method, name) go to /metrics with ObserveModule; unknown names are labelled unknown, so clients can't grow the label set. Writes made by tools are in the audit log like any other, with the user as the actor.

Testing

McpTestClient speaks the protocol to your test app in process, through its whole HTTP pipeline. Build it from any TestClient, so it acts as whoever that client acts as, and give it a bearer token when the client carries one per request:

apps/crm-api/tests/mcp.rs
Method
McpTestClient::new(client)Wraps a TestClient; its acting_as principal, cookies or guest status carry over. Requests carry no Origin header.
.bearer(token)Sends Authorization: Bearer <token> with every request.
.path(path), .protocol_version(version)Another mcp.path; another protocol version (default 2025-11-25). "2026-07-28" speaks the stateless protocol (is_stateless()).
.initialize()Performs the handshake and keeps the session id (session_id(), server_info()). On the stateless protocol, asks server/discover instead and keeps no session.
list_tools(), list_resources(), list_resource_templates(), list_prompts()What this user sees.
call_tool(name, args)A ToolResult: assert_ok(), assert_error(status, message), assert_json_path(path, value), json::<T>(), text(), structured(). call_tool_with_progress asks for progress notifications.
read_resource(uri)A ResourceResult, with the same assertions plus text(), blob() and mime_type().
get_prompt(name, args)A PromptResult: messages(), text(index), description(), assert_ok(), assert_error(..).
subscribe(uri), listen()Subscribes to a resource; opens the GET stream, or on the stateless protocol a subscriptions/listen stream for the subscribed resources and every list (NotificationStream::next(wait)).
notifications()Notifications the server sent this session on request streams.
request(method, params), close()Any JSON-RPC request; ends the session.

Test the access rules of each tool the way you test routes: one user who may, one who may not, and the list each of them sees.

Reference

Name
McpModulefrom_config() (section mcp), for_root(McpOptions). Provides McpSettings, McpRegistry and McpNotifier.
#[mcp_server]On the impl block of an injectable; collects its tools, resources and prompts. Import it from navree::mcp.
#[mcp_tool]A tool. Options: name, title, read_only, destructive, idempotent, open_world.
#[mcp_resource(uri)]A resource or resource template. Options: name, title, mime.
#[mcp_prompt]A prompt. Options: name, title.
McpContextThe caller: user, access, unit of work, session, client, progress.
PromptMessage, ResourceContentsPrompt messages; text, JSON or binary resource contents.
McpNotifierresource_updated(uri), tools_changed(), resources_changed(), prompts_changed().
m.mcp_server::<S>()Registers a server in a module (ModuleBuilderMcpExt).
McpServer, McpItem, ToolDef, ResourceDef, PromptDefWhat #[mcp_server] generates, for servers written by hand.
mcp:serve --user <email or id>The stdio transport (serve_stdio in code).
navree make:mcp <module> <name>Generates a server.
navree::mcp::testing::McpTestClientThe test client.
navree::mcp::rmcpThe MCP SDK, for protocol types such as rmcp::model::Tool.