forked from molecule-ai/molecule-core
Closes #2934 item 6 — the deferred follow-up from Ryan's onboarding- friction report. Quote: "this single command would have saved me 30 of the 45 minutes." When push delivery fails or the install half-works, the operator today has no signal — they hand-grep the Claude Code binary or chase the `from versions: none` red herring. Doctor renders six checks in one screen with concrete next-step suggestions: 1. Python version >=3.11? (wheel's pin) 2. Wheel install molecule-ai-workspace-runtime importable + version surfaced 3. PATH for binary `molecule-mcp` resolves on PATH; if not, prints the resolved user-site bin dir to add (or recommends pipx) 4. Env vars PLATFORM_URL + WORKSPACE_ID + token (env or *_FILE or .auth_token) 5. Platform reach GET ${PLATFORM_URL}/healthz returns 2xx 6. Registry register POST /registry/register with the resolved token returns 2xx — end-to-end auth check Each line: `[OK|WARN|FAIL] <label>: <status>` plus a `next:` hint when not OK. ANSI colors auto-disable on non-TTY / NO_COLOR. Exit code: 0 on all-OK or only-WARN, 1 on any FAIL — scriptable from CI install-checks. ## Files `workspace/mcp_doctor.py` (new) — six check functions + `run()` entry point. Uses urllib (stdlib) so doctor works even on a partial install where `requests` is missing. `workspace/mcp_cli.py` Subcommand dispatch: molecule-mcp doctor → mcp_doctor.run() molecule-mcp --help → usage banner molecule-mcp → server (unchanged) `workspace/tests/test_mcp_doctor.py` (new) — 10 tests covering each check's pass/fail/skip path plus the end-to-end exit-code contract on a stripped env. `scripts/build_runtime_package.py` Adds `mcp_doctor` to TOP_LEVEL_MODULES so the wheel ships the new module. ## Out of scope (deferred follow-ups) - Claude Code-specific checks (parse ~/.claude.json, verify each MCP entry is plugin-sourced + dev-channels flag set). That's a separate Claude-Code-shaped doctor; lives in the channel plugin. - Automated remediation. Doctor is diagnostic — tells the operator what's wrong + how to fix it, doesn't apply changes. ## Verification - python -m pytest tests/test_mcp_doctor.py -v → 10/10 PASS - python -m pytest tests/test_mcp_cli*.py → 67/67 PASS (existing CLI suite still green; subcommand dispatch added before env-validation, doesn't disturb the server-boot path) - manual: `molecule-mcp doctor` on a stripped env renders 4 FAIL + 2 WARN + exit code 1, with each `next:` hint actionable Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
10 KiB
Python
221 lines
10 KiB
Python
"""Console-script entry point for the ``molecule-mcp`` universal MCP server.
|
|
|
|
Validates required environment BEFORE importing the heavy
|
|
``a2a_mcp_server`` module — that module triggers a ``RuntimeError`` at
|
|
import time when ``WORKSPACE_ID`` is unset (a2a_client.py:22), and
|
|
console-script entry-point shims surface it as an ugly traceback. This
|
|
wrapper catches the missing-env case early and prints actionable help
|
|
to stderr so an operator running ``molecule-mcp`` for the first time
|
|
gets the right pointer in the first 3 lines of output instead of a
|
|
20-line traceback.
|
|
|
|
Standalone-runtime contract: this wrapper is responsible for keeping
|
|
the workspace ALIVE on the platform side, not just exposing tools.
|
|
Concretely it:
|
|
1. Calls ``POST /registry/register`` once at startup (idempotent —
|
|
the upsert flips status awaiting_agent → online for an external
|
|
workspace whose token matches).
|
|
2. Spawns a daemon heartbeat thread that POSTs to
|
|
``POST /registry/heartbeat`` every 20s. Without continuous
|
|
heartbeats the platform's healthsweep flips the workspace back
|
|
to awaiting_agent (visible as OFFLINE in the canvas with a
|
|
"Restart" CTA) within 60-90s.
|
|
3. Runs the MCP stdio loop in the foreground.
|
|
|
|
Why threads + sync requests: the MCP stdio server is async. The
|
|
heartbeat work is fire-and-forget HTTP. A daemon thread is the
|
|
lowest-friction integration — no asyncio bridging, dies automatically
|
|
when the main process exits, and ``requests`` is already a transitive
|
|
dependency via ``a2a-sdk``.
|
|
|
|
In-container usage (``python -m molecule_runtime.a2a_mcp_server`` or
|
|
direct import) bypasses this wrapper — the workspace runtime has its
|
|
own heartbeat loop in ``heartbeat.py`` so we don't double-heartbeat.
|
|
|
|
Module layout (RFC #2873 iter 3 split):
|
|
* ``mcp_heartbeat`` — register POST + heartbeat loop + auth-failure
|
|
escalation + inbound-secret persistence.
|
|
* ``mcp_workspace_resolver`` — env validation, single + multi-workspace
|
|
resolution, operator-help printer, on-disk token-file read.
|
|
* ``mcp_inbox_pollers`` — activate the inbox singleton + spawn one
|
|
daemon poller per workspace.
|
|
|
|
This file keeps just ``main()`` plus thin re-exports of the private
|
|
symbols so existing tests' imports (``mcp_cli._build_agent_card``,
|
|
``mcp_cli._heartbeat_loop``, etc.) keep working without churn.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
|
|
import configs_dir
|
|
import mcp_heartbeat
|
|
import mcp_inbox_pollers
|
|
import mcp_workspace_resolver
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Re-export public surface for back-compat with the pre-split callers
|
|
# and tests. The underscore-prefixed names mirror the names that
|
|
# existed in this module before the split — keeping them ensures
|
|
# `mcp_cli._build_agent_card`, `mcp_cli._heartbeat_loop`, etc.
|
|
# resolve identically to the new functions.
|
|
HEARTBEAT_INTERVAL_SECONDS = mcp_heartbeat.HEARTBEAT_INTERVAL_SECONDS
|
|
_HEARTBEAT_AUTH_LOUD_THRESHOLD = mcp_heartbeat.HEARTBEAT_AUTH_LOUD_THRESHOLD
|
|
_HEARTBEAT_AUTH_RELOG_INTERVAL = mcp_heartbeat.HEARTBEAT_AUTH_RELOG_INTERVAL
|
|
|
|
_build_agent_card = mcp_heartbeat.build_agent_card
|
|
_platform_register = mcp_heartbeat.platform_register
|
|
_heartbeat_loop = mcp_heartbeat.heartbeat_loop
|
|
_log_heartbeat_auth_failure = mcp_heartbeat.log_heartbeat_auth_failure
|
|
_persist_inbound_secret_from_heartbeat = mcp_heartbeat.persist_inbound_secret_from_heartbeat
|
|
_start_heartbeat_thread = mcp_heartbeat.start_heartbeat_thread
|
|
|
|
_resolve_workspaces = mcp_workspace_resolver.resolve_workspaces
|
|
_print_missing_env_help = mcp_workspace_resolver.print_missing_env_help
|
|
_read_token_file = mcp_workspace_resolver.read_token_file
|
|
|
|
_start_inbox_pollers = mcp_inbox_pollers.start_inbox_pollers
|
|
|
|
|
|
def main() -> None:
|
|
"""Entry point for the ``molecule-mcp`` console script.
|
|
|
|
Returns nothing — calls ``sys.exit`` on validation failure or on
|
|
normal completion of the underlying MCP server loop.
|
|
|
|
Two registration shapes:
|
|
* Single-workspace (legacy): ``WORKSPACE_ID`` + token env/file.
|
|
Unchanged behavior.
|
|
* Multi-workspace: ``MOLECULE_WORKSPACES`` JSON env var with N
|
|
``{"id": ..., "token": ...}`` entries. One register + heartbeat
|
|
+ inbox poller per entry; messages from any workspace land in
|
|
the same agent inbox tagged with ``arrival_workspace_id``.
|
|
|
|
Subcommand:
|
|
``molecule-mcp doctor`` runs an onboarding diagnostic against the
|
|
current shell environment + platform reachability and exits.
|
|
Closes Ryan's #2934 item 6.
|
|
"""
|
|
# Subcommand dispatch — must come BEFORE env-var validation so
|
|
# `molecule-mcp doctor` can run on a partially-configured shell
|
|
# and tell the operator what's missing. Argv shapes:
|
|
# molecule-mcp → run server (this function's main path)
|
|
# molecule-mcp doctor → run diagnostic, exit
|
|
# molecule-mcp --help → defer to doctor for now (no other
|
|
# flags are supported yet)
|
|
if len(sys.argv) > 1:
|
|
if sys.argv[1] in ("doctor", "--doctor"):
|
|
import mcp_doctor
|
|
sys.exit(mcp_doctor.run())
|
|
if sys.argv[1] in ("--help", "-h", "help"):
|
|
print(
|
|
"molecule-mcp — Molecule AI universal MCP server\n\n"
|
|
"Usage:\n"
|
|
" molecule-mcp Run the MCP stdio server (registers + heartbeats)\n"
|
|
" molecule-mcp doctor Run onboarding diagnostic + exit\n\n"
|
|
"Required env: PLATFORM_URL, WORKSPACE_ID (or MOLECULE_WORKSPACES),\n"
|
|
" MOLECULE_WORKSPACE_TOKEN (or MOLECULE_WORKSPACE_TOKEN_FILE)\n",
|
|
)
|
|
sys.exit(0)
|
|
|
|
if not os.environ.get("PLATFORM_URL", "").strip():
|
|
_print_missing_env_help(
|
|
["PLATFORM_URL"],
|
|
have_token_file=(configs_dir.resolve() / ".auth_token").is_file(),
|
|
)
|
|
sys.exit(2)
|
|
|
|
workspaces, errors = _resolve_workspaces()
|
|
if errors or not workspaces:
|
|
# Reuse the missing-env help printer for legacy WORKSPACE_ID +
|
|
# token shape, which is what most first-run operators hit. For
|
|
# MOLECULE_WORKSPACES errors, print directly so the JSON-shape
|
|
# message isn't mangled into the WORKSPACE_ID-style help.
|
|
if os.environ.get("MOLECULE_WORKSPACES", "").strip():
|
|
print("molecule-mcp: invalid MOLECULE_WORKSPACES:", file=sys.stderr)
|
|
for e in errors:
|
|
print(f" - {e}", file=sys.stderr)
|
|
else:
|
|
_print_missing_env_help(
|
|
errors or ["WORKSPACE_ID", "MOLECULE_WORKSPACE_TOKEN"],
|
|
have_token_file=(configs_dir.resolve() / ".auth_token").is_file(),
|
|
)
|
|
sys.exit(2)
|
|
|
|
platform_url = os.environ["PLATFORM_URL"].strip().rstrip("/")
|
|
|
|
# In multi-workspace mode the FIRST entry is treated as the
|
|
# "primary" — it gets exported to a2a_client.py's module-level
|
|
# WORKSPACE_ID (which gates a RuntimeError at import time) and is
|
|
# used by tools that don't yet take an explicit workspace_id. PR-2
|
|
# parameterizes those tools; for now this preserves existing
|
|
# outbound-tool behavior unchanged for single-workspace operators
|
|
# AND for the multi-workspace operator's first registered
|
|
# workspace.
|
|
primary_workspace_id, _primary_token = workspaces[0]
|
|
os.environ["WORKSPACE_ID"] = primary_workspace_id
|
|
|
|
# Configure logging so the operator sees register/heartbeat status
|
|
# without needing to set up logging themselves. WARNING by default
|
|
# keeps the steady-state quiet (only failures); MOLECULE_MCP_VERBOSE=1
|
|
# surfaces register-success + per-tick heartbeat info for debugging.
|
|
log_level = (
|
|
logging.INFO
|
|
if os.environ.get("MOLECULE_MCP_VERBOSE", "").strip()
|
|
else logging.WARNING
|
|
)
|
|
logging.basicConfig(level=log_level, format="[molecule-mcp] %(message)s")
|
|
|
|
# Populate the per-workspace token registry so heartbeat threads,
|
|
# the inbox poller, and (later) outbound tools resolve the right
|
|
# token for each workspace via ``platform_auth.auth_headers(wsid)``.
|
|
# Done BEFORE register/heartbeat thread spawn so a thread that
|
|
# races to fire its first request always sees its token.
|
|
try:
|
|
from platform_auth import register_workspace_token
|
|
for wsid, tok in workspaces:
|
|
register_workspace_token(wsid, tok)
|
|
except ImportError:
|
|
# Older installs that don't yet ship register_workspace_token —
|
|
# multi-workspace resolution silently degrades to the legacy
|
|
# single-token path; single-workspace operators see no change.
|
|
logger.debug("platform_auth.register_workspace_token unavailable; skipping registry populate")
|
|
|
|
# Standalone-mode register + heartbeat. Skipped via env var so an
|
|
# in-container caller (which has its own heartbeat loop) can reuse
|
|
# this entry point without double-heartbeating. The wheel's main
|
|
# console-script path always runs them; the
|
|
# MOLECULE_MCP_DISABLE_HEARTBEAT escape hatch exists for tests +
|
|
# the rare embedded use-case.
|
|
if not os.environ.get("MOLECULE_MCP_DISABLE_HEARTBEAT", "").strip():
|
|
for wsid, tok in workspaces:
|
|
_platform_register(platform_url, wsid, tok)
|
|
_start_heartbeat_thread(platform_url, wsid, tok)
|
|
|
|
# Inbox poller — the inbound side of the standalone path. Without
|
|
# this thread, the universal MCP server is OUTBOUND-ONLY: an agent
|
|
# can call delegate_task / send_message_to_user but never observe
|
|
# canvas-user or peer-agent messages. One poller per workspace; all
|
|
# of them write to the SAME shared inbox state so the agent's
|
|
# inbox_peek/pop/wait tools see a merged view (each message tagged
|
|
# with arrival_workspace_id so the agent can route the reply).
|
|
#
|
|
# Same disable pattern as heartbeat: in-container callers (with
|
|
# push delivery via canvas WebSocket) skip this to avoid duplicate
|
|
# delivery; tests use the env to keep imports cheap.
|
|
if not os.environ.get("MOLECULE_MCP_DISABLE_INBOX", "").strip():
|
|
_start_inbox_pollers(platform_url, [w[0] for w in workspaces])
|
|
|
|
# Env is valid — safe to import the heavy module now. Importing
|
|
# earlier would trigger a2a_client.py:22's module-level RuntimeError
|
|
# before our friendly help reaches the user.
|
|
from a2a_mcp_server import cli_main
|
|
cli_main()
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
main()
|