Hmbown/CodeWhale · error

MCP catalog changed after tool resolution; retry the call

Error message

MCP catalog changed after tool resolution; retry the call

What it means

The MCP connection's tool catalog was regenerated (generation counter bumped) after a call route was resolved against an older catalog snapshot. The library bails instead of calling a tool that may no longer exist or have a different schema, so stale resolutions fail fast and the caller can re-resolve and retry.

Solutions

  1. Re-resolve the tool route against the current catalog, then retry the call
  2. Refresh the connection (reconnect / re-list tools) so the route and catalog generations align
  3. Check whether the MCP server is restarting or updating tools mid-call and pin to a stable server version

Example fix

// before
let result = engine.call_route(&stale_route, &args).await?;
// after
let route = match engine.call_route(&route, &args).await {
    Err(e) if e.to_string().contains("catalog changed") => {
        let route = engine.resolve_route(&route.server_name, &route.tool_name).await?;
        engine.call_route(&route, &args).await?
    }
    r => r?,
};
Defensive patterns

Strategy: retry

Validate before calling

// before calling
if conn.catalog_generation != route.catalog_generation {
    route = resolve_route(&route.server_name, &route.tool_name).await?;
}

Try / catch

match call_route(&route, args).await {
    Err(e) if e.to_string().contains("catalog changed") => {
        let route = resolve_route(&route.server_name, &route.tool_name).await?;
        call_route(&route, args).await // retry once with fresh route
    }
    other => other,
}

Prevention

When it happens

Trigger: Calling an MCP tool through a route whose catalog_generation no longer matches the connection's current generation — i.e. the server's tool list changed (tools added/removed/updated) between route resolution and call_tool, e.g. after a reconnect or a server-initiated list-changed notification.

Common situations: A long-running session where the MCP server hot-reloads its tool list; a server restart or upgrade mid-session; reconnect logic refreshing tools while an old cached route is still in use.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/b21264f53b4e4af0. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/mcp.rs:4918

        let route = match self.resolve_advertised_tool(prefixed_name).await {
            Ok(route) => route,
            Err(error) => {
                // A real tool name reached a server whose login has lapsed
                // (stale catalog, or a mid-session 401). Name the one call
                // that recovers it instead of leaving a dead error.
                if let Some(server) = self.needs_auth_server_for_tool_name(prefixed_name) {
                    return Err(error.context(self.auth_required_hint(&server)));
                }
                return Err(error);
            }
        };
        let server_name = route.server_name.clone();
        let tool_name = route.tool_name.clone();
        // Copy the global timeouts to avoid borrow conflict
        let global_timeouts = self.config.timeouts;
        let conn = self.get_or_connect(&server_name).await?;
        if conn.catalog_generation != route.catalog_generation {
            anyhow::bail!("MCP catalog changed after tool resolution; retry the call");
        }
        if conn
            .config()
            .reviewed_plugin
            .as_ref()
            .map(|source| &source.authority)
            != route.plugin_authority.as_ref()
            || !conn.config().is_tool_enabled(&tool_name)
            || !conn.tools().iter().any(|tool| tool.name == tool_name)
        {
            anyhow::bail!("MCP tool '{tool_name}' is disabled for server '{server_name}'");
        }
        let timeout = conn.config().effective_execute_timeout(&global_timeouts);
        let result = match conn.call_tool(&tool_name, arguments.clone(), timeout).await {
            Ok(result) => Ok(result),
            // A rejected credential is not a stale session: reconnecting
            // replays the same rejection, so it takes the auth-required
            // path below instead of the transparent retry.

View on GitHub (pinned to 73e0f67d83)