molecule-core/workspace/platform_tools
Hongming Wang 1161b97faf feat(mcp): cross-workspace delegation routing (multi-ws PR-2)
PR-2 of the multi-workspace external-agent stack. PR-1 (#2739)
landed per-workspace auth + heartbeat + inbox. This PR threads
``source_workspace_id`` through the A2A client + tool surface so an
agent registered against multiple workspaces can list peers across
all of them and delegate from a specific source.

Changes
-------

* ``a2a_client``: ``discover_peer``, ``send_a2a_message``,
  ``get_peers_with_diagnostic``, and ``enrich_peer_metadata`` now
  accept ``source_workspace_id``. Routing uses it for both the
  X-Workspace-ID header and (transitively, via ``auth_headers(src)``)
  the bearer token. Defaults to module-level WORKSPACE_ID for
  back-compat.
* ``a2a_client._peer_to_source``: a new lock-free cache mapping each
  discovered peer back to the source workspace whose registry
  surfaced it. ``tool_list_peers`` populates the cache on every call;
  ``tool_delegate_task`` consults it for auto-routing.
* ``a2a_tools.tool_list_peers(source_workspace_id=None)``: when
  multiple workspaces are registered (MOLECULE_WORKSPACES) and no
  explicit source is passed, aggregates peers across every
  registered workspace and tags each entry with ``via: <src[:8]>``.
  Single-workspace mode is unchanged — no ``via:`` annotation, same
  output shape.
* ``a2a_tools.tool_delegate_task`` and ``tool_delegate_task_async``
  resolve source via ``source_workspace_id arg → _peer_to_source[target]
  → WORKSPACE_ID``. Agents almost never need to specify ``source_*``
  explicitly — call ``list_peers`` first and the cache handles the
  rest.
* ``tool_delegate_task_async`` idempotency key now includes the
  source workspace, so the same task delegated from two registered
  workspaces produces two distinct delegations (the right behavior
  — one per tenant audit trail).
* ``platform_auth.list_registered_workspaces()``: new helper for the
  tool layer to enumerate the multi-ws registry. Lock-free reads
  matched by the existing single-writer-per-workspace contract from
  PR-1.
* ``platform_auth.self_source_headers``: now passes ``workspace_id``
  through to ``auth_headers`` — without this, a multi-workspace POST
  source-tagged with ``X-Workspace-ID=ws_b`` was authenticating
  with ws_a's token (or no token if MOLECULE_WORKSPACE_TOKEN unset).
  Latent PR-1 bug exposed by the new tool surface.
* ``a2a_mcp_server`` tool dispatch passes ``source_workspace_id``
  from the tool call arguments.
* ``platform_tools.registry``: add ``source_workspace_id`` to the
  delegate_task, delegate_task_async, check_task_status, list_peers
  input schemas with copy explaining when to use it (rarely — the
  cache handles it).

Tests (15 new, all passing)
---------------------------

``test_a2a_multi_workspace.py``:
* TestDiscoverPeerSourceRouting (3): src arg drives header+token,
  fallback to module ws when omitted, invalid target short-circuits
  before any HTTP attempt.
* TestSendA2AMessageSourceRouting (1): X-Workspace-ID source header
  + Authorization bearer both come from the source arg via the
  patched self_source_headers chain.
* TestGetPeersSourceRouting (1): URL path AND headers use the
  source workspace id.
* TestToolListPeersAggregation (4): aggregates across multiple
  registered workspaces, tags origin, leaves single-workspace path
  unchanged, explicit src arg overrides aggregation, diagnostic
  joining when every workspace returns empty.
* TestToolDelegateTaskAutoRouting (3): cache-driven auto-route,
  explicit override beats cache, single-workspace fallback to
  module WORKSPACE_ID.
* TestListRegisteredWorkspaces (3): registry enumeration helper.

Plus ``tests/snapshots/a2a_instructions_mcp.txt`` regenerated to
absorb the new ``source_workspace_id`` schema entries.

Back-compat
-----------

Every change defaults ``source_workspace_id=None``; legacy
single-workspace operators (no MOLECULE_WORKSPACES) see identical
behavior — same URLs, same headers, same tool output. The 24
PR-1 tests + 125 existing A2A tests all still pass.

Out of scope (PR-3)
-------------------

Memory namespacing per registered workspace lands after the new
memory system v2 PR (#2740) settles in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:32:24 -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 feat(mcp): cross-workspace delegation routing (multi-ws PR-2) 2026-05-04 08:32:24 -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.