Skip to content

MCP endpoints

Several features on one URL, the identity the app declares once, and the case for more than one endpoint.

Every shipped MCP client config points at a single URL, so a real product usually wants all its domains behind one path. It gets that without giving up one mcp/ adapter per feature: hosts that write the same path aggregate onto one endpoint, and a bare #[mcp] is how a feature says “the app’s endpoint” without repeating the URL.

A #[mcp] path is not a namespace the host owns the way a #[controller]’s is — nothing nests under it. It names the one endpoint the host joins, which is exactly why peers share it.

apps/api/src/module.rs
#[module(imports = [WeatherModule, AuthzMcpModule], providers = [WeatherTool])]
pub struct WeatherMcpModule; // #[mcp]
#[module(imports = [HelloModule, AuthzMcpModule], providers = [HelloTool])]
pub struct HelloMcpModule; // #[mcp]
#[module(imports = [
HttpModule::for_root(None),
WeatherMcpModule,
HelloMcpModule,
])]
pub struct AssistantModule;

One client, one URL, both features’ tools:

Terminal window
INFO nest_rs::routes: mounted endpoint kind="mcp" path="/mcp"
INFO nest_rs::routes: mounted mcp host kind="mcp" path="/mcp" host="WeatherTool" tools="current_weather"
INFO nest_rs::routes: mounted mcp host kind="mcp" path="/mcp" host="HelloTool" tools="hello"

Nothing in a host changes: it stays a plain ServerHandler and never learns that it shares. tools/list, prompts/list and resources/list are the union of every host on the path; a tools/call is routed to the host that declares the name; the endpoint’s advertised capabilities are the union of theirs. A single host on a path is served verbatim — the merge only engages beyond one.

The one rule the merge adds: two hosts on one path may not serve the same tool name. MCP addresses a tool by bare name inside an endpoint, so the loser would be unreachable — it is a boot error naming the tool and both hosts:

Terminal window
duplicate MCP tool name on "/mcp": search (WeatherTool and HelloTool) — a tool is
addressed by bare name within an endpoint, so rename one of them or give each
host its own path

An MCP endpoint is one server to every client that reaches it. The protocol carries one serverInfo and one instructions, on initialize and on server/discover alike — the same shape new McpServer({ name, version }) builds in the TypeScript SDK, and the one a FastMCP parent keeps when it mounts children. Two declarations answer for it, each owned by whoever can know it.

The app says who it is and how its server is used, once, for every endpoint it exposes:

apps/api/src/module.rs
use nest_rs::core::module;
use nest_rs::mcp::{McpIdentity, McpModule, McpOptions};
#[module(imports = [
HttpModule::for_root(None),
McpModule::for_root(McpOptions {
server: Some(
McpIdentity::new("acme-assistant", env!("CARGO_PKG_VERSION"))
.title("Acme assistant")
.instructions("Ask before writing anything. Every result is scoped to your token."),
),
..Default::default()
}),
WeatherMcpModule,
HelloMcpModule,
])]
pub struct AssistantModule;

Both parts have to come from here. The version, because env!("CARGO_PKG_VERSION") written in a shared feature library is the library’s version rather than the deployment’s. The instructions, because they describe the server — a client may fold them into the model’s system prompt — and on an endpoint several features share, no single host can see the whole.

Keep them general: what a caller must know to use the surface at all. What each tool does is already carried by its own #[tool(description = "…")], which is what the model reads when it chooses between them.

A host says which endpoint stands apart, on the file that serves it:

crates/features/src/weather/mcp/tool.rs
#[mcp(path = "/mcp/weather", name = "acme-weather")]
#[derive(Clone)]
pub struct WeatherTool { /* … */ }

name and title are optional and each overrides the app’s per field, so the host above renames its endpoint while still inheriting the app’s version, title and instructions. Two hosts on one path both declaring is a boot error naming both — otherwise the endpoint’s name would depend on imports = [..] order.

path, name and title are the whole list. Every other identity field — version, description, website_url, icons, instructions — describes the server, and writing one on a host is a compile error naming the seam that takes it. A feature library knows neither the binary’s version nor, on a shared endpoint, the whole surface, so those have one owner: the app’s McpModule::for_root. To version the address, write it into the path — #[mcp(path = "/mcp/v1")].

Identity is declared; capabilities are observed. A declaration replaces what it states and can never claim a capability no host serves. Declare no instructions at all and the hosts’ own get_info blurbs are joined instead of dropped — a fallback, not a second way to write them.

The other direction: a second server is a second path on a host. Each mounts on the HTTP transport independently — different tools, different injected dependencies, different authorization policy if you want one.

Namespacing is the URL: /mcp for the app’s own endpoint, whatever you write for the second. The MCP spec already namespaces tools per endpoint, so a second endpoint is the namespace — no extra primitive to learn, and no prefix the second path has to sit under.

  • MCP — the host each of these mounts.
  • Authorization — resolved once per path, not per host.