Repository navigation
feat(mcp): plugin discovery for zero-downtime MCP bridge registration (GH#4462) - #8719
Conversation
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
Code Review — PR #8719 (MVA-1052: MCP plugin discovery)Reviewer: CodeReviewer agent | CI: startup-import-smoke ✅ | code-quality ❌ (Black, non-blocking) Verdict: Requires changes before merge. Two high-priority blockers (N+1 Redis, singleton anti-pattern) plus one issue needing explicit documentation (per-worker reload). High Priority — Blockers1. Singleton anti-pattern: The manual from autobot_shared.singleton_factory import lazy_singleton
_get_toggle_service = lazy_singleton(MCP_BridgeToggleService)2. N+1 Redis calls in
keys = [f"mcp_bridge:enabled:{n}" for n, *_ in MCP_BRIDGES]
values = await redis.mget(*keys)
enabled_map = {
name: (v is None or v.decode().lower() != "false")
for name, v in zip([n for n, *_ in MCP_BRIDGES], values)
}3. Production FastAPI runs 4 uvicorn workers.
Medium Priority4. Class name Class names must be 5.
Update the dataclass to 6. Toggle state in Bridge enable/disable state is persistent configuration, not cache. Storing it in the cache database means a cache flush silently resets all bridges to enabled. Use 7. The schema is defined but 8. Import order violation in In from type_defs.common import Metadata # ← last of one group
MANIFEST = MCPBridgeManifest(...) # ← module-level definition
from .schemas_code import (...) # ← new import group AFTER a definitionMove all imports above all module-level definitions. Low Priority9. Hardcoded
10. Disabled bridges removed from the codebase leave stale 11.
What Works Well
|
Second-pass CR — PR #8719 (commit e36e297)Reviewer: CodeReviewer (MVA-1182) All 11 items in the CR fix list were applied. However, verification of the resulting code surfaced 2 blocking bugs and 6 plausible issues that need attention before merge. 🔴 BLOCKING — Must fix before merge1.
2. import importlib.util # ← unused; importlib.metadata and importlib.import_module are usedCI enforces zero flake8 errors (per policy). This will fail the 🟡 PLAUSIBLE — Should fix or explicitly document3. Per-worker reload inconsistency degrades production reliability
4. Entry-point-discovered bridges can never be reloaded (HTTP 400 always) Bridges discovered via 5.
6. Empty
7. Silent bridge degradation at startup — minimal manifest with When 8. from fastapi import APIRouter, Depends
from services.mcp_bridge_manifest import MCPBridgeManifest
MANIFEST = MCPBridgeManifest(...) # ← module-level statement between import groups
from auth_middleware import get_current_userAll other bridge files place MANIFEST after all imports. An isort/pre-commit pass could reorder imports around the assignment. Inconsistent with fix #8 "Import ordering fixed". Move MANIFEST below all imports. ✅ Verified fixes from first CR
Decision: not ready to merge. Fix items 1 and 2 (blocking) and address items 3–8 before approval. |
mrveiss
left a comment
There was a problem hiding this comment.
Code Review — PR #8719 feat(mcp): plugin discovery for zero-downtime MCP bridge registration
Verdict: Changes Requested — 5 confirmed bugs, 5 confirmed PLAUSIBLE issues. Architecture direction is sound; correctness and reliability gaps need fixing.
🔴 Critical — Must Fix
1. Split-brain after reload_mcp_bridge: MCP_BRIDGES frozen, _MANIFEST_REGISTRY live (mcp_registry.py:438)
reload_mcp_bridge updates _MANIFEST_REGISTRY[name] but MCP_BRIDGES was frozen at module-import. _fetch_bridges_info reads bridge_desc/features from the stale tuple while serving fresh manifest_dict from the registry — callers see split state until process restart. After any reload that changes description, features, or endpoint, the top-level response fields diverge from the manifest block.
Fix: After updating _MANIFEST_REGISTRY, rebuild the affected MCP_BRIDGES entry (or drive the loop entirely from _MANIFEST_REGISTRY).
2. _get_redis() returns Optional[Redis]; no None guard in any toggle method (mcp_registry.py:192–231)
is_bridge_enabled, get_enabled_batch, and set_bridge_enabled all call redis.get/mget/set directly. On None return: AttributeError is swallowed by except Exception, logged as misleading "Failed to read bridge enabled state". set_bridge_enabled re-raises, surfacing as an opaque 500. Per CLAUDE.md: always guard if redis is None.
3. Per-worker cache: mcp_cache.invalidate_all() only clears 1 of 4 uvicorn workers (mcp_registry.py:481, 502, 542)
mcp_cache is an in-process singleton. After a toggle POST hits worker #2 and clears its cache, subsequent GETs landing on workers #1/#3/#4 continue serving stale enabled state for up to CACHE_TTL_SECONDS (300s default). Toggle appears non-functional from the admin UI. Known footgun: "any in-process singleton is per-worker" (memory index).
Fix: Pub/sub Redis notification on toggle so all workers pick up the state change, or remove enabled from the TTL-cached bridge list.
🟠 High — Must Fix Before Merge
4. Boolean parsing: "0", "no", "disabled" all treated as enabled=True (mcp_registry.py:198, 218)
value.lower() != "false" only disables on the exact string "false". Common Redis CLI convention (SET key 0) silently re-enables a disabled bridge. Also: value.decode() with no errors= arg raises UnicodeDecodeError on non-UTF-8 bytes, caught by except Exception → silent fail-open.
Fix: Explicit allowlist parse (value in (b"true", b"1")) and add errors="replace" to the decode.
5. reload_mcp_bridge leaks raw exception detail to API clients (mcp_registry.py:541)
raise HTTPException(status_code=500, detail=f"Failed to reload bridge '{name}': {e}")Exposes filesystem paths, import error details, and line numbers (CWE-209). Admin-gated but still a disclosure in audit logs.
Fix: Log full exception server-side; return a safe generic message in the detail field.
🟡 Medium
6. description="" fallback breaks old invariant; swallowed at DEBUG level (mcp_registry.py:380, 387)
Old static MCP_BRIDGES always had non-empty descriptions. New discover_bridges() falls back to description="" on import failure, logged at DEBUG (invisible in prod). test_discover_bridges_structure only checks isinstance(desc, str) — CI does not catch this regression.
Fix: Log at WARNING on import failure; add assert desc != "" in the test.
7. Entry-point bridges have module_path="", reload_mcp_bridge returns 400 with misleading message (mcp_registry.py:396, 522)
HTTPException(400, "No reloadable module path for bridge '{name}'") implies user error; actual cause is "entry-point bridges don't support hot-reload." Operators waste triage time.
8. importlib.reload() creates a new router object; FastAPI route table is NOT updated (mcp_registry.py:531)
Endpoint handlers in the running app remain unchanged after reload — only MANIFEST metadata updates. The endpoint name "reload" is misleading. Should clarify in the response message that only manifest metadata was refreshed; endpoint handler updates require a service restart.
🔵 Minor
9. _MANIFEST_REGISTRY.clear() races with concurrent _fetch_bridges_info reads (mcp_registry.py:350)
Not triggered in current code (discover_bridges is only called at import), but if re-invoked, the clear + repopulate is not atomic. In-flight readers get manifest=null for all bridges.
10. _MANIFEST_REGISTRY: dict[str, ...] mixes PEP 585 lowercase dict with Tuple from typing (mcp_registry.py:345)
Minor inconsistency — rest of file uses Dict from typing. Use Dict for uniformity.
Summary
| Severity | # | Items |
|---|---|---|
| 🔴 Critical | 3 | #1 split-brain, #2 None guard, #3 per-worker cache |
| 🟠 High | 2 | #4 bool parse, #5 exception leak |
| 🟡 Medium | 3 | #6 empty desc, #7 reload 400 msg, #8 misleading endpoint |
| 🔵 Minor | 2 | #9 race, #10 type annotation |
Items #1–#5 should all be fixed before merge. The per-worker cache invalidation (#3) is the most likely to cause confusing admin UI behavior in production.
…#4462) - Add MCPBridgeManifest dataclass for bridge metadata - Implement entry-point based discovery with module-scan fallback - Add per-bridge Redis enable/disable toggle - Add /bridges/{name}/enable|disable|reload endpoints - Update GET /bridges to include manifest and enabled status - Add unit tests for discovery and toggle Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Rename MCP_BridgeToggleService -> MCPBridgeToggleService (PEP 8) - Replace global singleton pattern with lazy_singleton() (project rule #5622) - Batch Redis enable-state lookups via mget (eliminates N+1 calls) - Switch toggle Redis DB from 'cache' to 'main' for durability - Add reload warning: importlib.reload affects only the current worker - Use MCPBridgeManifestSchema for manifest serialization in _fetch_bridges_info - Fix resource_limits type: Dict[str,int] -> Dict[str,Any] in dataclass - Import MCPBridgeManifestSchema, lazy_singleton; add Dict to typing imports - Fix import ordering in database_mcp.py and sequential_thinking_mcp.py - Replace hardcoded 11 in tests with len(_BRIDGE_MODULE_REGISTRY) - Clear _MANIFEST_REGISTRY at start of discover_bridges() to prevent accumulation Co-Authored-By: Paperclip <noreply@paperclip.ing>
Blockers: - Remove unused import importlib.util (flake8 F401) - _fetch_bridges_info now iterates _MANIFEST_REGISTRY directly so reloaded manifests are always fresh (MCP_BRIDGES was stale after reload) Plausible fixes: - reload endpoint returns HTTP 202 (partial consistency across uvicorn workers) - Entry-point bridges (module_path='') get descriptive 400 instead of misleading 'no reloadable module path' error - set_bridge_enabled guards against None redis client (prevents AttributeError) - Health-check skipped for bridges with no endpoint; status='unknown' - Fallback manifest log raised from DEBUG to WARNING - knowledge_mcp.py: move MANIFEST assignment after all imports (PEP 8) Co-Authored-By: Paperclip <noreply@paperclip.ing>
Thinking Path
GH#4462 requires zero-downtime MCP bridge registration: bridges should be discoverable at runtime without a process restart, and individually togglable via an admin API. The key constraint is backward compatibility — existing bridges registered as tuples must keep working.
Approach: use
importlib.metadataentry_points (PEP 517/660 standard plugin mechanism) as the primary discovery path, with a module-scan fallback for bridges that have aMANIFESTattribute but no entry_point. A Redis-backed toggle service (mcp_bridge:enabled:{name}) provides per-bridge enable/disable without touching the process. TheMCPBridgeManifestdataclass formalises metadata that was previously implicit.Alternatives considered: environment-variable gating (too coarse, no runtime toggle), a config-file registry (requires restart to pick up changes), dynamic import at request time (latency unpredictable).
What Changed
autobot-backend/services/mcp_bridge_manifest.py(new) —MCPBridgeManifestdataclass:name,version,description,features,endpoint,resource_limitsautobot-backend/api/mcp_registry.py—discover_bridges()scans entry_points groupautobot.mcp_bridgesthen falls back to module-scan forMANIFESTattrs;MCP_BridgeToggleServicefor Redis-backed enable/disable; new admin endpointsGET /bridges,POST /bridges/{name}/enable|disable|reload;MCP_BRIDGESnow dynamically populated at startup (backward-compatible)autobot-backend/api/schemas_code.py—BridgeManifestSchema,BridgeListSchemafor API response serialisationknowledge_mcp,vnc_mcp,sequential_thinking_mcp,structured_thinking_mcp,filesystem_mcp,browser_mcp,http_client_mcp,database_mcp,git_mcp,redis_mcp/__init__.py,prometheus_mcp) — each gainsMANIFEST = MCPBridgeManifest(...)for auto-discoveryautobot-backend/tests/unit/api/test_mcp_plugin_discovery.py(new) — 201-line unit test suite covering discovery, toggle, manifest validation, reload paths, and backward compat with legacy tuple formatVerification
Risks
MCP_BridgeToggleServicefalls back to treating all bridges as enabled (fail-open). Acceptable for most deployments but should be hardened if strict bridge isolation is needed.pip install -e .or a proper package install; bare script execution won't populate entry_points. Fallback module-scan covers this case.reloadendpoint re-imports the module — any global state in a bridge module is reset.Model Used
Claude Sonnet 4.6 (claude-sonnet-4-6)
Issue Link
Closes #4462
Checklist
CLAUDE.mdgit commitruns them automatically)Dev_new_gui(notmain)