From c4bb8033299680c3acf5d6e38d790ec2d2d4ed38 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 18:57:39 -0700 Subject: [PATCH] feat(mcp_cli): agent_card from env vars (capability discovery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External molecule-mcp runtimes register with hardcoded agent_card.name = molecule-mcp-{id[:8]} and skills=[]. That made every external workspace look identical on the canvas and gave peer agents calling list_peers no signal beyond name — they had to guess capabilities. Three new env vars let the operator declare identity + capabilities without code changes: * MOLECULE_AGENT_NAME — display name on canvas (default unchanged) * MOLECULE_AGENT_DESCRIPTION — one-line description (default empty) * MOLECULE_AGENT_SKILLS — comma-separated skill names Comma-separated skills get expanded to {"name": "..."} objects — the minimum shape that satisfies both shared_runtime.summarize_peers (reads s["name"]) AND canvas SkillsTab.tsx (id falls back to name). Strict-superset behaviour: when no env vars are set, agent_card matches the previous hardcoded value exactly. No regression for operators who haven't migrated. Why this matters end-to-end: * Canvas Skills tab now shows each declared skill as a chip * Peer agents calling list_peers see {name, skills} per peer and can route delegations to the right specialist * Same applies to the canvas Details tab + workspace card hover Tests cover: defaults match prior behaviour; name override; CSV → skill objects; whitespace stripping + empty entries dropped; description omitted when unset (keeps wire payload minimal); whitespace-only name falls back to default; end-to-end through _platform_register's payload. Co-Authored-By: Claude Opus 4.7 (1M context) --- workspace/mcp_cli.py | 46 +++++++++++- workspace/tests/test_mcp_cli.py | 125 ++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/workspace/mcp_cli.py b/workspace/mcp_cli.py index 38ae3e7b..ddc21c95 100644 --- a/workspace/mcp_cli.py +++ b/workspace/mcp_cli.py @@ -62,6 +62,50 @@ _HEARTBEAT_AUTH_LOUD_THRESHOLD = 3 _HEARTBEAT_AUTH_RELOG_INTERVAL = 20 +def _build_agent_card(workspace_id: str) -> dict: + """Build the ``agent_card`` payload sent to /registry/register. + + Three optional env vars override the defaults so an operator can + surface human-readable identity + capabilities to peers and the + canvas Skills tab without code changes: + + * ``MOLECULE_AGENT_NAME`` — display name (defaults to + ``molecule-mcp-{id[:8]}``). Surfaced in canvas workspace cards + and ``list_peers`` output. + * ``MOLECULE_AGENT_DESCRIPTION`` — one-liner about the agent's + purpose. Rendered in canvas Details + Skills tabs. + * ``MOLECULE_AGENT_SKILLS`` — comma-separated skill names + (e.g. ``research,code-review,memory-curation``). Each name is + expanded to a ``{"name": ...}`` skill object — the minimum + shape that satisfies both ``shared_runtime.summarize_peers`` + (uses ``s["name"]``) and the canvas SkillsTab.tsx schema + (id falls back to name when omitted). Empty / whitespace + entries are dropped. + + Defaults match the previous hardcoded behaviour exactly so this + is a strict superset — an operator who sets none of the env vars + sees no change. + """ + name = (os.environ.get("MOLECULE_AGENT_NAME") or "").strip() + if not name: + name = f"molecule-mcp-{workspace_id[:8]}" + + description = (os.environ.get("MOLECULE_AGENT_DESCRIPTION") or "").strip() + + skills_raw = (os.environ.get("MOLECULE_AGENT_SKILLS") or "").strip() + skills: list[dict] = [] + if skills_raw: + for s in skills_raw.split(","): + label = s.strip() + if label: + skills.append({"name": label}) + + card: dict = {"name": name, "skills": skills} + if description: + card["description"] = description + return card + + def _platform_register(platform_url: str, workspace_id: str, token: str) -> None: """One-shot register at startup; fails fast on auth errors. @@ -96,7 +140,7 @@ def _platform_register(platform_url: str, workspace_id: str, token: str) -> None payload = { "id": workspace_id, "url": "", - "agent_card": {"name": f"molecule-mcp-{workspace_id[:8]}", "skills": []}, + "agent_card": _build_agent_card(workspace_id), "delivery_mode": "poll", } headers = { diff --git a/workspace/tests/test_mcp_cli.py b/workspace/tests/test_mcp_cli.py index f306a7cf..608d1e7c 100644 --- a/workspace/tests/test_mcp_cli.py +++ b/workspace/tests/test_mcp_cli.py @@ -444,6 +444,131 @@ def test_register_payload_shape(monkeypatch): assert headers["Origin"] == "https://test.moleculesai.app" +# ============== Agent card env vars (capability discovery) ============== +# External runtimes register with hardcoded agent_card.name and skills=[]. +# Both the canvas SkillsTab and the list_peers tool surface skills to +# users + peer agents for routing — empty skills means peers route blind. +# MOLECULE_AGENT_NAME / DESCRIPTION / SKILLS env vars let the operator +# declare identity + capabilities without code changes. Defaults are +# strict-superset: unset env vars = previous hardcoded behaviour. + + +def test_build_agent_card_defaults_match_previous_behavior(monkeypatch): + """Strict-superset: when no env vars are set, the agent_card shape + matches the previous hardcoded value exactly. No silent regression + for operators who haven't set the new vars.""" + for var in ("MOLECULE_AGENT_NAME", "MOLECULE_AGENT_DESCRIPTION", "MOLECULE_AGENT_SKILLS"): + monkeypatch.delenv(var, raising=False) + + card = mcp_cli._build_agent_card("8dad3e29-c32a-4ec7-9ea7-94fe2d2d98ec") + + assert card == {"name": "molecule-mcp-8dad3e29", "skills": []} + + +def test_build_agent_card_name_from_env(monkeypatch): + """MOLECULE_AGENT_NAME overrides the auto-generated default so + operators can give the canvas card a human-readable label.""" + monkeypatch.setenv("MOLECULE_AGENT_NAME", "Research Assistant") + monkeypatch.delenv("MOLECULE_AGENT_DESCRIPTION", raising=False) + monkeypatch.delenv("MOLECULE_AGENT_SKILLS", raising=False) + + card = mcp_cli._build_agent_card("8dad3e29-c32a-4ec7-9ea7-94fe2d2d98ec") + + assert card["name"] == "Research Assistant" + + +def test_build_agent_card_skills_csv_to_objects(monkeypatch): + """MOLECULE_AGENT_SKILLS is comma-separated names; each gets + expanded to {'name': ...} — the minimum shape that satisfies both + shared_runtime.summarize_peers (s['name']) AND canvas SkillsTab + (id falls back to name).""" + monkeypatch.delenv("MOLECULE_AGENT_NAME", raising=False) + monkeypatch.setenv("MOLECULE_AGENT_SKILLS", "research,code-review,memory-curation") + + card = mcp_cli._build_agent_card("ws-1") + + assert card["skills"] == [ + {"name": "research"}, + {"name": "code-review"}, + {"name": "memory-curation"}, + ] + + +def test_build_agent_card_skills_strips_whitespace_and_empty(monkeypatch): + """Real-world env vars often have stray whitespace from copy-paste + or shell quoting. Strip each entry; drop empty ones.""" + monkeypatch.setenv( + "MOLECULE_AGENT_SKILLS", " research , , code-review ,, " + ) + + card = mcp_cli._build_agent_card("ws-1") + + assert card["skills"] == [{"name": "research"}, {"name": "code-review"}] + + +def test_build_agent_card_description_only_set_when_present(monkeypatch): + """description is omitted from the card when env var is unset — + keeps the wire payload minimal and matches the platform's + 'absent field = use default' contract.""" + monkeypatch.delenv("MOLECULE_AGENT_DESCRIPTION", raising=False) + + card = mcp_cli._build_agent_card("ws-1") + + assert "description" not in card + + monkeypatch.setenv("MOLECULE_AGENT_DESCRIPTION", "Researches things") + card2 = mcp_cli._build_agent_card("ws-1") + assert card2["description"] == "Researches things" + + +def test_build_agent_card_whitespace_only_name_falls_back_to_default(monkeypatch): + """An accidentally-empty MOLECULE_AGENT_NAME (e.g. operator set + the var but forgot to fill the value) falls back to the auto- + generated default, matching the WORKSPACE_ID whitespace handling + in main().""" + monkeypatch.setenv("MOLECULE_AGENT_NAME", " ") + + card = mcp_cli._build_agent_card("8dad3e29-c32a-4ec7-9ea7-94fe2d2d98ec") + + assert card["name"] == "molecule-mcp-8dad3e29" + + +def test_register_payload_uses_built_agent_card(monkeypatch): + """End-to-end: env vars flow through _platform_register's payload + so the platform sees the operator's declared identity, not the + hardcoded default.""" + monkeypatch.setenv("MOLECULE_AGENT_NAME", "Research Bot") + monkeypatch.setenv("MOLECULE_AGENT_SKILLS", "research,analysis") + + captured: dict[str, object] = {} + + class FakeResp: + status_code = 200 + text = "" + + class FakeClient: + def __init__(self, **_kwargs): pass + def __enter__(self): return self + def __exit__(self, *_a): return False + def post(self, url, json=None, headers=None): + captured["json"] = json + return FakeResp() + + import types + fake_httpx = types.ModuleType("httpx") + fake_httpx.Client = FakeClient + monkeypatch.setitem(sys.modules, "httpx", fake_httpx) + + mcp_cli._platform_register("https://test.moleculesai.app", "ws-1", "tok") + + body = captured["json"] + assert body["agent_card"]["name"] == "Research Bot" + assert body["agent_card"]["skills"] == [ + {"name": "research"}, + {"name": "analysis"}, + ] + + def test_heartbeat_loop_posts_to_correct_endpoint(monkeypatch): """Heartbeat thread must POST to /registry/heartbeat with the workspace_id + Origin/Authorization headers."""