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>
|
||
|---|---|---|
| .. | ||
| __init__.py | ||
| README.md | ||
| registry.py | ||
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:
- MCP server (
workspace/a2a_mcp_server.py) — theTOOLSJSON list - LangChain
@toolwrappers (workspace/builtin_tools/{delegation,memory}.py) - 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
TOOLSlist from_PLATFORM_TOOL_SPECSat import time - LangChain
@toolwrappers readname=spec.namefrom the registry - Doc generator (
executor_helpers._render_section()) produces the system-prompt block fromspec.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
- Append a
ToolSpec(...)toTOOLSinregistry.py. - Add the LangChain
@toolwrapper inworkspace/builtin_tools/(the wrapper body just callsspec.impl). - Update
_CLI_A2A_COMMAND_KEYWORDSinexecutor_helpers.py— set the value to the CLI subcommand keyword, or toNoneif the tool isn't exposed via the subprocess interface. - Regenerate snapshots — see the comment block at the top of
workspace/tests/test_platform_tools.pyfor the one-liner. - Run
pytest workspace/tests/test_platform_tools.py --no-cov.
Renaming a tool
Edit name in registry.py only. Then:
- The MCP TOOLS list rebuilds automatically.
- The doc generator regenerates automatically (snapshots will fail the diff — regenerate them).
- Search
workspace/for the old literal in case a non-adapter consumer (tests, plugin code) hardcoded the old name; update those. - Update any
_CLI_A2A_COMMAND_KEYWORDSkey + the literal substring in_A2A_INSTRUCTIONS_CLIif 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.