Skip to content

MCP client: fix config stacking and tool display naming #475

@itomek

Description

@itomek

Summary

Two related bugs in the MCP client framework affect usability when connecting to external MCP servers (e.g., OEM .NET servers). Both exist in the Python implementation; the C++ framework has the naming issue and lacks config loading entirely.


Bug 1: MCP Config Uses Fallback Instead of Stacking

Current Behavior

MCPConfig.__init__() in src/gaia/mcp/client/config.py:27-40 uses a fallback lookup — it picks the first config file found and ignores the other:

local_config = Path.cwd() / "mcp_servers.json"
if local_config.exists():
    config_file = local_config       # uses local, IGNORES global
else:
    config_file = ~/.gaia/mcp_servers.json  # falls back to global

Expected Behavior

Config files should stack (merge), with local overriding global on key conflicts:

  1. Load ~/.gaia/mcp_servers.json (global/user-level servers)
  2. Load ./mcp_servers.json (project-local servers)
  3. Merge: local entries override global entries with the same key

Example:

# ~/.gaia/mcp_servers.json (global)
{"mcpServers": {"time": {"command": "uvx", "args": ["mcp-server-time"]}}}

# ./mcp_servers.json (local)
{"mcpServers": {"acer": {"command": "dotnet", "args": ["run", "--project", "/path/to/oem"]}}}

# Result: both "time" and "acer" are available

If both files define the same server name, the local version wins.

Affected Files

  • Python: src/gaia/mcp/client/config.pyMCPConfig.__init__() and _load()
  • C++: No config loader exists — needs new MCPConfig class in cpp/include/gaia/mcp_config.h and cpp/src/mcp_config.cpp, plus Agent::loadMcpServersFromConfig() in cpp/src/agent.cpp

Reproduction

  1. Clear ~/.gaia/mcp_servers.json (empty {"mcpServers": {}})
  2. Put server entries in ./mcp_servers.json
  3. Run an agent with default MCPClientMixin.__init__(self) — no servers load because the empty global file is found first and the local file is never checked

Bug 2: MCP Tool Display Shows Internal Namespaced Name

Current Behavior

When GAIA registers MCP tools, it namespaces them as mcp_<server>_<tool> for internal registry uniqueness (e.g., mcp_acer_launch_experience_zone). This namespaced name is shown directly to the user in console output:

🔧 Executing operation
  Tool: mcp_acer_launch_experience_zone

The [MCP:acer] prefix is also prepended to tool descriptions in the system prompt, making them verbose:

- mcp_acer_launch_experience_zone(): [MCP:acer] launch_experience_zone is an MCP tool designed to...

Expected Behavior

Console output should show the original tool name with the server as context:

🔧 Executing operation
  Tool: launch_experience_zone (acer)

The metadata to do this already exists — _mcp_tool_name and _mcp_server are stored in the tool registry entry (src/gaia/mcp/client/mcp_client.py:58-59), but print_tool_usage() in console.py doesn't use them.

Affected Files

Python:

  • src/gaia/agents/base/console.py:794print_tool_usage() displays tool_name directly
  • src/gaia/agents/base/tools.py — Add utility to resolve display-friendly name from registry metadata

C++:

  • cpp/src/agent.cpp:523,525printToolUsage(toolName) passes the mangled name
  • cpp/include/gaia/types.hToolInfo already has mcpServer and mcpToolName fields (set in mcp_client.cpp:33-34)
  • cpp/src/console.cpp or cpp/include/gaia/console.hprintToolUsage needs display-friendly name resolution

Implementation Notes

Config Stacking Logic (identical for Python and C++)

if config_file explicitly provided:
    load only that file (preserves existing behavior for direct callers)
else:
    global_servers = load(~/.gaia/mcp_servers.json) or {}
    local_servers  = load(./mcp_servers.json) or {}
    merged = {**global_servers, **local_servers}  # local wins on conflict
    save target = local file if it existed, else global

Tool Display Name Resolution

def get_tool_display_name(tool_name: str) -> str:
    tool = _TOOL_REGISTRY.get(tool_name)
    if tool and "_mcp_tool_name" in tool:
        return f"{tool['_mcp_tool_name']} ({tool['_mcp_server']})"
    return tool_name

Acceptance Criteria

