From 477a50c515c068ad3376e9960bd5220ac7ade4e7 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Fri, 23 Oct 2026 14:18:36 -0400 Subject: [PATCH 1/2] fix(mcp): name a fastmcp/mcp mismatch and its repair (2.158.4) fastmcp 3.x pins mcp<2, but installing another tool that wants mcp 2.x moves mcp anyway, so `from fastmcp import` failed and the MCP server died before its handshake with nothing the session could see. The server now prints the mismatch and the pip repair and exits 1; the SessionStart hook adds the same message to the session. Checked on fastmcp 3.2.4 + mcp 2.3.0 (message) and 4.1.3 + 2.3.0 (handshake, 24 tools). Full suite: 4998 passed, 8 skipped; test_no_text_open_without_encoding fails only on untracked .wt/. Claude-Session: https://claude.ai/code/session_019S3KVhMUwYBjYF5e9nQrXW --- CHANGELOG.md | 14 +++++ cli/c3.py | 2 +- cli/hook_session_open.py | 22 +++++-- cli/mcp_server.py | 11 +++- core/mcp_stack.py | 120 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_mcp_stack.py | 122 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 core/mcp_stack.py create mode 100644 tests/test_mcp_stack.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7ee45..7396184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to Code Context Control (C3) are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.158.4] - 2026-10-23 + +### Fixed — a fastmcp/mcp mismatch now says how to repair it + +Each fastmcp line pins the mcp SDK it was built on: 3.x needs `mcp<2`, 4.x +needs `mcp>=2`. pip does not hold installed packages to their pins when it +installs something else, so installing a tool that wants mcp 2.x left a +fastmcp 3.x unable to import (`cannot import name 'McpError' from 'mcp'`). +The MCP server died before its handshake and the host reported only a closed +connection, in every session. The server now prints the mismatch and the pip +commands that repair it (`pip install --upgrade fastmcp`, or pin mcp back to +what the installed fastmcp needs) and exits 1, and the SessionStart hook puts +the same message into the session. + ## [2.158.3] - 2026-10-23 ### Fixed — `c3_bitbucket list_prs` takes `author` and `reviewer` diff --git a/cli/c3.py b/cli/c3.py index fd8e630..3a12e89 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -91,7 +91,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.158.3" +__version__ = "2.158.4" # The PreToolUse matcher for native subagent calls (installer and hub migration). AGENT_MATCHER = "Agent|Task" diff --git a/cli/hook_session_open.py b/cli/hook_session_open.py index a05ea17..bf49069 100644 --- a/cli/hook_session_open.py +++ b/cli/hook_session_open.py @@ -40,14 +40,19 @@ the same MCP process, and a "Session started" toast on every compaction would teach the user to ignore the real ones. +When the installed fastmcp and mcp cannot load together +(``core.mcp_stack``), the MCP server dies before its handshake and the host +reports only a closed connection; this hook then returns the mismatch and +the pip repair as context, so the session can say why C3's tools are gone. + The MCP server's own ``session_start`` row is untouched — it carries the C3 id and, when the host put its id in the environment, ``host_session_id`` too. This hook is what lets a client see the session before the runtime has finished starting (~seconds), and it is the row the desktop's "MCP failed to connect" sentinel starts its 30 s clock from. -Returns None always: SessionStart hook stdout becomes model context, and -this hook has nothing to tell the model. +Returns None, or ``{"_text": }`` for the case above: SessionStart +hook stdout becomes model context. """ from __future__ import annotations @@ -249,10 +254,19 @@ def hand_off_runtime(project: Path, host: str, host_sid: str, start_source: str) def run(payload: dict, project_path: Path | None = None): - """Core logic — importable by the dispatcher and tests. Returns None.""" + """Core logic — importable by the dispatcher and tests. + + Returns None, or ``{"_text": ...}`` naming a fastmcp/mcp mismatch. + """ start_source = str(payload.get("source") or "").strip().lower() if start_source in _SILENT_SOURCES: return None + try: + from core.mcp_stack import problem + stack_problem = problem() + except Exception as exc: + log_hook_error("hook_session_open", exc) + stack_problem = None project = find_project(payload, project_path) if project is None: return None @@ -278,7 +292,7 @@ def run(payload: dict, project_path: Path | None = None): pass hand_off_runtime(project, host, host_sid, start_source) ensure_ui(project, host_sid) - return None + return {"_text": stack_problem} if stack_problem else None def main() -> None: diff --git a/cli/mcp_server.py b/cli/mcp_server.py index 88158b7..fdf6cf3 100644 --- a/cli/mcp_server.py +++ b/cli/mcp_server.py @@ -21,7 +21,16 @@ # Add parent to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) -from fastmcp import Context, FastMCP +try: + from fastmcp import Context, FastMCP +except Exception: + # The host shows only "connection closed"; stderr reaches its MCP log. + from core.mcp_stack import problem as _mcp_stack_problem + _stack_problem = _mcp_stack_problem() + if not _stack_problem: + raise + print(_stack_problem, file=sys.stderr) + raise SystemExit(1) from cli.tools import _grants from core.host import resolve_host diff --git a/core/mcp_stack.py b/core/mcp_stack.py new file mode 100644 index 0000000..1dc816a --- /dev/null +++ b/core/mcp_stack.py @@ -0,0 +1,120 @@ +"""Whether the installed fastmcp and mcp packages can load together. + +Each fastmcp line pins the mcp SDK it was built on: 3.x needs ``mcp<2`` and +4.x needs ``mcp>=2``. pip does not hold installed packages to their pins when +it installs something else, so installing any tool that wants mcp 2.x moves +mcp out from under a fastmcp 3.x, and ``from fastmcp import ...`` then fails +with ``cannot import name 'McpError' from 'mcp'``. The MCP server dies before +the handshake and the host only reports that the connection closed. + +This module reads installed metadata without importing either package, so the +MCP server and the SessionStart hook can name the mismatch and the pip +command that repairs it. +""" +from __future__ import annotations + +import sys +from importlib import metadata +from pathlib import Path + +# fastmcp-slim declares mcp under these extras; fastmcp 3.2 and earlier +# declare it unconditionally (""). +_EXTRAS = ("", "client", "server", "mcp") + + +def _packaging(): + """packaging's Requirement and canonicalize_name, or None when absent.""" + try: + from packaging.requirements import Requirement + from packaging.utils import canonicalize_name + except ImportError: + try: + from pip._vendor.packaging.requirements import Requirement + from pip._vendor.packaging.utils import canonicalize_name + except ImportError: + return None + return Requirement, canonicalize_name + + +def _version(dist: str) -> str | None: + try: + return metadata.version(dist) + except metadata.PackageNotFoundError: + return None + + +def _mcp_requirement(requirement_cls, canonicalize_name): + """The mcp Requirement the installed fastmcp declares, or None.""" + fastmcp = _version("fastmcp") + if fastmcp is None: + return None + # From 3.3 the fastmcp wheel only depends on fastmcp-slim, which carries + # the mcp pin. A slim left behind at another version is not the code in + # use, so read it only when the two versions match. + dists = ["fastmcp"] + if _version("fastmcp-slim") == fastmcp: + dists.insert(0, "fastmcp-slim") + for dist in dists: + for raw in metadata.requires(dist) or []: + try: + req = requirement_cls(raw) + except Exception: + continue + if canonicalize_name(req.name) != "mcp": + continue + if req.marker is None or any( + req.marker.evaluate({"extra": e}) for e in _EXTRAS): + return req + return None + + +def _console_python(executable: str) -> str: + """python.exe in place of pythonw.exe: the repair command needs a console.""" + exe = Path(executable) + if exe.name.lower() == "pythonw.exe": + console = exe.with_name("python.exe") + if console.exists(): + return str(console) + return executable + + +def problem(executable: str | None = None) -> str | None: + """Describe a fastmcp/mcp mismatch and how to repair it, or return None. + + None also covers what this cannot judge: fastmcp not installed, no mcp pin + in its metadata, or no ``packaging`` to read the pin with. + """ + tools = _packaging() + if tools is None: + return None + req = _mcp_requirement(*tools) + if req is None: + return None + fastmcp = _version("fastmcp") + mcp = _version("mcp") + spec = str(req.specifier) + python = _console_python(executable or sys.executable) + pip = f'"{python}" -m pip' + if mcp is None: + return (f"C3's MCP server cannot start: fastmcp {fastmcp} needs " + f"mcp{spec}, and mcp is not installed in {sys.prefix}.\n" + f'Repair: {pip} install "mcp{spec}", then restart the session.') + if req.specifier.contains(mcp, prereleases=True): + return None + too_new = any(s.operator in ("<", "<=") and not s.contains(mcp, prereleases=True) + for s in req.specifier) + lines = [ + f"C3's MCP server cannot start: fastmcp {fastmcp} needs mcp{spec}, " + f"but mcp {mcp} is installed in {sys.prefix}. Installing another " + "package that wants a different mcp moves it; pip warns about the " + "conflict but installs anyway.", + "Repair with one of:", + ] + if too_new: + lines.append(f" {pip} install --upgrade fastmcp " + f"(moves fastmcp to the release built for mcp {mcp})") + lines.append(f' {pip} install "mcp{spec}" ' + f"(moves mcp back to what fastmcp {fastmcp} needs)") + lines.append(f"Then run {pip} check to see whether another package " + "needs the other mcp line, and restart the session.") + return "\n".join(lines) diff --git a/pyproject.toml b/pyproject.toml index 037f257..450319c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.158.3" +version = "2.158.4" description = "Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_mcp_stack.py b/tests/test_mcp_stack.py new file mode 100644 index 0000000..e5170d2 --- /dev/null +++ b/tests/test_mcp_stack.py @@ -0,0 +1,122 @@ +"""core.mcp_stack names a fastmcp/mcp mismatch; the MCP server and the +SessionStart hook report it instead of dying with an import traceback.""" +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from importlib import metadata +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from core import mcp_stack # noqa: E402 + +FASTMCP_3_PIN = ["httpx>=0.28.1", "mcp<2.0,>=1.24.0", "openai>=1.102.0; extra == \"openai\""] +SLIM_4_PINS = ["mcp-types<3.0.0,>=2.0.0", + "mcp<3.0.0,>=2.2.0; extra == \"client\"", + "mcp<3.0.0,>=2.2.0; extra == \"server\""] + + +def _installed(versions: dict, requires: dict): + def version(name): + if name not in versions: + raise metadata.PackageNotFoundError(name) + return versions[name] + + def reqs(name): + if name not in versions: + raise metadata.PackageNotFoundError(name) + return requires.get(name, []) + + return mock.patch.multiple(mcp_stack.metadata, version=version, requires=reqs) + + +class TestProblem(unittest.TestCase): + def test_fastmcp3_with_mcp2_names_both_repairs(self): + with _installed({"fastmcp": "3.2.4", "mcp": "2.3.0"}, {"fastmcp": FASTMCP_3_PIN}): + msg = mcp_stack.problem("C:/Py/python.exe") + self.assertIn("fastmcp 3.2.4 needs mcp<2.0,>=1.24.0, but mcp 2.3.0 is installed", msg) + self.assertIn('"C:/Py/python.exe" -m pip install --upgrade fastmcp', msg) + self.assertIn('"C:/Py/python.exe" -m pip install "mcp<2.0,>=1.24.0"', msg) + + def test_matching_slim_pin_is_satisfied(self): + with _installed({"fastmcp": "4.1.3", "fastmcp-slim": "4.1.3", "mcp": "2.3.0"}, + {"fastmcp": ["fastmcp-slim[client,server]==4.1.3"], + "fastmcp-slim": SLIM_4_PINS}): + self.assertIsNone(mcp_stack.problem()) + + def test_mcp_too_old_offers_only_the_mcp_repair(self): + with _installed({"fastmcp": "4.1.3", "fastmcp-slim": "4.1.3", "mcp": "1.30.0"}, + {"fastmcp": ["fastmcp-slim[client,server]==4.1.3"], + "fastmcp-slim": SLIM_4_PINS}): + msg = mcp_stack.problem("python") + self.assertIn("needs mcp<3.0.0,>=2.2.0, but mcp 1.30.0", msg) + self.assertIn('install "mcp<3.0.0,>=2.2.0"', msg) + self.assertNotIn("--upgrade fastmcp", msg) + + def test_stale_slim_at_other_version_is_ignored(self): + with _installed({"fastmcp": "3.2.4", "fastmcp-slim": "4.1.3", "mcp": "1.30.0"}, + {"fastmcp": FASTMCP_3_PIN, "fastmcp-slim": SLIM_4_PINS}): + self.assertIsNone(mcp_stack.problem()) + + def test_missing_mcp_is_reported(self): + with _installed({"fastmcp": "3.2.4"}, {"fastmcp": FASTMCP_3_PIN}): + msg = mcp_stack.problem("python") + self.assertIn("mcp is not installed", msg) + + def test_no_fastmcp_is_not_judged(self): + with _installed({"mcp": "2.3.0"}, {}): + self.assertIsNone(mcp_stack.problem()) + + def test_pythonw_is_swapped_for_console_python(self): + with tempfile.TemporaryDirectory() as tmp: + for name in ("python.exe", "pythonw.exe"): + (Path(tmp) / name).write_bytes(b"") + self.assertEqual(mcp_stack._console_python(str(Path(tmp) / "pythonw.exe")), + str(Path(tmp) / "python.exe")) + + +class TestSessionStartReports(unittest.TestCase): + def test_mismatch_becomes_session_context(self): + from cli import hook_session_open + with tempfile.TemporaryDirectory() as tmp, \ + mock.patch.dict(os.environ, {"C3_NO_UI_AUTOSTART": "1"}), \ + mock.patch.object(mcp_stack, "problem", return_value="fastmcp mismatch"): + (Path(tmp) / ".c3").mkdir() + out = hook_session_open.run( + {"session_id": "sid", "cwd": tmp, "source": "startup"}, Path(tmp)) + self.assertEqual(out, {"_text": "fastmcp mismatch"}) + + +class TestServerStartup(unittest.TestCase): + def test_broken_stack_exits_with_repair_instead_of_traceback(self): + with tempfile.TemporaryDirectory() as tmp: + fake = Path(tmp) / "site" + (fake / "fastmcp").mkdir(parents=True) + (fake / "fastmcp" / "__init__.py").write_text( + "raise ImportError(\"cannot import name 'McpError' from 'mcp'\")\n", + encoding="utf-8") + for name, ver, extra in (("fastmcp", "3.2.4", "Requires-Dist: mcp<2.0,>=1.24.0\n"), + ("mcp", "2.3.0", "")): + info = fake / f"{name}-{ver}.dist-info" + info.mkdir() + (info / "METADATA").write_text( + f"Metadata-Version: 2.1\nName: {name}\nVersion: {ver}\n{extra}", + encoding="utf-8") + env = {**os.environ, "PYTHONPATH": str(fake)} + proc = subprocess.run( + [sys.executable, str(REPO_ROOT / "cli" / "mcp_server.py"), "--project", tmp], + capture_output=True, text=True, env=env, timeout=60, + stdin=subprocess.DEVNULL) + self.assertEqual(proc.returncode, 1, proc.stderr) + self.assertIn("fastmcp 3.2.4 needs mcp<2.0,>=1.24.0, but mcp 2.3.0", proc.stderr) + self.assertNotIn("Traceback", proc.stderr) + + +if __name__ == "__main__": + unittest.main() From bc20c5eaae3edf4fa4bb50e33a5a1ef92e0c291e Mon Sep 17 00:00:00 2001 From: drknowhow Date: Fri, 23 Oct 2026 14:18:59 -0400 Subject: [PATCH 2/2] refactor(mcp_stack): catch InvalidRequirement only Claude-Session: https://claude.ai/code/session_019S3KVhMUwYBjYF5e9nQrXW --- core/mcp_stack.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/core/mcp_stack.py b/core/mcp_stack.py index 1dc816a..5f2db30 100644 --- a/core/mcp_stack.py +++ b/core/mcp_stack.py @@ -1,15 +1,9 @@ """Whether the installed fastmcp and mcp packages can load together. -Each fastmcp line pins the mcp SDK it was built on: 3.x needs ``mcp<2`` and -4.x needs ``mcp>=2``. pip does not hold installed packages to their pins when -it installs something else, so installing any tool that wants mcp 2.x moves -mcp out from under a fastmcp 3.x, and ``from fastmcp import ...`` then fails -with ``cannot import name 'McpError' from 'mcp'``. The MCP server dies before -the handshake and the host only reports that the connection closed. - -This module reads installed metadata without importing either package, so the -MCP server and the SessionStart hook can name the mismatch and the pip -command that repairs it. +fastmcp 3.x needs ``mcp<2`` and 4.x needs ``mcp>=2``, and pip installing some +other tool can move mcp out from under fastmcp anyway. Reads installed +metadata without importing either package, so the MCP server and the +SessionStart hook can name the mismatch and the pip command that repairs it. """ from __future__ import annotations @@ -23,17 +17,17 @@ def _packaging(): - """packaging's Requirement and canonicalize_name, or None when absent.""" + """packaging's (Requirement, InvalidRequirement, canonicalize_name), or None.""" try: - from packaging.requirements import Requirement + from packaging.requirements import InvalidRequirement, Requirement from packaging.utils import canonicalize_name except ImportError: try: - from pip._vendor.packaging.requirements import Requirement + from pip._vendor.packaging.requirements import InvalidRequirement, Requirement from pip._vendor.packaging.utils import canonicalize_name except ImportError: return None - return Requirement, canonicalize_name + return Requirement, InvalidRequirement, canonicalize_name def _version(dist: str) -> str | None: @@ -43,7 +37,7 @@ def _version(dist: str) -> str | None: return None -def _mcp_requirement(requirement_cls, canonicalize_name): +def _mcp_requirement(requirement_cls, invalid_requirement, canonicalize_name): """The mcp Requirement the installed fastmcp declares, or None.""" fastmcp = _version("fastmcp") if fastmcp is None: @@ -58,7 +52,7 @@ def _mcp_requirement(requirement_cls, canonicalize_name): for raw in metadata.requires(dist) or []: try: req = requirement_cls(raw) - except Exception: + except invalid_requirement: continue if canonicalize_name(req.name) != "mcp": continue