Merge pull request #2186 from Molecule-AI/fix/mcp-server-path-wheel-relative

fix(runtime): legacy /app/ path leaks across MCP server + agent prompts + docstrings
This commit is contained in:
Hongming Wang 2026-04-27 19:32:33 +00:00 committed by GitHub
commit db3b472bc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 60 additions and 20 deletions

View File

@ -228,13 +228,13 @@ CLI runtimes keep the same memory tool surface as the Python runtime. When `AWAR
Any process inside a workspace container (cron jobs, scripts, background tasks) can update the canvas card display:
```bash
molecule-monorepo-status "Running weekly SEO audit..." # show on canvas
molecule-monorepo-status "" # clear when done
python3 -m molecule_runtime.molecule_ai_status "Running weekly SEO audit..." # show on canvas
python3 -m molecule_runtime.molecule_ai_status "" # clear when done
```
From Python:
```python
from molecule_ai_status import set_status
from molecule_runtime.molecule_ai_status import set_status
set_status("Analyzing competitor data...")
```

View File

@ -48,7 +48,16 @@ logger = logging.getLogger(__name__)
WORKSPACE_MOUNT = "/workspace"
CONFIG_MOUNT = "/configs"
DEFAULT_MCP_SERVER_PATH = "/app/a2a_mcp_server.py"
# Resolved relative to this module so it tracks the wheel install
# location. The hardcoded "/app/a2a_mcp_server.py" was correct under
# the pre-#87 monolithic-template layout, but post-universal-runtime
# the file ships inside the molecule-ai-workspace-runtime wheel at
# site-packages/molecule_runtime/, while /app/ now holds only
# template-specific modules (adapter.py + the runtime-native executor).
# Stale path → Claude Code SDK silently fails to spawn the MCP
# subprocess → list_peers / delegate_task / a2a_send_message all
# disappear from the agent's toolset.
DEFAULT_MCP_SERVER_PATH = str(Path(__file__).parent / "a2a_mcp_server.py")
DEFAULT_DELEGATION_RESULTS_FILE = "/tmp/delegation_results.jsonl"
PLATFORM_HTTP_TIMEOUT_S = 5.0
MEMORY_RECALL_LIMIT = 10
@ -290,11 +299,11 @@ Instead: (1) try delegating to a different peer, (2) handle the task yourself, o
_A2A_INSTRUCTIONS_CLI = """## Inter-Agent Communication
You can delegate tasks to other workspaces using the a2a command:
python3 /app/a2a_cli.py peers # List available peers
python3 /app/a2a_cli.py delegate <workspace_id> <task> # Sync: wait for response
python3 /app/a2a_cli.py delegate --async <workspace_id> <task> # Async: return task_id
python3 /app/a2a_cli.py status <workspace_id> <task_id> # Check async task
python3 /app/a2a_cli.py info # Your workspace info
python3 -m molecule_runtime.a2a_cli peers # List available peers
python3 -m molecule_runtime.a2a_cli delegate <workspace_id> <task> # Sync: wait for response
python3 -m molecule_runtime.a2a_cli delegate --async <workspace_id> <task> # Async: return task_id
python3 -m molecule_runtime.a2a_cli status <workspace_id> <task_id> # Check async task
python3 -m molecule_runtime.a2a_cli info # Your workspace info
For quick questions, use sync delegate. For long tasks, use --async + status.
Only delegate to peers listed by the peers command (access control enforced)."""

View File

@ -4,14 +4,10 @@
Usage (from any script, cron job, or shell inside the container):
# Set current task (shows on canvas card)
python3 /app/molecule_ai_status.py "Running weekly SEO audit..."
python3 -m molecule_runtime.molecule_ai_status "Running weekly SEO audit..."
# Clear task (removes banner from canvas)
python3 /app/molecule_ai_status.py ""
# Or use the shell alias:
molecule-monorepo-status "Analyzing competitor data..."
molecule-monorepo-status ""
python3 -m molecule_runtime.molecule_ai_status ""
The status appears as an amber banner on the workspace card in the canvas,
visible to the project owner in real-time.
@ -63,13 +59,13 @@ def set_status(task: str):
timeout=5.0,
)
except Exception as e:
print(f"molecule-monorepo-status: failed to update: {e}", file=sys.stderr)
print(f"molecule_ai_status: failed to update: {e}", file=sys.stderr)
if __name__ == "__main__": # pragma: no cover
if len(sys.argv) < 2:
print("Usage: molecule-monorepo-status 'task description'")
print(" molecule-monorepo-status '' # clear")
print("Usage: python3 -m molecule_runtime.molecule_ai_status 'task description'")
print(" python3 -m molecule_runtime.molecule_ai_status '' # clear")
sys.exit(1)
set_status(sys.argv[1])

View File

@ -23,6 +23,7 @@ Covers 100% of the public surface:
from __future__ import annotations
import json
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@ -88,6 +89,18 @@ def test_get_mcp_server_path_default(monkeypatch):
assert get_mcp_server_path() == DEFAULT_MCP_SERVER_PATH
def test_get_mcp_server_path_default_resolves_to_existing_file():
# Locks in the wheel-relative resolution: if a future refactor moves
# a2a_mcp_server.py out of the package directory or breaks the
# __file__-based lookup, Claude Code SDK silently fails to spawn the
# MCP subprocess and inter-agent tools (list_peers, delegate_task)
# vanish at runtime. This assertion catches that at unit-test time.
assert os.path.exists(DEFAULT_MCP_SERVER_PATH), (
f"DEFAULT_MCP_SERVER_PATH points at a missing file: "
f"{DEFAULT_MCP_SERVER_PATH}"
)
def test_get_mcp_server_path_env_override(monkeypatch):
monkeypatch.setenv("A2A_MCP_SERVER_PATH", "/custom/mcp.py")
assert get_mcp_server_path() == "/custom/mcp.py"
@ -432,10 +445,28 @@ def test_get_a2a_instructions_mcp_default():
def test_get_a2a_instructions_cli_variant():
out = get_a2a_instructions(mcp=False)
assert "a2a_cli.py" in out
assert "a2a_cli" in out
assert "MCP tools" not in out
def test_a2a_cli_instructions_use_module_invocation_not_legacy_app_path():
# The CLI variant of the a2a instructions ships in the agent system
# prompt for non-MCP runtimes (Ollama, custom). The model copies the
# invocation form verbatim into shell calls, so any path drift here
# silently breaks delegation. The legacy /app/a2a_cli.py path was
# correct under the pre-#87 monolithic-template Docker layout but
# stops resolving once the runtime ships as a wheel — pin the
# canonical `python3 -m molecule_runtime.a2a_cli` form so future
# refactors can't silently regress it.
out = get_a2a_instructions(mcp=False)
assert "/app/a2a_cli.py" not in out, (
"Legacy /app/a2a_cli.py path leaked back into the CLI-variant "
"system prompt — agents on Ollama/custom runtimes would copy "
"this verbatim and every delegation would fail."
)
assert "python3 -m molecule_runtime.a2a_cli" in out
def test_a2a_mcp_instructions_reference_existing_tools():
"""The MCP instructions text must only reference tools that are actually
registered in a2a_mcp_server.py. If someone renames a server tool, the

View File

@ -113,7 +113,11 @@ class TestSetStatus:
mod.set_status("something")
captured = capsys.readouterr()
assert "molecule-monorepo-status: failed to update" in captured.err
# Error prefix matches the canonical module-form invocation; the
# legacy molecule-monorepo-status shell alias only existed in the
# dev-only workspace/Dockerfile base image, never in shipped
# template images, so the prefix was misleading.
assert "molecule_ai_status: failed to update" in captured.err
assert "platform unreachable" in captured.err
def test_set_status_heartbeat_fields_are_correct(self, monkeypatch):