Composable MCP server builder for zero-boilerplate capability composition.
This crate provides a builder pattern for composing MCP servers from individual capability providers, eliminating the boilerplate of implementing ServerHandler and manually delegating methods.
Instead of implementing the full ServerHandler trait:
// Traditional approach - lots of boilerplate
impl ServerHandler for MyServer {
fn list_tools(&self, ...) -> ... { self.tools.list_tools(...) }
fn call_tool(&self, ...) -> ... { self.tools.call_tool(...) }
fn list_prompts(&self, ...) -> ... { self.prompts.list_prompts(...) }
fn get_prompt(&self, ...) -> ... { self.prompts.get_prompt(...) }
// ... many more delegations
}You can compose a server from individual providers:
use rmcp_server_builder::ServerBuilder;
use rmcp::model::Implementation;
let server = ServerBuilder::new()
.info(Implementation::from_build_env())
.instructions("A helpful assistant with access to various tools.")
.tools(my_tools_provider)
.prompts(my_prompts_provider)
.build();Add to your Cargo.toml:
[dependencies]
rmcp-server-builder = "0.4"
rmcp = { version = "3.5.1", features = ["server"] }A server sometimes needs more than one provider of the same capability, for example
tools from two different backends. [ServerBuilder] takes one provider per capability,
so compose the several providers into one first, with one of the six merging types:
[MergedToolsProvider], [MergedPromptsProvider], [MergedResourcesProvider],
[MergedCompletionProvider], [MergedLoggingProvider], [MergedSubscriptionsProvider].
use rmcp_server_builder::{MergedResourcesProvider, MergedToolsProvider, ServerBuilder};
use rmcp::model::Implementation;
let tools = MergedToolsProvider::new()
.with_provider(weather_tools)
.with_provider(calendar_tools);
let resources = MergedResourcesProvider::new()
.with_provider(weather_resources)
.with_provider(calendar_resources);
let server = ServerBuilder::new()
.info(Implementation::from_build_env())
.tools(tools)
.resources(resources)
.build();Three rules hold across every merging type:
- Duplicate is an error. A tool name, a prompt name, or a resource URI listed by
more than one composed provider is a composition error, not a silent pick of one
provider over another.
list_*fails naming the duplicated key and the two providers that list it. A call of a tool name fails only when that name itself is duplicated; a call of any other, unique tool name still routes. Known limitation: a call drains every provider'slist_toolsfirst, so a failinglist_toolsof any one provider fails every tool call. A resource template string MAY recur across providers: two providers can describe the same URI shape over disjoint data. - Cursor composition. A merged provider's own pagination cursor wraps which composed provider listed a page and that provider's own cursor, so a client's pagination loop sees one list, drained provider by provider.
listChangedpropagation. A merged provider re-lists every composed provider on eachlist_*call, so a list change is visible on the next call. The composed server advertiseslistChangedonly when every provider of aMergedSubscriptionsProvidersupports it: one provider's flag would promise notifications the others never send.
Each merging type routes a call to the composed provider that owns it:
| Capability | Routes by |
|---|---|
| Tools, prompts | exact name |
| Resources | exact URI, then resource template, then registered scheme owner, then inferred URI scheme |
| Completions | the ref the request names (a prompt or a resource template) |
| Logging | fans out: every composed provider receives the call |
| Subscriptions | a URI to its one accepting provider; list-changed flags to every provider |
tasks/* |
the provider that created the task |
Protocol 2026-07-28 delivers change notifications on one subscriptions/listen stream.
Implement [SubscriptionsProvider] and set it with ServerBuilder::subscriptions:
use rmcp_server_builder::{
ListChangedSupport, ServerBuilder, SubscriptionPart, SubscriptionsProvider,
};
struct Watcher;
impl SubscriptionsProvider for Watcher {
fn support(&self) -> ListChangedSupport {
ListChangedSupport { tools: true, prompts: false, resources: false }
}
fn accepts_resource(&self, uri: &str) -> bool {
uri.starts_with("file://")
}
async fn listen(&self, part: SubscriptionPart) -> Result<(), rmcp::ErrorData> {
part.sink().notify_tool_list_changed().await.ok();
part.cancelled().await;
Ok(())
}
}
let server = ServerBuilder::new()
.info(info)
.tools(tools)
.resources(resources)
.subscriptions(Watcher)
.build();The composed server derives the acknowledgment and the listChanged flags from the provider:
- Acknowledgment. The server acknowledges each requested list-changed flag that
support()carries, and each requested URI thataccepts_resourceaccepts.rmcpthen removes each flag and each resource subscription that the server capabilities do not advertise. listChanged. With a subscriptions provider set,listChangedon the tools, prompts and resources capabilities comes fromsupport(), for every protocol version: also for a client on a version withoutsubscriptions/listen. A provider with a static list supports the flag and never sends the notification.resources.subscribe. The flag stays with the info provider. Advertise it there, orrmcpdrops every requested resource subscription.- Stream end.
listenreturning ends the stream:Ok(())sends the completion result, an error sends that error. In aMergedSubscriptionsProvider, the first part to end ends the stream with its outcome; the other parts are cancelled, and the stream ends after every part has ended. A part that ignores its cancellation holds the stream open. - Part sink.
SubscriptionPart::sinkrefuses a notification outside the part's accepted filter withSubscriptionSendError::NotificationNotAccepted. Once the part is cancelled, the sink refuses every notification withSubscriptionSendError::SubscriptionClosed; a refused notification is not delivered. A send still pending when the cancellation arrives also returnsSubscriptionClosed, with delivery undetermined:rmcpcan already have queued the notification for its transport. A part MAY start already cancelled: another part of aMergedSubscriptionsProvidercan end the stream before it first runs. Handle a send error; do not.expect()a send.
Without a subscriptions provider, subscriptions/listen is method-not-found. No blanket
implementation of SubscriptionsProvider exists for ServerHandler: ServerHandler::listen
takes a SubscriptionContext, which only rmcp builds.
subscriptions/listen exists on protocol 2026-07-28 only. The composed server implements the
protocol versions that ServerInfoProvider::supported_protocol_versions returns; the default
is every version rmcp knows.
Server and ServerBuilder take a seventh type parameter, the subscriptions provider,
between the logging and the info parameters. Write Unset there wherever the type is named:
| 0.3 | 0.4 |
|---|---|
Server<T, P, R, C, L, I> |
Server<T, P, R, C, L, Unset, I> |
ServerBuilder<T, P, R, C, L, I> |
ServerBuilder<T, P, R, C, L, Unset, I> |
0.2 builds on rmcp 3.x; 0.1 builds on rmcp 1.x. Four signatures change:
| Item | 0.1 | 0.2 |
|---|---|---|
ToolsProvider::call_tool |
Result<CallToolResult, ErrorData> |
Result<CallToolResponse, ErrorData> |
PromptsProvider::get_prompt |
Result<GetPromptResult, ErrorData> |
Result<GetPromptResponse, ErrorData> |
ResourcesProvider::read_resource |
Result<ReadResourceResult, ErrorData> |
Result<ReadResourceResponse, ErrorData> |
ServerInfoProvider::get_info |
ServerInfo |
ServerConfig |
Convert an ordinary result with .into():
Ok(CallToolResult::success(content).into())ServerInfo is a deprecated alias of ServerConfig in rmcp 3.x; rename it to drop the
warning.
Migrate the downstream ServerHandler implementations and every boundary that passes rmcp
types to rmcp 3.x in the same change: the rmcp 1.x and 3.x types do not unify.
cargo build
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all -- --checkMIT