From 169e284d57c714bd7750d6589df03223b51381db Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 15:20:19 -0700 Subject: [PATCH 1/6] 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"} From 74c5e0d7a81a26d318bc7ef2d2f2c7b8840b373f Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 15:30:15 -0700 Subject: [PATCH 2/6] fix(workspace-runtime): add Origin header so SaaS edge WAF accepts MCP tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered while smoke-testing the molecule-mcp external-runtime path against a live tenant (hongmingwang.moleculesai.app). Every tool call that hit /workspaces/* or /registry/*/peers returned 404 — but /registry/register and /registry/heartbeat returned 200. Diagnosis: the tenant's edge WAF requires a same-origin header. Without it, unhandled paths get silently rewritten to the canvas Next.js app, which has no /workspaces or /registry/:id/peers route and returns an empty 404. The molecule-mcp-claude-channel plugin already sets this header (server.ts:271-276); the workspace runtime never did because in-container PLATFORM_URLs (Docker network) aren't behind the WAF. Fix: extend platform_auth.auth_headers() to include Origin: ${PLATFORM_URL} whenever PLATFORM_URL is set. Inside-container behavior is unchanged (the WAF is path-irrelevant for the internal hostnames). External-runtime calls now thread the WAF correctly. Verification (live, against a freshly-registered external workspace): pre-fix: get_workspace_info → "not found", list_peers → 404 post-fix: get_workspace_info → full workspace JSON, list_peers → "Claude Code Agent (ID: 97ac32e9..., status: online)" This is the kind of bug unit tests can never catch — caught only by running the wheel against the real tenant. Memory: feedback_always_run_e2e.md. Test coverage: 4 new tests in test_platform_auth.py — Origin alone when no token + Origin + Authorization both, no-PLATFORM_URL falls through to original empty-dict behavior, env-token path with Origin. Co-Authored-By: Claude Opus 4.7 (1M context) --- workspace/platform_auth.py | 23 +++++++++++++++++++---- workspace/tests/test_platform_auth.py | 26 ++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/workspace/platform_auth.py b/workspace/platform_auth.py index 85d1b210..da4e4bd9 100644 --- a/workspace/platform_auth.py +++ b/workspace/platform_auth.py @@ -111,11 +111,26 @@ def auth_headers() -> dict[str, str]: """Return a header dict to merge into httpx calls. Empty if no token is available yet — callers send the request as-is and the platform's heartbeat handler grandfathers pre-token workspaces through until - their next /registry/register issues one.""" + their next /registry/register issues one. + + Always sets ``Origin`` to ``PLATFORM_URL`` when that env var is set. + On hosted SaaS deployments the tenant's edge WAF requires a same- + origin header — without it ``/workspaces/*`` and ``/registry/*/peers`` + requests get silently rewritten to the canvas Next.js app, which has + no such routes and returns an empty 404. Inside-container calls are + unaffected (Docker-internal PLATFORM_URLs aren't behind the WAF). + Discovered while smoke-testing the molecule-mcp external-runtime + path against a live tenant — every tool call returned "not found" + because the WAF was eating them. + """ + headers: dict[str, str] = {} + platform_url = os.environ.get("PLATFORM_URL", "").strip() + if platform_url: + headers["Origin"] = platform_url tok = get_token() - if not tok: - return {} - return {"Authorization": f"Bearer {tok}"} + if tok: + headers["Authorization"] = f"Bearer {tok}" + return headers def self_source_headers(workspace_id: str) -> dict[str, str]: diff --git a/workspace/tests/test_platform_auth.py b/workspace/tests/test_platform_auth.py index bff0a20a..38480393 100644 --- a/workspace/tests/test_platform_auth.py +++ b/workspace/tests/test_platform_auth.py @@ -65,15 +65,36 @@ def test_save_token_rotation_overwrites(tmp_path): assert platform_auth.get_token() == "token-v2" -def test_auth_headers_when_no_token_is_empty(): +def test_auth_headers_when_no_token_and_no_platform_is_empty(monkeypatch): + monkeypatch.delenv("PLATFORM_URL", raising=False) assert platform_auth.auth_headers() == {} -def test_auth_headers_format(): +def test_auth_headers_when_no_token_includes_origin(monkeypatch): + """Origin must be set even without a token — the WAF gates ALL + requests to /workspaces and /registry, including pre-token bootstrap + register calls. Without Origin those would silently 404 from Next.js.""" + monkeypatch.setenv("PLATFORM_URL", "https://tenant.moleculesai.app") + assert platform_auth.auth_headers() == {"Origin": "https://tenant.moleculesai.app"} + + +def test_auth_headers_format(monkeypatch): + monkeypatch.delenv("PLATFORM_URL", raising=False) platform_auth.save_token("hello-world") assert platform_auth.auth_headers() == {"Authorization": "Bearer hello-world"} +def test_auth_headers_includes_origin_when_platform_url_set(monkeypatch): + """Both Authorization and Origin land on the same dict so the + SaaS edge WAF accepts every workspace-runtime request.""" + monkeypatch.setenv("PLATFORM_URL", "https://hongmingwang.moleculesai.app") + platform_auth.save_token("tok") + assert platform_auth.auth_headers() == { + "Authorization": "Bearer tok", + "Origin": "https://hongmingwang.moleculesai.app", + } + + def test_get_token_caches_after_first_disk_read(tmp_path, monkeypatch): path = tmp_path / ".auth_token" path.write_text("disk-token") @@ -179,5 +200,6 @@ def test_env_token_caches_like_file_token(tmp_path, monkeypatch): 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.delenv("PLATFORM_URL", raising=False) monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "external-bearer") assert platform_auth.auth_headers() == {"Authorization": "Bearer external-bearer"} From 716589742c855df5c406c2794ecff11603c0c37f Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 15:34:27 -0700 Subject: [PATCH 3/6] feat(canvas): add Universal MCP tab to external-agent connect modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Connect your external agent" dialog already covered Claude Code, Python SDK, curl, and raw fields. This adds a Universal MCP tab that documents the new \`molecule-mcp\` console script — the runtime- agnostic baseline shipped by PR #2413's workspace-runtime changes. Surface area: - New \`externalUniversalMcpTemplate\` constant in workspace-server. Three-step snippet: pip install runtime → one-shot register via curl → wire molecule-mcp into agent's MCP config (Claude Code example, notes that hermes/codex/etc. take the same env-var contract). - Workspace create response now includes \`universal_mcp_snippet\` alongside the existing curl/python/channel snippets. - Canvas modal renders the tab when \`universal_mcp_snippet\` is present; backward-compatible with older platform builds (tab hides when empty). Origin/WAF coverage (the user explicitly asked for this): - The runtime wheel handles Origin automatically (this PR's earlier commit on platform_auth.auth_headers). - The curl tab now sets \`Origin: {{PLATFORM_URL}}\` preemptively with an explanatory comment; \`/registry/register\` is currently WAF-allowed without it but adding now keeps the snippet working if WAF rules expand. The comment also explains why \`/workspaces/*\` paths return empty 404 without Origin — the exact failure mode I hit while smoke-testing this PR live. - The MCP snippet's footer notes that the wheel auto-handles Origin so operators don't think about it. End-to-end verification (against live tenant hongmingwang.moleculesai.app, freshly registered workspace): - get_workspace_info → full JSON - list_peers → "Claude Code Agent (ID: 97ac32e9..., status: online)" - recall_memory → "No memories found." all returned by the molecule-mcp binary speaking MCP stdio to this Claude Code session. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/ExternalConnectModal.tsx | 46 +++++++++++++--- .../internal/handlers/external_connection.go | 54 +++++++++++++++++++ .../internal/handlers/workspace.go | 11 ++++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/canvas/src/components/ExternalConnectModal.tsx b/canvas/src/components/ExternalConnectModal.tsx index a10fb54c..29e4e7de 100644 --- a/canvas/src/components/ExternalConnectModal.tsx +++ b/canvas/src/components/ExternalConnectModal.tsx @@ -32,6 +32,14 @@ export interface ExternalConnectionInfo { // haven't shipped molecule-core PR #2304 yet (older response payload // omits the field; tab is hidden if empty). claude_code_channel_snippet?: string; + // Universal MCP snippet — runtime-agnostic outbound tool path via + // the `molecule-mcp` console script in the + // molecule-ai-workspace-runtime PyPI wheel. Works with any MCP-aware + // agent runtime (Claude Code, hermes, codex, third-party). Outbound- + // only: pair with claude_code_channel or python tabs for heartbeat + // + inbound. Optional for backward compat with platforms that + // haven't shipped PR #2413 yet. + universal_mcp_snippet?: string; } interface Props { @@ -39,7 +47,7 @@ interface Props { onClose: () => void; } -type Tab = "python" | "curl" | "claude" | "fields"; +type Tab = "python" | "curl" | "claude" | "mcp" | "fields"; export function ExternalConnectModal({ info, onClose }: Props) { // Default to Claude Code when the platform offers it — that's the @@ -89,6 +97,13 @@ export function ExternalConnectModal({ info, onClose }: Props) { 'MOLECULE_WORKSPACE_TOKENS=', `MOLECULE_WORKSPACE_TOKENS=${info.auth_token}`, ); + // Universal MCP snippet uses the same "" + // placeholder pattern as the curl tab — the auth token is exported + // as WORKSPACE_AUTH_TOKEN and reused inline for `molecule-mcp`. + const filledUniversalMcp = info.universal_mcp_snippet?.replace( + 'WORKSPACE_AUTH_TOKEN=""', + `WORKSPACE_AUTH_TOKEN="${info.auth_token}"`, + ); return ( !o && onClose()}> @@ -110,11 +125,19 @@ export function ExternalConnectModal({ info, onClose }: Props) { aria-label="Connection snippet format" className="mt-4 flex gap-1 border-b border-zinc-800" > - {( - filledChannel - ? (["claude", "python", "curl", "fields"] as Tab[]) - : (["python", "curl", "fields"] as Tab[]) - ).map((t) => ( + {(() => { + // Build the tab order dynamically. Claude Code first + // (when offered) since it's the simplest setup; Python + // SDK second (full register+heartbeat+inbound); Universal + // MCP third (any MCP-aware runtime, outbound-only); curl + // for one-shot register; Fields for raw values. + const tabs: Tab[] = []; + if (filledChannel) tabs.push("claude"); + tabs.push("python"); + if (filledUniversalMcp) tabs.push("mcp"); + tabs.push("curl", "fields"); + return tabs; + })().map((t) => (