MCP
Expose services to language models via the Model Context Protocol — declared like a controller.
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.
That ambient ability covers every MCP capability, not tools alone. A
prompt fetch, a resource read, a completion, a tasks/* call: each runs inside
the caller’s request scope, with the caller’s ability installed and the
operation’s transaction around it. A handler writes no check on any of them.
nest-rs-mcp builds on rmcp 3.x — the framework wraps
it so a 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 --features mcpThe mcp feature pulls the HTTP transport the endpoint mounts on and the guard
surface that gates it — including the fallback operation guard that lets a
registered global pool gate /mcp instead of the endpoint staying deny-all.
None of that is a line you write.
rmcp is not a dependency either, and it is not an import: #[tools] emits
the SDK’s own macros inside generated code that carries its own scope. That keeps
the SDK at the exact major nest-rs was built against — two majors in one graph
put two ServerHandler traits in scope and every generated method mismatches.
A host that needs the raw SDK reaches it through nest_rs::mcp::rmcp.
The 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::{McpError, Parameters, input, mcp, tools};
#[input]pub struct HelloParams { pub name: String,}
#[mcp]#[derive(Clone, Default)]pub struct HelloTool;
#[tools]impl HelloTool { #[tool(description = "Say hello to someone by name.")] #[public] async fn hello(&self, Parameters(params): Parameters<HelloParams>) -> Result<String, McpError> { Ok(format!("Hello, {}!", params.name)) }}Every operation declares its access posture, and one of the two forms is
required — #[public] here because a greeting gates nothing. An operation
nobody thought about does not compile, rather than shipping ungated to a model.
Authorization covers the other form.
That’s a complete MCP server — but a bare #[mcp] endpoint is closed by
default. The endpoint gates through the app’s dyn McpOperationGuard; with
none registered it falls back to the global guard pool
(App::builder().use_guards_global(...)), and with no pool either it is
deny-all — every request answers 401, so a schema-added tool can never
leak unauthenticated.
Which posture you got is in the boot line: mode="deny_all" with nothing
registered, mode="global_guard_pool" once a pool is.
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] 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.)
Two decorators, one per item shape:
#[mcp]on the struct registers it as a discoverable provider that mounts a streamable-HTTP endpoint on the HTTP transport, at/mcp. An optionalpathserves a different URL instead — write it whole, the way a client config carries it.#[tools]on the impl declares the operations. Every#[tool]and#[prompt]method is mounted, and the endpoint’s advertised capabilities are derived from the roles present — you cannot serve a tool you forgot to declare. An operation declares the same request layers a#[query]does:#[use_guards(...)],#[authorize(...)]/#[public], and a pipe on its arguments.- The description is
#[tool(description = "…")]— the sentence the model reads to pick this tool, so it is a value the decorator compiles in rather than commentary. Write a doc comment instead and it is used as a fallback, so the prose is never written twice; write neither and it does not compile.
The struct can take #[inject] fields like any other provider — HelloTool has
none, real servers usually do.
Note what the file does not name: no rmcp, no ServerHandler, no router, no
get_info. Those are rmcp’s, and the expansion keeps them in generated code.
Run it
Section titled “Run it”$ nestrs run dev assistantINFO nest_rs::routes: mounted endpoint kind="mcp" path="/mcp"DEBUG nest_rs::mcp: mcp operations gated mode="operation_guard"INFO nest_rs::http: transport listening addr=0.0.0.0:3003 tls=falsePoint 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.
Multiple tools on one server
Section titled “Multiple tools on one server”Each #[tool] method becomes its own callable. Share state through &self —
#[inject] once, every tool reuses it.
#[mcp(path = "/mcp/weather")]#[derive(Clone)]pub struct WeatherTool { #[inject] svc: Arc<dyn WeatherService>,}
#[tools]impl WeatherTool { #[tool(description = "Return the current temperature for a city.")] #[public] async fn current_temperature( &self, Parameters(p): Parameters<CityParams>, ) -> Result<String, McpError> { let t = self.svc.current(&p.city).await.opaque()?; Ok(format!("{t:.1}°C")) }
#[tool(description = "Return the forecast for a city, N days out.")] #[public] async fn forecast( &self, Parameters(p): Parameters<ForecastParams>, ) -> Result<String, McpError> { let f = self.svc.forecast(&p.city, p.days).await.opaque()?; Ok(f.summary()) }}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 — add validate(...) attributes: #[input] already carries the
validator derive, so no extra import and no extra manifest line.
use nest_rs::mcp::input;
#[derive(Debug)]#[input]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,}Wrap the arguments in Valid<T> and the check runs before the body — the same
pipe binding GraphQL, WebSockets and queue handlers use:
#[tool(description = "Return the current weather at the given GPS coordinates (Open-Meteo).")]#[public]async fn current_weather( &self, Parameters(params): Parameters<Valid<Coords>>,) -> Result<String, McpError> { let params = params.into_inner(); let report = self.svc .current(params.latitude, params.longitude) .await .opaque()?;
Ok(report.summary())}JsonSchema derives the schema sent to the client — from Coords, not from the
carrier, so a client never sees Valid — and a rejection answers
invalid_params carrying the field errors, which is the one MCP error a model
can act on:
{"code":-32602,"message":"validation failed", "data":{"latitude":[{"code":"range"}]}}Piped<P, T> runs any pipe in the same slot when the
argument needs transforming rather than validating.
Failing without leaking
Section titled “Failing without leaking”A tool body talks to a language model, and an error’s Display is handed to
it verbatim: a DbErr carries schema, column names and sometimes row values.
So a failure the model has no business reading goes through opaque:
let rows = self.svc.list().await.opaque()?;The real error is logged at error on nest_rs::mcp, inside rmcp’s own
per-operation span; the model gets a constant internal error. A deliberate
error is the opposite case — McpError::invalid_params(...) exists to be read,
so return it directly and never route it through opaque.
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”- Authorization — the bridge every tool call is gated by, and the posture each operation declares.
- Endpoints — several features on one URL, and what the client is told the server is.
- Configuration — the env keys, and how a failing operation talks to a model.