Skip to content

Commit b8e6f31

Browse files
authored
fix(mcp): re-cap mcp dependency below 2.0 (#2941)
Installing GAIA's `[mcp]` extra today can resolve `mcp` 2.0.0, which silently breaks every MCP server GAIA ships — not just on `main`, but in the `v0.23.0` tag as already released. mcp 2.0.0 removed the API GAIA's server code imports (`mcp.server.fastmcp.FastMCP`, renamed to `MCPServer` and relocated), so any custom agent exposed as an MCP server, the TUI MCP server, and the Agent UI MCP server all fail to even import. GAIA's own CI already caught this — the "Custom agent MCP harness" job has been failing on the `v0.23.0` release branch — but that job doesn't block merges, so it shipped anyway. **Verified independently: `main`'s tip, the `v0.23.0` release PR's head commit, and the `v0.23.0` tag itself all currently carry the broken `<3.0` cap.** #2885's "not a v0.23.0 blocker" line is inaccurate; a follow-up comment on that issue covers it separately. This restores the `<2.0` cap that a routine dependency bump had widened without doing the accompanying client port its own code comment called for, corrects that comment's description of *what* breaks (the hand-rolled `MCPClient.connect()` it named doesn't import the `mcp` package at all — the real failure is in GAIA's FastMCP-based server code), and adds a test that fails if the cap widens again without that port. The actual port to the mcp 2.x API is tracked separately (#2940) since it needs a live client/server round trip to verify, not just an import-level fix. Closes #2885 ### Test plan - [x] `python -m pytest tests/unit/test_mcp_extras.py -v` — both new guard tests pass - [x] Before-fix reproduction: `pip install 'mcp>=1.1.0,<3.0'` resolves to `mcp==2.0.0`; `from mcp.server.fastmcp import FastMCP` raises `ModuleNotFoundError` - [x] After-fix verification: clean-venv `pip install -e ".[mcp]"` resolves to `mcp==1.29.0`; `from mcp.server.fastmcp import FastMCP` succeeds - [x] `tests/installer/test_custom_agent_mcp_harness.py` — all 3 pass under the fixed cap (mcp 1.29.0); controlled A/B in the same venv forced to mcp 2.0.0 reproduces 1 of the 3 failing ("Unknown tool name" — the dummy MCP server dies on the same import before it can register its tool) - [x] `python util/lint.py --all` passes <details> <summary>Reproduction evidence (before / after)</summary> **Before fix — today's `<3.0` cap resolves to mcp 2.0.0:** ``` $ pip install 'mcp>=1.1.0,<3.0' --dry-run Collecting mcp<3.0,>=1.1.0 Downloading mcp-2.0.0-py3-none-any.whl.metadata (7.7 kB) ... Would install ... mcp-2.0.0 ... ``` **Before fix — the API GAIA's MCP servers import does not exist under it:** ``` $ python -c "from mcp.server.fastmcp import FastMCP" Traceback (most recent call last): File "<string>", line 1, in <module> ModuleNotFoundError: No module named 'mcp.server.fastmcp' ``` **After fix — clean venv, fixed cap:** ``` $ pip install -e ".[mcp]" ... $ pip show mcp Name: mcp Version: 1.29.0 $ python -c "from mcp.server.fastmcp import FastMCP; print(FastMCP)" <class 'mcp.server.fastmcp.server.FastMCP'> ``` **Controlled A/B — same venv, same test file, only the mcp version changes:** ``` $ python -m pytest tests/installer/test_custom_agent_mcp_harness.py -v # mcp==1.29.0 test_custom_agent_dummy_mcp_path_uses_installed_bundle PASSED test_custom_agent_with_mcp_reports_diagnosable_connection_failure PASSED test_custom_agent_no_mcp_path_imports_and_emits_no_mcp_traffic PASSED 3 passed $ pip install mcp==2.0.0 # force-upgrade the same venv $ python -m pytest tests/installer/test_custom_agent_mcp_harness.py -v # mcp==2.0.0 test_custom_agent_dummy_mcp_path_uses_installed_bundle FAILED AssertionError: assert 'error' == 'success' ERROR gaia.agents.base.agent._execute_tool: Unknown tool name. Use only tools listed in your AVAILABLE TOOLS section. test_custom_agent_with_mcp_reports_diagnosable_connection_failure PASSED test_custom_agent_no_mcp_path_imports_and_emits_no_mcp_traffic PASSED 1 failed, 2 passed ``` Root cause of that one failure: the harness's own dummy MCP server fixture (`tests/fixtures/mcp/dummy_server/server.py`) has the same `from mcp.server.fastmcp import FastMCP` import, so under mcp 2.0.0 it crashes on startup before it can register its tool — the client never sees it, hence "Unknown tool name" rather than a connection error. **Live-CI corroboration (not generated for this PR — already existing, found while investigating):** `build-installers.yml`'s "Custom agent MCP harness" job has failed with the identical `assert 'error' == 'success'` signature on the `v0.23.0` release branch's own CI runs, on both `ubuntu-latest` and `windows-latest`. That job isn't required for merge, so it shipped anyway. </details>
1 parent efa2e80 commit b8e6f31

2 files changed

Lines changed: 167 additions & 4 deletions

File tree

setup.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,13 @@
221221
"bpy",
222222
],
223223
"mcp": [
224-
# Capped below 2.0: mcp 2.0.0 (released 2026-07-28) breaks
225-
# MCPClient.connect() — the custom-agent harness went red with no
226-
# code change. Lift the cap in a change that ports the client.
227-
"mcp>=1.1.0,<3.0",
224+
# Capped below 2.0: mcp 2.0.0 (released 2026-07-28) removed
225+
# mcp.server.fastmcp (FastMCP -> MCPServer, moved to
226+
# mcp.server.mcpserver), breaking every FastMCP-based server
227+
# GAIA ships (agent_mcp_server.py, servers/agent_ui_mcp.py,
228+
# servers/tui_mcp.py). Lift the cap only in a change that
229+
# ports them.
230+
"mcp>=1.1.0,<2.0",
228231
"starlette",
229232
"uvicorn",
230233
],

