feat(workspace-runtime): expose universal MCP server to runtime=external operators

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-30 15:20:19 -07:00
parent 36e263a07d
commit 169e284d57
7 changed files with 377 additions and 13 deletions

View File

@ -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=<uuid> \\
PLATFORM_URL=https://<tenant>.staging.moleculesai.app \\
MOLECULE_WORKSPACE_TOKEN=<bearer> \\
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.
"""

View File

@ -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"

View File

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

74
workspace/mcp_cli.py Normal file
View File

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

View File

@ -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:

View File

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

View File

@ -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"}