Skip to content

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.

Terminal window
cargo add nest-rs --features mcp

The 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 fastest way to see the shape: one tool, one string in, one string out.

crates/features/src/hello/mcp/tool.rs
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:

crates/features/src/hello/module.rs
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 optional path serves 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.

Terminal window
$ nestrs run dev assistant
Terminal window
INFO 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=false

Point an MCP-aware client at http://localhost:3003/mcp. With Claude Code, add it over the streamable-HTTP transport:

Terminal window
$ claude mcp add --transport http hello http://localhost:3003/mcp

Or explore the tool surface with the MCP Inspector — no client config needed:

Terminal window
$ npx @modelcontextprotocol/inspector

Now 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.

Each #[tool] method becomes its own callable. Share state through &self#[inject] once, every tool reuses it.

crates/features/src/weather/mcp/tool.rs
#[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.

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.

crates/features/src/weather/coords.rs
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:

crates/features/src/weather/mcp/tool.rs (abridged)
#[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.

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:

crates/features/src/weather/mcp/tool.rs
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.

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.

  • 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.