Skip to content

fix(mcp): verify hook-free multi-host support - #341

Merged
cdeust merged 2 commits into
mainfrom
agent/verify-multi-host-mcp
Aug 2, 2026
Merged

fix(mcp): verify hook-free multi-host support#341
cdeust merged 2 commits into
mainfrom
agent/verify-multi-host-mcp

Conversation

@cdeust

@cdeust cdeust commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Summary

Make Cortex's local stdio server reliably usable without Claude lifecycle hooks across Claude Code, Gemini CLI, and Codex CLI. The server now skips FastMCP's banner-time network update probe so a SOCKS proxy without the optional HTTP SOCKS extra cannot abort startup before MCP initialize. The change also isolates Claude-only agent metadata, aligns host manifests and documentation, and adds protocol plus pinned vendor-parser contracts in CI.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would change existing behavior)
  • Refactor (no functional change; rules/coding-standards.md compliance)
  • Documentation only
  • Audit-finding closure (cite the finding ID)

Test plan

  • All existing tests pass. (Full matrix passed on cc32fc56.)
  • New tests added for new behavior.
  • Mutation survival check: re-enabling the banner probe, dropping the custom Claude agent path, or drifting manifest versions/config fields fails the new contracts.
  • Manual verification of CLI / MCP-tool behavior changes.

Evidence run locally:

  • pytest tests_py/test_main.py tests_py/test_tool_profiles.py tests_py/infrastructure/test_stdio_transport_wiring.py tests_py/scripts/test_mcp_json_contract.py tests_py/scripts/test_cross_host_manifests.py tests_py/scripts/test_verify_mcp_hosts.py -q — 49 passed.
  • ruff check . and ruff format --check . — passed.
  • scripts/check_doc_claims.py, scripts/generate_repo_badges.py --check, workflow YAML parsing, and git diff --check — passed.
  • scripts/verify_mcp_hosts.py -- hypermnesia-mcp under a deliberately unusable SOCKS proxy — Claude, Gemini, and Codex client identities each completed initialize, discovery, and a real SQLite memory_stats call for both the full (54 tools locally) and exact lean (10 tools) profiles.
  • Pinned Claude, Gemini, and Codex CLIs parsed the plugin/extension/Codex MCP configuration locally; the same lockfile-backed checks now run in an isolated read-only CI job.

Audit notes

  • Engineering/genius review (Feynman): caught a missing Node runtime in CI, authenticated-E2E overclaims, an incomplete Codex environment example, inconsistent PyPI support language, and npm install-script trust scope.
  • Closures: added an isolated Node 24 job with read-only permissions and no persisted checkout credentials; narrowed claims to protocol/configuration contracts; included the SQLite env in Codex registration and assertions; documented PyPI as best-effort compatibility; documented why Claude's pinned postinstall is required.
  • Opus 5 review: challenged the overly broad banner suppression, missing lean-profile coverage, fork safety for vendor install scripts, ambiguous stdout diagnostics/resource intent, and an implicit PyPI support-policy change.
  • Closures in cc32fc56: preserve the banner and its setting while disabling only FastMCP's update lookup; verify all three hosts against both profiles and the exact 10-tool lean set; gate the vendor-parser job on same-repository PRs; reject malformed stdout at the parse boundary and document the resource contract; state the policy change in the changelog.
  • Outstanding deferred findings: none.

Coding-standards compliance

  • §2.2 Layer dependency direction preserved (no inward layer imports outward).
  • §3.2 No any (or as any, or untyped dicts at boundaries) in production code.
  • §4.1 No file > 500 lines (test files exempt).
  • §4.2 No function > 50 lines (dispatch tables exempt).
  • §4.4 No function with > 4 parameters.
  • §7 Local reasoning preserved (no clever constructs that defeat single-function comprehension).
  • §8 Every numeric constant ≥ 3 significant digits has a source or is represented without an unexplained literal.
  • §9 No dead code, no TODOs without issue references.

Breaking changes

None.

Screenshots / logs

Representative smoke output:

PASS claude-code/full: initialize + discovery + memory_stats (54 tools)
PASS claude-code/lean: initialize + discovery + memory_stats (10 tools)
PASS gemini-cli/full: initialize + discovery + memory_stats (54 tools)
PASS gemini-cli/lean: initialize + discovery + memory_stats (10 tools)
PASS codex-cli/full: initialize + discovery + memory_stats (54 tools)
PASS codex-cli/lean: initialize + discovery + memory_stats (10 tools)

Reviewer checklist

  • CHANGELOG.md updated under the appropriate section.
  • Documentation updated (README / SKILL.md / docs/).
  • No secrets / credentials / PII in the diff.
  • CI passes on the latest commit.

@cdeust cdeust left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — verified against the installed dependency, not the diff alone

CI is green at the time of writing (18 checks passed, Test (Python 3.12) still running).

What holds up

The root cause is correctly identified and fixed at its source. In the installed fastmcp==3.4.5, log_server_banner (utilities/cli.py:201) calls check_for_newer_version() as its first statement. The only other call sites are in fastmcp/cli/cli.py, which is not on this server's path. Suppressing the banner therefore does remove the sole network probe from the startup path — this is a fix at the cause, not at the throw site (coding-standards.md §6.1).

The signature is sound. show_banner is keyword-only and typed bool | None, so partial(run_stdio_drained, mcp, show_banner=False) yields the zero-argument callable anyio.run expects.