AC1: Config Stacking — Python

  • AC1.1 When only ~/.gaia/mcp_servers.json exists, all servers from it are loaded
  • AC1.2 When only ./mcp_servers.json exists, all servers from it are loaded
  • AC1.3 When both files exist with no overlapping keys, all servers from both are loaded
  • AC1.4 When both files define the same server name, the local (./) version is used
  • AC1.5 When neither file exists, config is empty and no errors are raised
  • AC1.6 When config_file is explicitly passed to MCPConfig(), only that file is loaded (no stacking) — preserves existing behavior for direct callers like gaia mcp add
  • AC1.7 _save() writes to the local config file if it existed during load, otherwise to the global file
  • AC1.8 Legacy "servers" key is still supported in both global and local files

AC2: Config Stacking — C++

  • AC2.1 New MCPConfig class exists in cpp/include/gaia/mcp_config.h and cpp/src/mcp_config.cpp
  • AC2.2 C++ MCPConfig implements the same stacking logic as Python (global + local merge, local overrides)
  • AC2.3 Agent::loadMcpServersFromConfig() loads servers from MCPConfig and calls connectMcpServer() for each
  • AC2.4 C++ stacking handles missing files gracefully (no crashes, empty config)

AC3: Tool Display — Python

  • AC3.1 Console output for MCP tools shows launch_experience_zone (acer) format instead of mcp_acer_launch_experience_zone
  • AC3.2 Native (non-MCP) tools display unchanged — no regression
  • AC3.3 Known tool descriptions in print_tool_usage() (e.g., query_documents → "Searching through indexed documents...") continue to work unchanged
  • AC3.4 The _mcp_tool_name and _mcp_server metadata fields remain in the tool registry entry for routing and debugging

AC4: Tool Display — C++

  • AC4.1 C++ console output for MCP tools shows original_name (server) format
  • AC4.2 C++ native tools display unchanged

AC5: Regression — No Breaking Changes

  • AC5.1 MCPConfig(config_file="/explicit/path.json") still loads only that file — no stacking, no change in behavior
  • AC5.2 MCPClientMixin.__init__(self, auto_load_config=False) still skips config loading
  • AC5.3 MCPClientMixin.__init__(self, config_file="path") still uses only the specified file
  • AC5.4 gaia mcp add <server> still writes to the correct config file
  • AC5.5 Existing agents that use connect_mcp_server() inline (without config files) continue to work
  • AC5.6 Tool registry still uses mcp_<server>_<tool> as the internal key — only display changes
  • AC5.7 LLM system prompt still includes the namespaced tool name so the LLM can call it correctly
  • AC5.8 Tool resolution (_resolve_tool_name()) still handles LLM calling tools with or without the mcp_ prefix
  • AC5.9 _unregister_mcp_tools() still correctly removes tools by namespaced key

AC6: Tests

  • AC6.1 Python unit tests cover all config stacking scenarios (AC1.1–AC1.8)
  • AC6.2 Python unit tests cover tool display name resolution (AC3.1–AC3.3)
  • AC6.3 C++ unit tests cover config stacking (AC2.1–AC2.4)
  • AC6.4 C++ unit tests cover tool display name (AC4.1–AC4.2)
  • AC6.5 All existing MCP unit tests pass without modification (regression)

AC7: Documentation

  • AC7.1 docs/sdk/infrastructure/mcp.mdx documents config stacking behavior
  • AC7.2 Documentation explains precedence: local overrides global

Test Matrix

Scenario Expected
Global only has servers All global servers load
Local only has servers All local servers load
Both have servers (no overlap) All servers from both load
Both have same key Local version used
Neither file exists Empty config, no errors
Explicit config_file passed Only that file loaded (no stacking)
Tool display for MCP tool Shows original_name (server)
Tool display for native tool Shows tool name unchanged

Existing Test Files to Update

  • tests/unit/mcp/client/test_mcp_client_manager.pyTestMCPConfig class
  • tests/unit/mcp/client/test_mcp_client_mixin.py — tool registration tests
  • cpp/tests/test_mcp_client.cpp — C++ tool schema tests

Documentation to Update

  • docs/sdk/infrastructure/mcp.mdx — Config section
  • docs/sdk/sdks/mcp.mdx — If it covers config

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingmcpMCP integration changesp1medium priority

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions