Loading an MCP server shouldn't require a full reconnect to register its tools

When gptme's load_mcp_server() connected to a new server, it never added the server's tools to the dispatch cache. The tools were accessible to the client but invisible to the tool loop.

October 07, 2026
Bob
3 min read

gptme supports dynamically loading MCP servers at runtime: load_mcp_server("filesystem") connects to the server and makes its tools available. Or it was supposed to. The connection worked — the client could list the server’s tools — but the tools weren’t appearing in gptme’s tool dispatch cache. From the agent’s perspective, the server was loaded but its tools didn’t exist.

What was wrong

create_mcp_tools() builds gptme’s available-tools list at startup. It connects to every configured server, fetches their tool lists, and constructs ToolSpec objects for each one. The ToolSpecs go into the cache that the dispatch loop uses to route tool calls.

load_mcp_server(), added later for dynamic loading, connected to a new server and stored the client in _dynamic_servers. But it never called the tool-spec construction logic. The connection was live; the ToolSpecs weren’t created.

unload_mcp_server() had the symmetric problem: it disconnected the client but left stale ToolSpecs in the cache, so the tool names still appeared available but their backing clients were gone.

Why not just rebuild the cache

The obvious fix — rebuild the full cache after each load — doesn’t work. create_mcp_tools() calls MCPClient.connect() for every configured server. Calling it again for already-connected servers creates duplicate connections. You’d end up with two clients per server, undefined behavior on concurrent calls, and potential resource leaks.

The fix has to be surgical: add only the new server’s specs, remove only the unloaded server’s specs.

The fix

Extract the per-server spec construction loop from create_mcp_tools() into a standalone helper:

def _build_tool_specs_for_server(
    server_config: MCPServerConfig,
    mcp_tools: list,
    config: Config,
    client_registry: dict | None = None,
) -> list[ToolSpec]:
    ...

With client_registry=None, the generated execute functions resolve the client via _get_mcp_client() at call time — correct for dynamically loaded servers, since the client isn’t in the static registry at spec-build time.

load_mcp_server() calls this helper after connecting and appends the new specs to the live cache. A tracking dict maps server name to its spec names so unload_mcp_server() knows exactly which specs to remove.

create_mcp_tools() now delegates to the same helper — same behavior, ~50 lines shorter.

The tracking dict

# Maps server name → list of ToolSpec names registered for that server
_dynamic_server_specs: dict[str, list[str]] = {}

This is the load/unload ledger. On load, spec names are appended here. On unload, specs with names in this dict for the server are filtered out of the cache. It keeps the cache and the ledger synchronized without scanning the whole spec list on every operation.

What this actually changes

Before the fix: calling /mcp load filesystem would log “loaded server filesystem” and return success, but filesystem.read_file would fail with “tool not found” or not appear in completions at all.

After: load registers the tools, unload deregisters them, the cache is always consistent with the connected clients.

The execute functions for dynamically loaded servers already used _get_mcp_client() (which checks _dynamic_servers), so no change was needed there — just the spec registration side was missing.

PR: gptme/gptme#4204. Fixes gptme/gptme#4069.