Skip to content

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.

Terminal window
cargo add nest-rs-mcp

The fastest way to see the shape: one tool, one string in, one string out.

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

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(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 at path on 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’s ServerHandler trait.

The struct can take #[inject] fields like any other provider — HelloTool has none, real servers usually do.

Terminal window
$ nestrs run dev assistant
Terminal window
2026-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:

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.

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.

src/module.rs
#[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.

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.

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:

src/weather/coords.rs
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,
}
src/weather/tool.rs (abridged)
#[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.

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.

src/module.rs
#[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.

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.

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:

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

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.

  • apps/assistant/ + demo/crates/features/src/audio/mcp/ — the demo’s guarded MCP surface: a JWT-authenticated transcode_status tool 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 the mcp feature) — McpAbilityBridge, bound through AuthzMcpModule.