MCP
MCP (Model Context Protocol) is the spec language models use to call tools.
In the Publish workspace, the assistant lives in
assistant on port 3003.
A NestRS MCP server is a struct with a #[mcp] decorator: DI handles its
dependencies, #[tool] defines each tool, and the server self-mounts on the
HTTP transport at the path you choose. Same DI, same access graph, same
ambient ability — your tools live next to your controllers, not in a sidecar.
nest-rs-mcp builds on rmcp — the framework wraps it
so a tool server is an #[injectable] struct that self-mounts on the HTTP
transport, with schemars deriving the tool input
schemas the client reads.
Install
Section titled “Install”cargo add nest-rs-mcpThe smallest possible tool
Section titled “The smallest possible tool”The fastest way to see the shape: one tool, one string in, one string out.
use nest_rs_mcp::{ mcp, tool, tool_handler, tool_router, CallToolResult, Content, McpError, Parameters, ServerHandler,};use schemars::JsonSchema;use serde::Deserialize;
#[derive(Deserialize, JsonSchema)]pub struct HelloParams { pub name: String,}
#[mcp(path = "/mcp")]#[derive(Clone, Default)]pub struct HelloTool;
#[tool_router]impl HelloTool { #[tool(description = "Say hello to someone by name.")] async fn hello( &self, Parameters(params): Parameters<HelloParams>, ) -> Result<CallToolResult, McpError> { Ok(CallToolResult::success(vec![Content::text(format!( "Hello, {}!", params.name, ))])) }}
#[tool_handler]impl ServerHandler for HelloTool {}That’s a complete MCP server — but a bare #[mcp] endpoint is deny-all by
default: mounted without a dyn McpOperationGuard, it answers every request
401, so a schema-added tool can never leak unauthenticated. A deliberately
public tool has to say so, by binding AllowAllMcpGuard:
use nest_rs_core::module;use nest_rs_mcp::{AllowAllMcpGuard, McpOperationGuard};
use crate::hello::tool::HelloTool;
#[module(providers = [ HelloTool, AllowAllMcpGuard as dyn McpOperationGuard,])]pub struct HelloModule;#[mcp(path = "/mcp")] mounts it on the existing HTTP transport — same port,
same CORS, no second server. An MCP client pointed at
http://localhost:3003/mcp sees one tool called hello and can invoke it. (To
authenticate instead of opening it, see Authorization below.)
The three macros split the work:
#[mcp(path = "...")]registers the struct as a discoverable provider that mounts a streamable-HTTP endpoint atpathon the HTTP transport.#[tool_router]collects every#[tool(...)]method on the impl into the router the MCP runtime calls when a client invokes a tool.#[tool_handler]plugs that router into rmcp’sServerHandlertrait.
The struct can take #[inject] fields like any other provider — HelloTool has
none, real servers usually do.
Run it
Section titled “Run it”$ nestrs run dev assistant2026-06-03T10:34:07.882Z INFO nest_rs::http: bound 1 MCP server on 0.0.0.0:3003 POST /mcp → HelloTool (1 tool)Point an MCP-aware client at http://localhost:3003/mcp. With Claude Code, add
it over the streamable-HTTP transport:
$ claude mcp add --transport http hello http://localhost:3003/mcpOr explore the tool surface with the MCP Inspector — no client config needed:
$ npx @modelcontextprotocol/inspectorNow use it. Ask the model — in Claude Code, “say hello to Ada” — and it calls
the hello tool with { "name": "Ada" }; the tool answers Hello, Ada!. That
round trip — client → /mcp → your #[tool] method → response — is the whole
surface. You have a running assistant, discovered and wired like any other
provider.
Authorization
Section titled “Authorization”The quickstart opened /mcp deliberately with AllowAllMcpGuard. To
authenticate instead — bearer JWT, ability, row-level filtering — import
AuthzMcpModule, which binds
McpAbilityBridge as the
endpoint’s dyn McpOperationGuard (mirroring AuthzHttpModule,
AuthzGraphqlModule, and AuthzWsModule on the other transports). The bridge
re-runs your AuthGuard + AuthzGuard chain on every MCP request and seeds the
resulting Ability into the per-call context — the same guards HTTP and GraphQL
run, nothing re-implemented per transport.
#[module( imports = [ HttpModule::for_root(None), DatabaseModule::for_root(None), AuthnModule, AuthzMcpModule, ], providers = [HelloTool],)]pub struct AssistantModule;With AuthzMcpModule imported you drop AllowAllMcpGuard — the bridge is the
endpoint’s guard, so the deny-all default is replaced, not silenced. Tools then
read the ambient ability the same way HTTP handlers do: through a service that
goes through Repo, which applies the ability’s row-level filter automatically.
A tool that lists users gets only the users the caller is allowed to see — same
policy, same AppAbility, same audit log under nest_rs::authz. See the
per-transport bridges page for
the four-transport story.
Multiple tools on one server
Section titled “Multiple tools on one server”Each method under #[tool_router] becomes its own callable. Share state
through &self — #[inject] once, every tool reuses it.
#[mcp(path = "/weather")]#[derive(Clone)]pub struct WeatherTool { #[inject] svc: Arc<dyn WeatherService>,}
#[tool_router]impl WeatherTool { #[tool(description = "Return the current temperature for a city.")] async fn current_temperature( &self, Parameters(p): Parameters<CityParams>, ) -> Result<CallToolResult, McpError> { let t = self.svc.current(&p.city).await.map_err(internal)?; Ok(CallToolResult::success(vec![Content::text(format!("{t:.1}°C"))])) }
#[tool(description = "Return the forecast for a city, N days out.")] async fn forecast( &self, Parameters(p): Parameters<ForecastParams>, ) -> Result<CallToolResult, McpError> { let f = self.svc.forecast(&p.city, p.days).await.map_err(internal)?; Ok(CallToolResult::success(vec![Content::text(f.summary())])) }}
#[tool_handler]impl ServerHandler for WeatherTool {}The client now sees two tools — current_temperature and forecast — both
sharing the injected WeatherService. The description is what the model
reads to decide when to call each.
A validated tool
Section titled “A validated tool”Tool inputs are deserialized to a typed struct, so the schema the client reads
is already typed. For extra checks beyond shape — ranges, enums, mutual
exclusion — pair serde with validator:
use nest_rs_mcp::schemars;use serde::Deserialize;use validator::Validate;
#[derive(Debug, Deserialize, schemars::JsonSchema, Validate)]pub struct Coords { #[validate(range(min = -90.0, max = 90.0))] pub latitude: f64,
#[validate(range(min = -180.0, max = 180.0))] pub longitude: f64,}#[tool(description = "Return the current weather at the given GPS coordinates (Open-Meteo).")]async fn current_weather( &self, Parameters(params): Parameters<Coords>,) -> Result<CallToolResult, McpError> { params .validate() .map_err(|e| McpError::invalid_params(e.to_string(), None))?;
let report = self.svc .current(params.latitude, params.longitude) .await .map_err(internal)?;
Ok(CallToolResult::success(vec![Content::text(report.summary())]))}JsonSchema derives the schema sent to the client; validator rejects
out-of-range values with McpError::invalid_params — the model sees a
structured error and can retry with corrected inputs.
Multiple servers on one app
Section titled “Multiple servers on one app”A second server is a second #[mcp] struct on a different path. List both
in the module’s providers and each mounts on the HTTP transport independently
— different tools, different injected dependencies, different authorization
policy if you want one.
#[module( imports = [HttpModule::for_root(None), AuthzMcpModule], providers = [HelloTool, WeatherTool],)]pub struct AssistantModule;There is no per-server namespace primitive (no WsServer<N> analogue) —
clients distinguish servers by URL: /mcp for HelloTool, /weather for
WeatherTool. The MCP spec already namespaces tools per endpoint, so a second
endpoint is the namespace.
Resources and prompts
Section titled “Resources and prompts”MCP defines two primitives beyond tools: resources (read-only documents the
model can fetch) and prompts (parameterized templates the user picks).
Today, nest-rs-mcp ships only the tool surface — there is no #[resource]
or #[prompt] decorator, and the #[tool_handler] macro leaves the
list_resources / read_resource / list_prompts / get_prompt methods
on rmcp’s ServerHandler at their default (empty) impls.
If you need resources or prompts now, hand-implement the relevant
ServerHandler methods on your #[mcp] struct directly — the
#[tool_handler] macro is additive, not exclusive, and the raw rmcp surface
is re-exported from nest_rs_mcp. When the decorators land they will follow
the same shape as #[tool]: a method on the impl, schemars-derived input
schema, ambient Ability honoured the same way.
Error handling
Section titled “Error handling”McpError (re-exported as nest_rs_mcp::McpError, alias for rmcp’s
ErrorData) gives the model a structured failure with a JSON-RPC error code.
Six constructors cover the common cases:
| Constructor | When |
|---|---|
McpError::invalid_params(msg, data) | The tool input was syntactically valid JSON but semantically wrong — range, missing required field, mutual exclusion |
McpError::invalid_request(msg, data) | The whole request shape was off |
McpError::parse_error(msg, data) | The JSON itself couldn’t be parsed |
McpError::method_not_found::<MethodName>() | The client asked for a tool that doesn’t exist on this server |
McpError::resource_not_found(msg, data) | An identified resource isn’t there (when you ship resources) |
McpError::internal_error(msg, data) | Anything else — wrap an upstream failure |
A typical adapter for a domain error:
fn internal<E: std::fmt::Display>(e: E) -> McpError { McpError::internal_error(e.to_string(), None)}The data field accepts any JSON value — attach structured diagnostics there
when the model would benefit from them (e.g. the validator’s per-field errors).
Anything beyond these six codes flows through internal_error — there is no
custom-code constructor.
Trait-based DI
Section titled “Trait-based DI”Injecting Arc<dyn WeatherService> keeps the concrete provider
module-private — a test app can swap in a stub by overriding the provider via
AppBuilder::override_dyn. Same hexagonal pattern as the rest of the
framework: the tool depends on the trait, the impl lives behind the module,
the access graph rejects accidental coupling at boot.
Going further
Section titled “Going further”- Per-transport authz bridges
— how
AuthzMcpModuleplugs the ambientAbilityinto your tools. - Database —
Repohonours the same ambient ability inside a tool as inside an HTTP handler. - Security / Authentication — the bearer JWT flow your MCP tools inherit transitively.
Reference
Section titled “Reference”apps/assistant/+demo/crates/features/src/audio/mcp/— the demo’s guarded MCP surface: a JWT-authenticatedtranscode_statustool over the Publish audio pipeline (no token ⇒ the endpoint refuses).crates/nest-rs-mcp/—#[mcp], the streamable-HTTP endpoint factory, and the re-exported rmcp surface (tool_router,tool,tool_handler,ServerHandler,McpError,Parameters,CallToolResult,Content).crates/nest-rs-authz/(with themcpfeature) —McpAbilityBridge, bound throughAuthzMcpModule.