Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 18 additions & 4 deletions cli/hook_session_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": <mismatch>}`` for the case above: SessionStart
hook stdout becomes model context.
"""
from __future__ import annotations

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion cli/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions core/mcp_stack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Whether the installed fastmcp and mcp packages can load together.

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

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, InvalidRequirement, canonicalize_name), or None."""
try:
from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name
except ImportError:
try:
from pip._vendor.packaging.requirements import InvalidRequirement, Requirement
from pip._vendor.packaging.utils import canonicalize_name
except ImportError:
return None
return Requirement, InvalidRequirement, canonicalize_name


def _version(dist: str) -> str | None:
try:
return metadata.version(dist)
except metadata.PackageNotFoundError:
return None


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:
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 invalid_requirement:
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)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
122 changes: 122 additions & 0 deletions tests/test_mcp_stack.py
Original file line number Diff line number Diff line change
@@ -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()
Loading