"""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()