From 169e284d57c714bd7750d6589df03223b51381db Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 15:20:19 -0700 Subject: [PATCH] feat(workspace-runtime): expose universal MCP server to runtime=external operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the baseline universal MCP path that any external runtime (Claude Code, hermes, codex, anything that speaks MCP stdio) can use, before optimizing per-runtime channels. Today the workspace MCP server only spins up inside the container; external operators have no way to call the 8 platform tools (delegate_task, list_peers, send_message_to_user, commit_memory, etc.) from outside. Three additive changes: 1. **`platform_auth.get_token()` env-var fallback** — adds `MOLECULE_WORKSPACE_TOKEN` as a fallback when no `${CONFIGS_DIR}/.auth_token` file exists. File-first preserves in-container behavior unchanged. External operators (no /configs volume) now have a way to supply the token without faking the filesystem layout. 2. **`molecule-mcp` console script** — adds a new entry point in the published `molecule-ai-workspace-runtime` PyPI wheel. Operators run `pip install molecule-ai-workspace-runtime`, set 3 env vars (WORKSPACE_ID, PLATFORM_URL, MOLECULE_WORKSPACE_TOKEN), and register the binary in their agent's MCP config. `mcp_cli.main` is a thin validator wrapper — it checks env BEFORE importing the heavy `a2a_mcp_server` module so a misconfigured first-run gets a friendly 3-line error instead of a 20-line module-level RuntimeError traceback. 3. **Wheel smoke gate** — extends `scripts/wheel_smoke.py` to assert `cli_main` and `mcp_cli.main` are importable. Same regression class as the 0.1.16 main_sync incident: a silent rename or unrewritten import here would break every external operator on the next wheel publish (memory: feedback_runtime_publish_pipeline_gates.md). Test coverage: - `tests/test_platform_auth.py` — 8 new tests for the env-var fallback: file-priority, env-fallback, whitespace handling, cache, header construction, empty-env-as-unset. - `tests/test_mcp_cli.py` — 8 new tests for the validator: each required var separately, file-or-env satisfies token requirement, whitespace-only env treated as missing, help mentions canvas Tokens tab. - Full `workspace/tests/` suite green: 1346 passed, 1 skipped. - Local end-to-end: built wheel, installed in venv, ran `molecule-mcp` with no env → friendly error; with env → MCP server starts. Why now / why this shape: user redirect was "support the baseline first so all runtimes can use, then optimize". A claude-only MCP channel leaves hermes/codex/third-party operators broken on runtime=external. This PR ships the runtime-agnostic baseline; per- runtime polish (claude-channel push delivery, hermes-native bindings) is a follow-up PR. PR #2412 fixed the partner bug where canvas Restart silently revoked the operator's token — the two together unblock the external-runtime story end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/build_runtime_package.py | 27 +++++ scripts/wheel_smoke.py | 11 ++ workspace/a2a_mcp_server.py | 31 +++++- workspace/mcp_cli.py | 74 ++++++++++++++ workspace/platform_auth.py | 44 +++++--- workspace/tests/test_mcp_cli.py | 141 ++++++++++++++++++++++++++ workspace/tests/test_platform_auth.py | 62 +++++++++++ 7 files changed, 377 insertions(+), 13 deletions(-) create mode 100644 workspace/mcp_cli.py create mode 100644 workspace/tests/test_mcp_cli.py diff --git a/scripts/build_runtime_package.py b/scripts/build_runtime_package.py index 956f4233..74db6b8d 100755 --- a/scripts/build_runtime_package.py +++ b/scripts/build_runtime_package.py @@ -68,6 +68,7 @@ TOP_LEVEL_MODULES = { "internal_chat_uploads", "internal_file_read", "main", + "mcp_cli", "molecule_ai_status", "platform_auth", "platform_inbound_auth", @@ -217,6 +218,7 @@ dependencies = [ [project.scripts] molecule-runtime = "molecule_runtime.main:main_sync" +molecule-mcp = "molecule_runtime.mcp_cli:main" [tool.setuptools.packages.find] where = ["."] @@ -240,6 +242,31 @@ directory** by the `publish-runtime` GitHub Actions workflow on every `runtime-v*` tag push. **Do not edit this package directly** — edit `workspace/` in the monorepo. +## External-runtime MCP server (`molecule-mcp`) + +Operators running an agent outside the platform's container fleet +(any runtime that supports MCP stdio — Claude Code, hermes, codex, +etc.) can install this wheel and run the universal MCP server +locally: + +```sh +pip install molecule-ai-workspace-runtime +WORKSPACE_ID= \\ + PLATFORM_URL=https://.staging.moleculesai.app \\ + MOLECULE_WORKSPACE_TOKEN= \\ + molecule-mcp +``` + +That exposes the same 8 platform tools (`delegate_task`, `list_peers`, +`send_message_to_user`, `commit_memory`, etc.) that container-bound +runtimes already get via the workspace's auto-spawned MCP. Register +the binary in your agent's MCP config (e.g. Claude Code's +`claude mcp add molecule -- molecule-mcp` with the env above). + +The token comes from the canvas → Tokens tab. Restarting an external +workspace from the canvas no longer revokes the token (PR #2412), so +operator tokens persist across status nudges. + See [`docs/workspace-runtime-package.md`](https://github.com/Molecule-AI/molecule-core/blob/main/docs/workspace-runtime-package.md) for the publish flow and architecture. """ diff --git a/scripts/wheel_smoke.py b/scripts/wheel_smoke.py index 32db3350..b4f14b03 100644 --- a/scripts/wheel_smoke.py +++ b/scripts/wheel_smoke.py @@ -32,6 +32,17 @@ def smoke_imports_and_invariants() -> None: from molecule_runtime.builtin_tools import memory # noqa: F401 from molecule_runtime.adapters import get_adapter, BaseAdapter, AdapterConfig + # cli_main + mcp_cli.main are the molecule-mcp console-script entry + # points — the external-runtime universal MCP path. Same regression + # class as the 0.1.16 main_sync incident: a silent rename or missed + # rewrite here would break every external operator's MCP install on + # the next wheel publish. Pin both names because pyproject points + # at mcp_cli.main, which then imports a2a_mcp_server.cli_main. + from molecule_runtime.a2a_mcp_server import cli_main # noqa: F401 + from molecule_runtime.mcp_cli import main as mcp_cli_main # noqa: F401 + assert callable(cli_main), "a2a_mcp_server.cli_main must be callable" + assert callable(mcp_cli_main), "mcp_cli.main must be callable" + assert a2a_client._A2A_ERROR_PREFIX, "a2a_client missing error sentinel" assert callable(get_adapter), "adapters.get_adapter must be callable" assert hasattr(BaseAdapter, "name"), "BaseAdapter interface broken" diff --git a/workspace/a2a_mcp_server.py b/workspace/a2a_mcp_server.py index a6455a42..59c1f3f4 100644 --- a/workspace/a2a_mcp_server.py +++ b/workspace/a2a_mcp_server.py @@ -201,5 +201,34 @@ async def main(): # pragma: no cover break -if __name__ == "__main__": # pragma: no cover +def cli_main() -> None: # pragma: no cover + """Synchronous entry point for the ``molecule-mcp`` console script. + + Declared in scripts/build_runtime_package.py as the wheel's + entry-point target (``molecule-mcp = "molecule_runtime.a2a_mcp_server:cli_main"``). + External-runtime operators install ``molecule-ai-workspace-runtime`` + and register this binary as an MCP server in their agent's config — + Claude Code, hermes, codex, anything that speaks MCP stdio. + + Required environment: + WORKSPACE_ID — this workspace's UUID (must + already be registered on the + platform). + PLATFORM_URL — base URL of the Molecule + platform (e.g. https://your- + tenant.staging.moleculesai.app). + MOLECULE_WORKSPACE_TOKEN — bearer token for this workspace + (issued by the platform on + first /registry/register). + + Mirrors the ``main_sync`` pattern in main.py — wheel-smoke gates + in scripts/wheel_smoke.py assert this name is importable so a + silent rename can't break every external-runtime operator's MCP + install (the 0.1.16 main_sync incident is the cautionary + precedent). + """ asyncio.run(main()) + + +if __name__ == "__main__": # pragma: no cover + cli_main() diff --git a/workspace/mcp_cli.py b/workspace/mcp_cli.py new file mode 100644 index 00000000..691a9a19 --- /dev/null +++ b/workspace/mcp_cli.py @@ -0,0 +1,74 @@ +"""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. + +Existing in-container usage (``python -m molecule_runtime.a2a_mcp_server`` +or direct import) is unaffected — those paths bypass this wrapper. Only +the external-runtime ``molecule-mcp`` console script routes through here. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def _print_missing_env_help(missing: list[str], have_token_file: bool) -> None: + print("molecule-mcp: missing required environment.\n", file=sys.stderr) + print("Set the following before running molecule-mcp:", file=sys.stderr) + print(" WORKSPACE_ID — your workspace UUID (from canvas)", file=sys.stderr) + print( + " PLATFORM_URL — base URL of your Molecule platform " + "(e.g. https://your-tenant.staging.moleculesai.app)", + file=sys.stderr, + ) + if not have_token_file: + print( + " MOLECULE_WORKSPACE_TOKEN — bearer token for this workspace " + "(canvas → Tokens tab)", + file=sys.stderr, + ) + print("", file=sys.stderr) + print(f"Currently missing: {', '.join(missing)}", file=sys.stderr) + + +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. + """ + missing: list[str] = [] + if not os.environ.get("WORKSPACE_ID", "").strip(): + missing.append("WORKSPACE_ID") + if not os.environ.get("PLATFORM_URL", "").strip(): + missing.append("PLATFORM_URL") + # Token can come from env OR file — only flag when both are absent. + # Mirrors platform_auth.get_token's resolution order (file-first, + # env-fallback). + configs_dir = Path(os.environ.get("CONFIGS_DIR", "/configs")) + has_token_file = (configs_dir / ".auth_token").is_file() + has_token_env = bool(os.environ.get("MOLECULE_WORKSPACE_TOKEN", "").strip()) + if not has_token_file and not has_token_env: + missing.append("MOLECULE_WORKSPACE_TOKEN (or CONFIGS_DIR/.auth_token)") + + if missing: + _print_missing_env_help(missing, have_token_file=has_token_file) + sys.exit(2) + + # 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() diff --git a/workspace/platform_auth.py b/workspace/platform_auth.py index f767381d..85d1b210 100644 --- a/workspace/platform_auth.py +++ b/workspace/platform_auth.py @@ -39,22 +39,42 @@ def _token_file() -> Path: def get_token() -> str | None: - """Return the cached token, reading it from disk on first call.""" + """Return the cached token, reading it from disk on first call. + + Resolution order: + 1. In-process cache (hot path) + 2. ``${CONFIGS_DIR}/.auth_token`` file (in-container default — + the platform writes this on provision and rotates it on + restart) + 3. ``MOLECULE_WORKSPACE_TOKEN`` env var (external-runtime path — + operators running the universal MCP server outside a + container have no /configs volume to populate, so they pass + the token via env) + + File-first preserves in-container behavior unchanged: containers + always have /configs/.auth_token on disk, env-var fallback only + fires when there's no file. This is additive — no existing caller + sees a behavior change. + """ global _cached_token if _cached_token is not None: return _cached_token path = _token_file() - if not path.exists(): - return None - try: - tok = path.read_text().strip() - except OSError as exc: - logger.warning("platform_auth: failed to read %s: %s", path, exc) - return None - if not tok: - return None - _cached_token = tok - return tok + if path.exists(): + try: + tok = path.read_text().strip() + except OSError as exc: + logger.warning("platform_auth: failed to read %s: %s", path, exc) + tok = "" + if tok: + _cached_token = tok + return tok + # File missing or empty — fall back to env (external-runtime path). + env_tok = os.environ.get("MOLECULE_WORKSPACE_TOKEN", "").strip() + if env_tok: + _cached_token = env_tok + return env_tok + return None def save_token(token: str) -> None: diff --git a/workspace/tests/test_mcp_cli.py b/workspace/tests/test_mcp_cli.py new file mode 100644 index 00000000..5e466838 --- /dev/null +++ b/workspace/tests/test_mcp_cli.py @@ -0,0 +1,141 @@ +"""Tests for workspace/mcp_cli.py — the molecule-mcp console-script +entry-point validator. + +The wrapper exists to surface a friendly missing-env error before +a2a_client.py:22's module-level RuntimeError fires. Regressions here +ship a poor first-run UX to every external-runtime operator. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +import mcp_cli + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch, tmp_path): + """Each test starts with no Molecule env vars set + a fresh + CONFIGS_DIR pointing at an empty tmpdir.""" + for var in ("WORKSPACE_ID", "PLATFORM_URL", "MOLECULE_WORKSPACE_TOKEN"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("CONFIGS_DIR", str(tmp_path)) + yield + + +def _run_main_capturing_exit(capsys) -> tuple[int, str]: + """Call mcp_cli.main and return (exit_code, stderr). + + main() is supposed to sys.exit on missing env. Any non-exit return + means it tried to run the real MCP loop, which we don't want in a + unit test (and which would also fail because we never set the + mandatory env). + """ + with pytest.raises(SystemExit) as exc_info: + mcp_cli.main() + captured = capsys.readouterr() + code = exc_info.value.code if isinstance(exc_info.value.code, int) else 1 + return code, captured.err + + +def test_missing_workspace_id_exits_with_message(capsys): + code, err = _run_main_capturing_exit(capsys) + assert code == 2, f"expected exit code 2, got {code}" + assert "WORKSPACE_ID" in err + assert "PLATFORM_URL" in err # also missing + assert "MOLECULE_WORKSPACE_TOKEN" in err # also missing + + +def test_only_workspace_id_missing(capsys, monkeypatch): + monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080") + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "tok") + code, err = _run_main_capturing_exit(capsys) + assert code == 2 + # Only WORKSPACE_ID should appear in the "currently missing" list. + assert "Currently missing: WORKSPACE_ID" in err + + +def test_only_platform_url_missing(capsys, monkeypatch): + monkeypatch.setenv("WORKSPACE_ID", "00000000-0000-0000-0000-000000000000") + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "tok") + code, err = _run_main_capturing_exit(capsys) + assert code == 2 + assert "Currently missing: PLATFORM_URL" in err + + +def test_only_token_missing(capsys, monkeypatch): + monkeypatch.setenv("WORKSPACE_ID", "00000000-0000-0000-0000-000000000000") + monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080") + code, err = _run_main_capturing_exit(capsys) + assert code == 2 + assert "MOLECULE_WORKSPACE_TOKEN" in err + + +def test_token_file_satisfies_token_requirement(capsys, monkeypatch, tmp_path): + """Token from CONFIGS_DIR/.auth_token must be accepted (in-container + path).""" + (tmp_path / ".auth_token").write_text("file-token") + monkeypatch.setenv("WORKSPACE_ID", "00000000-0000-0000-0000-000000000000") + monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080") + # No MOLECULE_WORKSPACE_TOKEN — but file exists. Validation should + # pass; we then short-circuit before importing the heavy module by + # patching the import to a no-op spy. + + spy_called: dict[str, bool] = {"called": False} + + def fake_cli_main(): + spy_called["called"] = True + + # Patch the heavy import to avoid actually running the MCP server. + # mcp_cli does the import lazily inside main(), so we monkeypatch + # sys.modules to inject a fake a2a_mcp_server. + import types + fake_module = types.ModuleType("a2a_mcp_server") + fake_module.cli_main = fake_cli_main + monkeypatch.setitem(sys.modules, "a2a_mcp_server", fake_module) + + mcp_cli.main() # should NOT exit + assert spy_called["called"], "expected cli_main to be invoked when env+file are valid" + + +def test_env_token_satisfies_token_requirement(capsys, monkeypatch): + """Token from env must be accepted (external-runtime path).""" + monkeypatch.setenv("WORKSPACE_ID", "00000000-0000-0000-0000-000000000000") + monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080") + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "env-token") + + spy_called: dict[str, bool] = {"called": False} + + def fake_cli_main(): + spy_called["called"] = True + + import types + fake_module = types.ModuleType("a2a_mcp_server") + fake_module.cli_main = fake_cli_main + monkeypatch.setitem(sys.modules, "a2a_mcp_server", fake_module) + + mcp_cli.main() + assert spy_called["called"] + + +def test_whitespace_only_env_treated_as_missing(capsys, monkeypatch): + """An accidentally-empty env var (WORKSPACE_ID=" ") must NOT be + considered set — otherwise the error would surface deep inside an + HTTP call instead of in this validator.""" + monkeypatch.setenv("WORKSPACE_ID", " ") + monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080") + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "tok") + code, err = _run_main_capturing_exit(capsys) + assert code == 2 + assert "WORKSPACE_ID" in err + + +def test_help_lists_canvas_tokens_tab_pointer(capsys): + """Operator must know WHERE to get a token. The help mentions the + canvas Tokens tab so they can self-recover without asking on + Slack.""" + code, err = _run_main_capturing_exit(capsys) + assert code == 2 + assert "Tokens tab" in err or "canvas" in err.lower() diff --git a/workspace/tests/test_platform_auth.py b/workspace/tests/test_platform_auth.py index ca08bdf7..bff0a20a 100644 --- a/workspace/tests/test_platform_auth.py +++ b/workspace/tests/test_platform_auth.py @@ -119,3 +119,65 @@ def test_default_configs_dir_fallback(tmp_path, monkeypatch): # We expect _token_file() to resolve under /configs when env is unset. path = platform_auth._token_file() assert str(path).startswith("/configs") + + +# ==================== MOLECULE_WORKSPACE_TOKEN env-var fallback ==================== +# External-runtime path: operators running the universal MCP server outside +# a container have no /configs volume. They pass the token via env. The +# fallback must NOT override the file when both are present (in-container +# rotation must keep working) and MUST surface env when the file is absent. + + +def test_get_token_uses_env_when_file_absent(tmp_path, monkeypatch): + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "env-token-xyz") + assert not (tmp_path / ".auth_token").exists() + assert platform_auth.get_token() == "env-token-xyz" + + +def test_get_token_file_takes_priority_over_env(tmp_path, monkeypatch): + """In-container rotation must keep working — file overrides env.""" + (tmp_path / ".auth_token").write_text("file-token") + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "env-token-should-be-ignored") + assert platform_auth.get_token() == "file-token" + + +def test_get_token_falls_back_to_env_when_file_empty(tmp_path, monkeypatch): + """Empty file is equivalent to absent — env still fires.""" + (tmp_path / ".auth_token").write_text("") + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "env-token-fallback") + assert platform_auth.get_token() == "env-token-fallback" + + +def test_get_token_strips_env_whitespace(tmp_path, monkeypatch): + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", " padded-env-token \n") + assert platform_auth.get_token() == "padded-env-token" + + +def test_get_token_ignores_empty_env(tmp_path, monkeypatch): + """Empty string env var is the same as unset — no false positive.""" + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "") + assert platform_auth.get_token() is None + + +def test_get_token_ignores_whitespace_only_env(tmp_path, monkeypatch): + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", " \n\n ") + assert platform_auth.get_token() is None + + +def test_env_token_caches_like_file_token(tmp_path, monkeypatch): + """Once env-token is read, mutating env shouldn't affect cached value.""" + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "first-env-token") + assert platform_auth.get_token() == "first-env-token" + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "second-env-token") + # Cache returns first value + assert platform_auth.get_token() == "first-env-token" + # clear_cache forces re-read of env + platform_auth.clear_cache() + assert platform_auth.get_token() == "second-env-token" + + +def test_auth_headers_works_with_env_token(tmp_path, monkeypatch): + """Header construction must use the env-fallback token, not silently + return {} when no file exists.""" + monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "external-bearer") + assert platform_auth.auth_headers() == {"Authorization": "Bearer external-bearer"}