tests/unit/test_mcp_extras.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2+
# SPDX-License-Identifier: MIT
3+
4+
"""Packaging guard for the [mcp] extra's version pin (issue #2885).
5+
6+
setup.py's ``extras_require["mcp"]`` block carries a comment saying the mcp
7+
dependency is "Capped below 2.0" because mcp 2.0.0 (released 2026-07-28)
8+
removed ``mcp.server.fastmcp`` (``FastMCP`` was renamed to ``MCPServer`` and
9+
moved to ``mcp.server.mcpserver``), breaking every FastMCP-based server GAIA
10+
ships. But the pin itself reads ``mcp>=1.1.0,<3.0``, which does NOT enforce
11+
the cap the comment describes: the comment and the enforced version range
12+
contradict each other, so ``pip install`` can still resolve an mcp 2.x that
13+
breaks those servers.
14+
15+
This file asserts two things, independently:
16+
17+
* the pin is exactly ``mcp>=1.1.0,<2.0`` (the enforced range, not the
18+
comment's claim about it);
19+
* the comment directly above the pin and the pin itself never contradict
20+
each other — if the comment still says the dependency is "Capped below
21+
2.0", the enforced range must actually be ``<2.0``.
22+
23+
This is a static packaging assertion — it reads setup.py's source text and
24+
never imports or installs anything, so it works in the CI unit-tests venv
25+
that does not install [mcp]. Modelled on test_api_extras.py (#1617) and
26+
test_base_keyring_dep.py (#1621).
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import re
32+
from pathlib import Path
33+
34+
SETUP_PY = Path(__file__).resolve().parents[2] / "setup.py"
35+
36+
# Single source of truth for the expected pin. If a future change legitimately
37+
# widens the cap (e.g. after porting the client to the mcp 2.x API), update
38+
# this one constant — and setup.py's pin and comment to match — rather than
39+
# editing the assertions below.
40+
EXPECTED_MCP_PIN = "mcp>=1.1.0,<2.0"
41+
EXPECTED_CAP_COMMENT_SUBSTRING = "capped below 2.0"
42+
43+
_PORT_INSTRUCTION = (
44+
"mcp 2.0.0 removed mcp.server.fastmcp (FastMCP -> MCPServer) — before "
45+
"widening the cap past <2.0, port GAIA's FastMCP-based MCP servers "
46+
"(src/gaia/mcp/agent_mcp_server.py, src/gaia/mcp/servers/agent_ui_mcp.py, "
47+
"src/gaia/mcp/servers/tui_mcp.py — docker_mcp.py is blocked transitively "
48+
"via agent_mcp_server.py) to the mcp 2.x API."
49+
)
50+
51+
52+
def _parse_extra(name: str) -> list[str]:
53+
"""Extract the requirement strings from a named extras_require block.
54+
55+
Walks the file line by line so brackets that appear inside ``# comments``
56+
don't confuse a naive non-greedy regex match.
57+
"""
58+
lines = SETUP_PY.read_text(encoding="utf-8").splitlines()
59+
in_block = False
60+
body: list[str] = []
61+
for raw in lines:
62+
stripped = raw.strip()
63+
if not in_block:
64+
if re.match(rf'"{re.escape(name)}"\s*:\s*\[', stripped):
65+
in_block = True
66+
continue
67+
if stripped.startswith("]"):
68+
break
69+
if stripped.startswith("#"):
70+
continue
71+
body.append(raw)
72+
assert in_block, f'Could not find "{name}" extra in setup.py extras_require'
73+
return re.findall(r'"([^"]+)"', "\n".join(body))
74+
75+
76+
def _pin_and_preceding_comment(name: str, pin_prefix: str) -> tuple[str, str]:
77+
"""Return ``(pin_requirement_string, comment_text_directly_above_it)``.
78+
79+
Walks setup.py line by line to find the requirement string starting with
80+
``pin_prefix`` inside the ``extras_require[name]`` block, then walks
81+
backward collecting the contiguous run of ``# ...`` comment lines
82+
immediately above it (stopping at the first non-comment line). Mirrors
83+
``_parse_extra``'s line-walking style, but keeps the comment text that
84+
``_parse_extra`` deliberately discards.
85+
"""
86+
lines = SETUP_PY.read_text(encoding="utf-8").splitlines()
87+
in_block = False
88+
block_start = -1
89+
pin_line_idx = None
90+
pin_value = None
91+
for i, raw in enumerate(lines):
92+
stripped = raw.strip()
93+
if not in_block:
94+
if re.match(rf'"{re.escape(name)}"\s*:\s*\[', stripped):
95+
in_block = True
96+
block_start = i
97+
continue
98+
if stripped.startswith("]"):
99+
break
100+
match = re.match(rf'"({re.escape(pin_prefix)}[^"]*)"', stripped)
101+
if match:
102+
pin_line_idx = i
103+
pin_value = match.group(1)
104+
break
105+
assert in_block, f'Could not find "{name}" extra in setup.py extras_require'
106+
assert pin_line_idx is not None, (
107+
f'No requirement starting with "{pin_prefix}" found in the "{name}" '
108+
"extras_require block (setup.py)."
109+
)
110+
111+
comment_lines: list[str] = []
112+
j = pin_line_idx - 1
113+
while j > block_start:
114+
stripped = lines[j].strip()
115+
if not stripped.startswith("#"):
116+
break
117+
comment_lines.insert(0, stripped.lstrip("#").strip())
118+
j -= 1
119+
120+
return pin_value, " ".join(comment_lines)
121+
122+
123+
def test_mcp_extra_pin_is_capped_below_2_0() -> None:
124+
"""setup.py's mcp extra must pin EXPECTED_MCP_PIN exactly — see #2885.
125+
126+
This checks the enforced version range directly (not the comment that
127+
claims to describe it), so a contradiction between the two can't hide
128+
behind a comment nobody re-checked.
129+
"""
130+
mcp_reqs = _parse_extra("mcp")
131+
assert EXPECTED_MCP_PIN in mcp_reqs, (
132+
f'#2885: setup.py\'s "mcp" extras_require block does not pin '
133+
f'"{EXPECTED_MCP_PIN}". ' + _PORT_INSTRUCTION + "\n"
134+
f'Current "mcp" extra: {mcp_reqs}'
135+
)
136+
137+
138+
def test_mcp_extra_capped_comment_matches_pin() -> None:
139+
"""The "Capped below 2.0" comment above the mcp pin must match the pin — see #2885.
140+
141+
setup.py documents the cap with a comment ("Capped below 2.0: mcp 2.0.0
142+
... removed mcp.server.fastmcp"), but the pin itself can drift out of
143+
sync with what the comment claims (it currently reads
144+
``mcp>=1.1.0,<3.0``). This must fail whenever the comment still claims
145+
the dependency is capped below 2.0 but the pin's upper bound is anything
146+
other than EXPECTED_MCP_PIN — the contradiction must not silently pass.
147+
"""
148+
pin, comment = _pin_and_preceding_comment("mcp", "mcp>=")
149+
assert EXPECTED_CAP_COMMENT_SUBSTRING in comment.lower(), (
150+
"#2885: expected the comment directly above the mcp pin in setup.py "
151+
'to still say "Capped below 2.0" (it documents why mcp is capped, '
152+
f"following the mcp 2.0.0 mcp.server.fastmcp removal); got: {comment!r}. "
153+
"If the comment was intentionally reworded, update this test to match "
154+
"it; otherwise restore the comment."
155+
)
156+
assert pin == EXPECTED_MCP_PIN, (
157+
f'#2885: setup.py\'s mcp extra comment says "Capped below 2.0" but the '
158+
f'enforced pin is "{pin}", not "{EXPECTED_MCP_PIN}" — the comment and '
159+
"the enforced version range contradict each other. " + _PORT_INSTRUCTION
160+
)

0 commit comments

Comments
 (0)