Summary
Manual RMCP tool registration currently makes it easy to accidentally drop the RunningService while keeping only a cloned peer/sink in Rig's tool registry. The resulting behavior is confusing: tools can appear available immediately after setup, then disappear later when the agent is stored in application state and prompted again.
Related bug/repro issue: #2199
Broken user gist: https://gist.github.com/arcayr/1ebb21ee4fe80d5d3fa3f454c3f2f578
Corrected lifetime example: https://gist.github.com/gold-silver-copper/46bee9339c5d0b1f33c03394feece4b7
Problem
This shape looks reasonable at first glance:
let srv = client_info.clone().serve(transport).await?;
for tool in srv.list_all_tools().await? {
tool_server = tool_server.rmcp_tool(tool, srv.peer().clone());
}
let handle = tool_server.run();
let agent = client.agent(model).tool_server_handle(handle).build();
But srv.peer().clone() does not keep srv alive. RunningService owns the live RMCP connection/task. When srv falls out of scope, RMCP closes/cancels the transport. Rig later observes the peer sink as closed and retires the MCP tool registration.
The first prompt can still appear to work because shutdown is asynchronous, so this presents as an intermittent or state-hoisting bug rather than an obvious lifetime issue.
Why this is ergonomic/documentation debt
The current API accepts the exact thing users naturally have at registration time: a Tool plus a cloned peer/sink. That makes it easy to miss that another value, the RunningService, must be owned somewhere else for the registered tool to remain usable.
The intended long-lived app shape is closer to:
struct AppModel {
agent: Agent<...>,
mcp_services: Vec<rmcp::service::RunningService<rmcp::service::RoleClient, ClientInfo>>,
}
For dynamic/server-backed RMCP tools, the McpClientHandler + ToolServerHandle path also makes this clearer because connect(...) returns the service that must be retained.
Possible improvements
A few options, not mutually exclusive:
- Document prominently on
ToolServer::rmcp_tool, ToolServer::rmcp_tool_with_timeout, AgentBuilder::rmcp_tool, and AgentBuilder::rmcp_tools that the caller must retain the RunningService backing the peer.
- Add an example showing manual registration from
list_all_tools() with a struct that stores both Agent and Vec<RunningService<...>>.
- Consider renaming docs/parameters from
client/peer to something that hints it is only a sink handle, not the connection owner.
- Consider a higher-level helper that returns both a tool-server handle update and a guard/service value that must be retained.
- Consider a diagnostic log when directly registered RMCP tools are retired because their peer sink is closed, including a hint about keeping
RunningService alive.
Acceptance Criteria
Users following docs/examples for runtime RMCP tool registration should understand that peer().clone() is insufficient to own the connection lifetime, and should have an obvious pattern for storing the returned RunningService alongside their app model/agent state.
Summary
Manual RMCP tool registration currently makes it easy to accidentally drop the
RunningServicewhile keeping only a cloned peer/sink in Rig's tool registry. The resulting behavior is confusing: tools can appear available immediately after setup, then disappear later when the agent is stored in application state and prompted again.Related bug/repro issue: #2199
Broken user gist: https://gist.github.com/arcayr/1ebb21ee4fe80d5d3fa3f454c3f2f578
Corrected lifetime example: https://gist.github.com/gold-silver-copper/46bee9339c5d0b1f33c03394feece4b7
Problem
This shape looks reasonable at first glance:
But
srv.peer().clone()does not keepsrvalive.RunningServiceowns the live RMCP connection/task. Whensrvfalls out of scope, RMCP closes/cancels the transport. Rig later observes the peer sink as closed and retires the MCP tool registration.The first prompt can still appear to work because shutdown is asynchronous, so this presents as an intermittent or state-hoisting bug rather than an obvious lifetime issue.
Why this is ergonomic/documentation debt
The current API accepts the exact thing users naturally have at registration time: a
Toolplus a cloned peer/sink. That makes it easy to miss that another value, theRunningService, must be owned somewhere else for the registered tool to remain usable.The intended long-lived app shape is closer to:
For dynamic/server-backed RMCP tools, the
McpClientHandler + ToolServerHandlepath also makes this clearer becauseconnect(...)returns the service that must be retained.Possible improvements
A few options, not mutually exclusive:
ToolServer::rmcp_tool,ToolServer::rmcp_tool_with_timeout,AgentBuilder::rmcp_tool, andAgentBuilder::rmcp_toolsthat the caller must retain theRunningServicebacking the peer.list_all_tools()with a struct that stores bothAgentandVec<RunningService<...>>.client/peerto something that hints it is only a sink handle, not the connection owner.RunningServicealive.Acceptance Criteria
Users following docs/examples for runtime RMCP tool registration should understand that
peer().clone()is insufficient to own the connection lifetime, and should have an obvious pattern for storing the returnedRunningServicealongside their app model/agent state.