The new contract test is severe rather than accommodating. _environment() reproduces the actual failure (ALL_PROXY=socks5://127.0.0.1:9 with no socks extra) and — the detail that matters — pops FASTMCP_SHOW_SERVER_BANNER and FASTMCP_CHECK_FOR_UPDATES from the child environment. The test cannot pass by accident through ambient configuration. That is properly constructed falsifiability, and it is the strongest part of this PR.

The rename is clean. agents/claude-agents/ is detected as a 100% rename, plugin.json declares the new path, the wiki_groomer.py docstring follows, and no reference to the old path survives anywhere in the tree.

The new CI job is hardened: pinned action SHAs, persist-credentials: false, permissions: contents: read, a pinned lockfile, and a verified justification for permitting install scripts (--ignore-scripts was confirmed to leave claude plugin validate unusable). The rationale is documented at the right place.


Findings

1 — The fix is one level broader than its own stated diagnosis. (main finding)

The inline comment argues: "Version discovery belongs in an explicit maintenance/doctor path, never in the protocol handshake." That is an argument for disabling the version check, not the banner. fastmcp exposes exactly that lever: settings.check_for_updates (settings.py:354, FASTMCP_CHECK_FOR_UPDATES).

The concrete consequence: _resolve_show_banner exists precisely to honour fastmcp.settings.show_server_banner, carries a sourced citation, and was re-verified against the installed package on 2026-07-30. Its only production caller now passes a hard-coded False. That resolution branch becomes unreachable in production — it is kept alive only by its tests (tests_py/infrastructure/test_stdio_transport_wiring.py:166,182). Sourced, tested, no real caller: coding-standards.md §9.

The side effect is also silent: FASTMCP_SHOW_SERVER_BANNER=1 now does nothing, on every host, including hosts where the banner never caused a problem. The banner also carried server name/version diagnostics on stderr; that is gone for everyone.

Both resolutions are defensible, but the choice should be explicit:

  • target check_for_updates and leave banner resolution intact; or
  • accept "banner always off" and say so — in which case _resolve_show_banner should be removed or marked vestigial, not left in place implying a behaviour it no longer has.

2 — The lean profile has no protocol coverage.

MIN_TOOL_COUNT = 52 is a floor, and this PR's own evidence reports 54 tools. The assertion passes at 52, 54, or 200 — it guards against catastrophic loss, not against drift. More importantly, verify_mcp_hosts.py exercises only the full profile (no occurrence of lean or profile in the script), yet tool_profiles.py is modified here and LEAN_INSTRUCTIONS is part of the host-neutral contract this PR establishes. A lean server exposing ~10 tools would fail the floor outright. In a PR whose subject is the multi-host contract, that is the most notable gap.

3 — Supply-chain surface on fork PRs.

ci.yml triggers on pull_request, and the new job runs npm ci with install scripts enabled. A fork PR that edits package-lock.json executes arbitrary code on the runner. The mitigations are real (read-only token, no secrets on fork PRs, no persisted credentials), so the blast radius is compute abuse and cache poisoning rather than credential theft. But it is a surface introduced by this PR. Cheap hardening: gate the job on github.event.pull_request.head.repo.fork == false, or assert the lockfile is unmodified before npm ci.

4 — Two minor points in verify_mcp_hosts.py.

  • resources != [] freezes "no MCP resources" as a permanent contract. If that is intended, it deserves a comment; the day a resource is legitimately exposed, this fails with a message that does not explain why.
  • _responses() silently skips unparseable lines. A malformed frame surfaces as missing response for request id=N, pointing the diagnosis at the wrong place.

5 — A product-policy change ships inside a comment.

pyproject.toml moves PyPI from "a deprecated, best-effort secondary channel" to "the best-effort, hook-free compatibility channel for Gemini CLI, Codex CLI, and other hosts". That is consistent with the PR's intent, but it changes a support commitment. It belongs in CHANGELOG.md, not only in a source comment.


Verdict

Mergeable on substance: the fix is correct, minimal, placed at the cause, and proven by a real reproduction rather than a mock. Finding 1 warrants an explicit decision before publishing — not because it is wrong, but because the PR renders unreachable a path it documents as correct, without saying so. Findings 2 and 3 are legitimate follow-ups. Findings 4 and 5 are cosmetic.

@cdeust

cdeust commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Merci pour la revue. Les cinq points sont traités dans cc32fc56 :

  1. Le correctif est maintenant ciblé : le banner FastMCP et FASTMCP_SHOW_SERVER_BANNER restent actifs. Seul fastmcp.settings.check_for_updates passe à "off" au composition root avant le démarrage stdio. Le test d'entrée verrouille cette séparation.
  2. Le smoke protocolaire exécute désormais full et lean pour les trois identités hôtes. lean doit exposer exactement les 10 outils de LEAN_TOOL_NAMES; full est borné à 52–55 outils pour couvrir les trois intégrations optionnelles documentées. Les instructions d'initialisation et l'appel SQLite memory_stats sont vérifiés dans les deux profils.
  3. Le job des parseurs vendor ne s'exécute plus sur les PR issues de forks, afin qu'un lockfile non fiable ne puisse pas déclencher les scripts d'installation npm. Les PR du dépôt, main et workflow_dispatch restent couverts.
  4. Toute ligne stdout JSON malformée ou tout frame non objet échoue maintenant immédiatement au niveau du parseur, avec tests dédiés. Le contrat resources == [] est commenté comme choix d'interop explicite.
  5. Le changelog annonce explicitement le passage de PyPI du canal legacy déprécié vers une compatibilité locale stdio « best effort », Claude restant le canal principal.

Validation locale après correction : 49 tests ciblés, Ruff check/format, contrôles documentation/YAML, Pyright ciblé, et 6 cas stdio réels (Claude/Gemini/Codex × full/lean) passent.

@cdeust
cdeust merged commit 07ecdc2 into main Aug 2, 2026
20 checks passed
@cdeust
cdeust deleted the agent/verify-multi-host-mcp branch August 2, 2026 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant