molecule-core/workspace/platform_tools
Hongming Wang 829ab66462 mcp: support multi-workspace external-agent registration (PR-1)
External MCP agents (e.g. Claude Code installed on a company PC) can
now register against MULTIPLE workspaces from a single process — the
agent participates as a peer in workspace A (company) AND workspace B
(personal) simultaneously, with one merged inbox tagged so replies
route to the correct tenant.

Use case (verbatim from operator): "I have this computer AI thats in
company's PC, he is going to be put in company's workspace, but
personally, I want to register it to my own workspace as well, so
that I can talk to it and asking him to do work."

## What changed

**Wire format** — new env var:

  MOLECULE_WORKSPACES='[
    {"id":"<company-wsid>","token":"<company-tok>"},
    {"id":"<personal-wsid>","token":"<personal-tok>"}
  ]'

When set, mcp_cli iterates the array and spawns one (register +
heartbeat + inbox poller) trio per workspace. Single-workspace mode
(WORKSPACE_ID + MOLECULE_WORKSPACE_TOKEN) is unchanged — every
existing operator's setup keeps working bit-for-bit.

**Per-workspace token registry** (platform_auth.py):
  register_workspace_token(wsid, tok) — populated by mcp_cli once
  per workspace before any thread spawns; thread-safe registration
  + lock-free reads on the hot path. auth_headers(workspace_id=...)
  routes to the per-workspace token; auth_headers() with no arg
  uses the legacy resolution path unchanged (back-compat).

**Per-workspace inbox cursors** (inbox.py):
  InboxState now supports cursor_paths={wsid: Path,...}. Each poller
  advances its own cursor — one workspace's slow poll can't stall
  another, and a 410 only resets the affected workspace's cursor.
  Single-workspace constructor (cursor_path=Path(...)) still works
  exactly as before via __post_init__ promotion to the empty-string
  key. Cursor filenames disambiguated by workspace_id[:8] when
  multi-workspace; single-workspace keeps the legacy filename so
  upgrade doesn't invalidate on-disk state.

**Arrival workspace tagging** (inbox.py):
  InboxMessage.arrival_workspace_id — tells the agent which OF ITS
  workspaces the inbound message arrived on. Set by the poller from
  the cursor key. to_dict() omits the field when empty so single-
  workspace consumers see no shape change.

**Reply routing** (a2a_tools.py + a2a_mcp_server.py + registry.py):
  send_message_to_user(workspace_id=...) — optional override that
  selects which workspace's /notify endpoint to POST to (and which
  token authenticates). Multi-workspace agents pass the inbound
  message's arrival_workspace_id; single-workspace agents omit it
  and route to the only registered workspace via the legacy URL.

## Out of scope (future PRs)

- PR-2: cross-workspace delegation auto-routing — when an agent
  receives a request from personal-ws "delegate to ops-bot" and
  ops-bot lives in company-ws, the agent should auto-pick its
  company-ws identity for the outbound delegate_task. Today the
  agent must pass via_workspace explicitly (or fall through to
  primary workspace).
- PR-3: memory namespacing — commit_memory() still writes to the
  primary workspace's memory regardless of inbound context. Will
  revisit when the new memory system (PR #2733 just landed) settles.

## Tests

  workspace/tests/test_mcp_cli_multi_workspace.py — 24 new tests:
    * MOLECULE_WORKSPACES JSON parsing (valid + 6 error shapes)
    * Token registry register / lookup / rotation / clear
    * auth_headers routing by workspace_id with legacy fallback
    * Per-workspace cursor save/load/reset isolation
    * arrival_workspace_id present-when-set, omitted-when-empty
    * default_cursor_path namespacing

  All 110 pre-existing tests in test_mcp_cli.py / test_inbox.py /
  test_platform_auth.py still pass — back-compat is mechanical.

Refs: project memory entry "External agent multi-workspace
registration", design questions answered 2026-05-04 by user
(JSON env var; explicit memory writes deferred to PR-3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:06:00 -07:00
..
__init__.py feat(platform): single-source-of-truth tool registry — adapters consume, no drift 2026-04-28 17:11:36 -07:00
README.md docs: registry pattern + harness scripts READMEs 2026-04-28 22:19:40 -07:00
registry.py mcp: support multi-workspace external-agent registration (PR-1) 2026-05-04 08:06:00 -07:00

Platform tool registry

Single source of truth for every tool the platform exposes to agents (A2A delegation, hierarchical memory, broadcast, introspection).

Why this exists

Pre-#2240, three places independently declared each tool:

  1. MCP server (workspace/a2a_mcp_server.py) — the TOOLS JSON list
  2. LangChain @tool wrappers (workspace/builtin_tools/{delegation,memory}.py)
  3. Agent-facing system-prompt docs (workspace/executor_helpers.py)

Adding a tool to one and forgetting the others happened repeatedly. The canonical case: send_message_to_user was registered in MCP TOOLS but the executor_helpers doc string never mentioned it, so agents saw the tool as available but had no usage guidance — a silent capability regression.

What the registry does

registry.py defines each tool ONCE as a frozen ToolSpec:

ToolSpec(
    name="delegate_task",
    short="Delegate a task to a peer workspace via A2A and WAIT for the response.",
    when_to_use="Use for QUICK questions and small sub-tasks where you can afford to wait inline...",
    input_schema={...},          # JSON Schema, consumed by MCP server
    impl=tool_delegate_task,     # the actual coroutine
    section="a2a",               # which prompt section it belongs to
)

Adapters consume specs; no hardcoded names anywhere else:

  • MCP server builds its TOOLS list from _PLATFORM_TOOL_SPECS at import time
  • LangChain @tool wrappers read name=spec.name from the registry
  • Doc generator (executor_helpers._render_section()) produces the system-prompt block from spec.short (bullet) + spec.when_to_use (heading + paragraph)

CLI subprocess block — special case

Non-MCP runtimes (ollama, custom subprocess adapters) use a separate hand-maintained block in executor_helpers._A2A_INSTRUCTIONS_CLI because the CLI subcommand vocabulary (peers, delegate, status, info) differs from the MCP tool names (list_peers, delegate_task, etc.). Auto-generation would lose the readable invocation syntax.

Alignment is enforced via _CLI_A2A_COMMAND_KEYWORDS (in executor_helpers.py): every a2a-section spec must be keyed there with either a CLI subcommand keyword OR an explicit None if the tool is intentionally not exposed via subprocess (e.g. send_message_to_user because its structured attachments field doesn't survive positional-arg shell invocation).

Tests that catch drift

workspace/tests/test_platform_tools.py:

Test What it catches
test_mcp_server_registers_every_registry_tool MCP TOOLS list out of sync with registry
test_mcp_tool_descriptions_match_registry_short hand-edited MCP description that drifted
test_mcp_tool_input_schemas_match_registry schema duplicated in server file
test_a2a_instructions_text_includes_every_a2a_tool doc generator missed a tool
test_old_pre_rename_names_not_present_in_docs stale name leaked back in
test_a2a_mcp_instructions_match_snapshot rendered shape (bullet ordering, headings, footers) drifted
test_a2a_cli_instructions_match_snapshot CLI block edited in a way that changes shape
test_hma_instructions_match_snapshot HMA section drifted
test_cli_keyword_mapping_covers_every_a2a_tool tool added to registry without a CLI mapping decision
test_cli_keyword_substrings_appear_in_cli_block CLI keyword in the mapping but missing from the doc block

The snapshot files at workspace/tests/snapshots/*.txt are LF-pinned in .gitattributes so a Windows contributor with core.autocrlf=true doesn't get mysterious test failures.

Adding a new tool

  1. Append a ToolSpec(...) to TOOLS in registry.py.
  2. Add the LangChain @tool wrapper in workspace/builtin_tools/ (the wrapper body just calls spec.impl).
  3. Update _CLI_A2A_COMMAND_KEYWORDS in executor_helpers.py — set the value to the CLI subcommand keyword, or to None if the tool isn't exposed via the subprocess interface.
  4. Regenerate snapshots — see the comment block at the top of workspace/tests/test_platform_tools.py for the one-liner.
  5. Run pytest workspace/tests/test_platform_tools.py --no-cov.

Renaming a tool

Edit name in registry.py only. Then:

  1. The MCP TOOLS list rebuilds automatically.
  2. The doc generator regenerates automatically (snapshots will fail the diff — regenerate them).
  3. Search workspace/ for the old literal in case a non-adapter consumer (tests, plugin code) hardcoded the old name; update those.
  4. Update any _CLI_A2A_COMMAND_KEYWORDS key + the literal substring in _A2A_INSTRUCTIONS_CLI if applicable.

Removing a tool

Delete the ToolSpec and the _CLI_A2A_COMMAND_KEYWORDS key. Adapters and doc generators stop registering it automatically; the structural tests prevent stale references from surviving.