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:
#[mcp_server]
impl DealsMcp {
/// Lists the deals the user can see, newest first.
#[mcp_tool(read_only)]
#[can(Read, Deal)]
async fn list_deals(&self, cx: &McpContext, args: ListDeals) -> Result<Vec<DealResource>> {
// ...
}
}An MCP server offers three kinds of things:
| Kind | What it is | Navree |
|---|---|---|
| Tools | Actions the model can take, with typed arguments | #[mcp_tool] |
| Resources | Data the client can read and attach to a conversation, by URI | #[mcp_resource] |
| Prompts | Templates 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:
[dependencies]
navree = { git = "https://github.com/NavreeHQ/navree", features = ["mcp"] }
utoipa = { version = "6", features = ["chrono", "uuid"] }Add McpModule to the app, next to the HTTP and auth modules:
use navree::prelude::*;
pub fn app(sources: ConfigSources) -> AppBuilder {
Navree::app()
.module(ConfigModule::for_root(sources))
.module(CryptoModule::from_config())
.module(HttpModule::from_config())
.module(DatabaseModule::from_config())
.module(AuthModule::from_config())
.module(AccessModule::for_root(access_options()))
.module(McpModule::from_config()) // section `mcp`; serves /mcp
.module(DealsModule)
// ...
}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
[mcp]
name = "navree-crm"
title = "Navree CRM"
instructions = """
Tools for the Navree CRM. Deals move through the stages lead, qualified, \
proposal, negotiation, won and lost. Amounts are in cents.\
"""
auth = "bearer"| Key | Default | Meaning |
|---|---|---|
mcp.path | /mcp | Where the HTTP transport is served. Absolute: no API prefix, no version. |
mcp.name | app.name, else the executable's name | The server name clients show. |
mcp.title | mcp.name | A display title. |
mcp.version | app.version, else 0.0.0 | The server version clients see. |
mcp.instructions | none | Text the client gives the model about your server: what it is for and how its data is shaped. |
mcp.auth | bearer | bearer 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_secs | 3600 | How long an idle session lives. |
mcp.max_request_bytes | 1048576 | The largest JSON-RPC message accepted; a larger one gets a 413. |
mcp.oauth.resource | app.url + mcp.path | With 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.scopes | the 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:
use crm_core::{Deal, Move};
use navree::mcp::{McpContext, PromptMessage, mcp_server};
use navree::prelude::*;
use crate::{DealBoard, DealFilter, DealResource, DealService, MoveDeal};
/// The deals part of the CRM's MCP server.
#[injectable]
pub struct DealsMcp {
deals: Arc<DealService>,
}
#[mcp_server]
impl DealsMcp {
/// Lists the deals the user can see, newest first, optionally only one stage
/// or the user's own. Returns at most 25.
#[mcp_tool(read_only)]
#[can(Read, Deal)]
async fn list_deals(&self, cx: &McpContext, args: ListDeals) -> Result<Vec<DealResource>> {
let filter = DealFilter {
stage: args.stage,
search: args.query,
owner_id: args.mine.then(|| cx.user_id()),
..DealFilter::default()
};
let page = PageQuery { per_page: 25, ..PageQuery::default() };
Ok(self.deals.list(cx.uow(), cx.accessible::<Deal, Read>(), filter, page).await?.data)
}
/// Moves a deal to another stage. Moving to `lost` needs a reason.
#[mcp_tool(destructive)]
#[can(Update, Deal)]
async fn move_deal(&self, cx: &McpContext, args: MoveDealArgs) -> Result<DealResource> {
let deal = cx.authorize::<Deal, Move>(args.deal_id).await?;
self.deals
.move_to(
cx.uow(),
cx.user_id(),
deal.into_inner(),
MoveDeal { stage: Some(args.stage), position: None, lost_reason: args.lost_reason },
)
.await
}
// resources and prompts: see below
}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:
impl DealsModule {
fn wire(&self, m: &mut ModuleBuilder) {
// ...
m.mcp_server::<DealsMcp>();
}
}/// The activities part of the CRM's MCP server.
#[injectable]
pub struct ActivitiesMcp {
activities: Arc<ActivityService>,
}
#[mcp_server]
impl ActivitiesMcp {
/// Logs a call, email, meeting or note on a deal, or adds a task with a due date.
#[mcp_tool]
#[can(Create, Activity)]
async fn log_activity(&self, cx: &McpContext, args: LogActivity) -> Result<ActivityResource> {
self.activities
.create(
cx.uow(),
cx.access(),
CreateActivity {
kind: args.kind,
subject: args.subject,
body: args.body,
due_at: args.due_at,
subject_type: SubjectType::Deal,
subject_id: args.deal_id,
assignee_id: None,
},
)
.await
}
}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 withtitle = "List deals". A name is 1 to 128 letters, digits,_,-or.; anything else doesn't compile. - Arguments are one struct deriving
Deserialize,ToSchemaandgarde::Validate, like a validated request body. The tool's input schema comes fromToSchema, 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 thedeal_idargument and checks the action on it before the method runs, asfrom = "id"does on a route; the method takes it as anAuthorized<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, asmove_dealdoes above. - Results. A value that serializes to a JSON object is sent as structured content and as its JSON text. A
Stringis 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.
#[derive(Debug, Deserialize, ToSchema, Validate)]
pub struct ListDeals {
/// Words from the deal title.
#[garde(length(chars, max = 100))]
pub query: Option<String>,
/// Only deals in this stage.
#[garde(skip)]
pub stage: Option<Stage>,
/// Only the user's own deals.
#[garde(skip)]
#[serde(default)]
pub mine: bool,
}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:
| Attribute | Hint sent | Means |
|---|---|---|
#[mcp_tool(read_only)] | readOnlyHint: true | Changes nothing. Clients may run it without asking. |
#[mcp_tool(destructive)] | destructiveHint: true | May overwrite or remove data. Clients ask first. |
#[mcp_tool(idempotent)] | idempotentHint: true | Calling it twice with the same arguments does no more than once. |
#[mcp_tool(open_world)] | openWorldHint: true | Reaches 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:
{"statusCode": 403, "message": "Forbidden", "error": "Forbidden", "code": "forbidden"}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:
#[mcp_server]
impl DealsMcp {
// ...
/// A deal with its company, contact, owner, stage and amount.
#[mcp_resource("crm://deals/{id}", mime = "application/json")]
#[can(Read, Deal, from = "id")]
async fn deal(&self, cx: &McpContext, deal: Authorized<Deal, Read>) -> Result<DealResource> {
self.deals.show(cx.uow(), deal.into_inner()).await
}
/// The pipeline: every stage with its deals, counts and totals.
#[mcp_resource("crm://pipeline", mime = "application/json")]
#[can(Read, Deal)]
async fn pipeline(&self, cx: &McpContext) -> Result<DealBoard> {
self.deals.board(cx.uow(), cx.accessible::<Deal, Read>(), Default::default()).await
}
}- Fixed URIs (
crm://pipeline) are listed inresources/list. Templates (crm://deals/{id}) are listed inresources/templates/list, and clients fill them in. A URI without a scheme doesn't compile, and neither does afromthat names no URI variable. - A template's other variables reach the method as parameters with the same name, of any
FromStrtype (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/jsonby default), aString(sent as text,text/plainby default), orResourceContents(or aVecof them):ResourceContents::blob(bytes, mime)for binary data such as a PDF from storage,ResourceContents::text(text, mime)andResourceContents::json(&value)(which returns aResult). - The resource's name is the method name; set
name = ".."andtitle = ".."on the attribute. - Clients can subscribe to a resource they may read. Inject
Arc<McpNotifier>into any service and callnotifier.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:
use navree::mcp::PromptMessage;
#[derive(Debug, Deserialize, ToSchema, Validate)]
pub struct FollowUpArgs {
/// The deal to follow up on.
#[garde(skip)]
pub deal_id: Uuid,
}
#[mcp_server]
impl DealsMcp {
// ...
/// Drafts a follow-up email to a deal's main contact from its recent activity.
#[mcp_prompt(name = "follow-up-email", title = "Draft a follow-up email")]
#[can(Read, Deal)]
async fn follow_up(&self, cx: &McpContext, args: FollowUpArgs) -> Result<Vec<PromptMessage>> {
let deal = cx.authorize::<Deal, Read>(args.deal_id).await?;
let deal = self.deals.show(cx.uow(), deal.into_inner()).await?;
Ok(vec![
PromptMessage::resource(format!("crm://deals/{}", deal.id)),
PromptMessage::user(format!(
"Read the deal and its activity (use list_deals and the deal resource), then draft \
a short follow-up email from me to {}. No filler. End with one clear next step.",
deal.contact.as_ref().map_or("the main contact", |c| c.full_name.as_str()),
)),
])
}
}- 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)andPromptMessage::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
PromptMessageor aString(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:
claude mcp add --transport http crm https://crm.example.com/mcp --header "Authorization: Bearer nvp_7Kq2..."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:
- The client calls
/mcpwithout a token and gets a 401 withWWW-Authenticate: Bearer resource_metadata="https://crm.example.com/.well-known/oauth-protected-resource/mcp". - It reads that document (also served at
/.well-known/oauth-protected-resource): theresource, itsauthorization_servers,bearer_methods_supported, theresource_name(mcp.title) andscopes_supported(mcp.oauth.scopes, by default the OAuth server's scopes such asdeals:read). Then it reads the authorization server's metadata and registers itself (dynamic client registration, whenoauth.dynamic_registrationis on). - It sends the user to the sign-in and consent screen with PKCE. The user approves the scopes the client asked for.
- It calls
/mcpwith 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/listandprompts/listreturn 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 atnavree::securityand, with an audit sink, recorded asaccess.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, callMcpNotifier::tools_changed()(orresources_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:
| Request | Route name | Does |
|---|---|---|
POST /mcp | mcp.message | Sends a JSON-RPC message. The answer is JSON, or an SSE stream when the server has progress or notifications to send first. |
GET /mcp | mcp.stream | Opens an SSE stream for messages the server starts, such as resource updates and list changes. |
DELETE /mcp | mcp.close | Ends 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:
./target/release/crm-api mcp:serve --user [email protected]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:
{
"mcpServers": {
"crm": {
"command": "/srv/crm/crm-api",
"args": ["mcp:serve", "--user", "[email protected]"]
}
}
}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.
claude mcp add --transport http crm https://crm.example.com/mcpIn Claude on the web or the desktop, add a custom connector in the connector settings with the URL https://crm.example.com/mcp. Claude signs in through OAuth.
Add a connector with the URL https://crm.example.com/mcp, on plans that support custom connectors. ChatGPT signs in through OAuth.
{
"mcpServers": {
"crm": { "url": "https://crm.example.com/mcp" }
}
}{
"servers": {
"crm": { "type": "http", "url": "https://crm.example.com/mcp" }
}
}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:
use crm_testkit::{Role, TestCrm};
use navree::mcp::testing::McpTestClient;
use serde_json::json;
#[tokio::test]
async fn reps_log_activity_but_cannot_move_others_deals() {
let crm = TestCrm::builder().app(crm_api::app).build().await;
let owner = crm.login_as(Role::Rep1).await;
let deal = create_deal(&owner, "Acme renewal").await; // POSTs a company and a deal
let rep = crm.login_as(Role::Rep2).await;
let mcp = McpTestClient::new(rep.inner().clone())
.bearer(rep.access_token())
.initialize()
.await;
let tools = mcp.list_tools().await;
assert!(tools.iter().any(|tool| tool.name == "log_activity"));
mcp.call_tool(
"log_activity",
json!({ "deal_id": deal["id"], "kind": "call", "subject": "Dana approved the budget" }),
)
.await
.assert_ok()
.assert_json_path("subject", json!("Dana approved the budget"));
mcp.call_tool("move_deal", json!({ "deal_id": deal["id"], "stage": "won" }))
.await
.assert_error(403, "Forbidden");
mcp.read_resource(&format!("crm://deals/{}", deal["id"].as_str().unwrap()))
.await
.assert_json_path("stage", json!("lead"));
}| 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 | |
|---|---|
McpModule | from_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. |
McpContext | The caller: user, access, unit of work, session, client, progress. |
PromptMessage, ResourceContents | Prompt messages; text, JSON or binary resource contents. |
McpNotifier | resource_updated(uri), tools_changed(), resources_changed(), prompts_changed(). |
m.mcp_server::<S>() | Registers a server in a module (ModuleBuilderMcpExt). |
McpServer, McpItem, ToolDef, ResourceDef, PromptDef | What #[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::McpTestClient | The test client. |
navree::mcp::rmcp | The MCP SDK, for protocol types such as rmcp::model::Tool. |