Compare commits

..

1 Commits

Author SHA1 Message Date
Hongming Wang 0032cfc3ce docs(readme): regenerate tool table from source (87 tools, was 20)
The previous README listed only 20 tools; the server actually registers 87
across 12 modules. Categories newly documented: Channels, Plugins,
Schedules, Approval (extra create/get), Delegation & Activity, Discovery
& Org (peers, events, viewport, bundles, templates beyond list_templates),
plus the four Phase 30 Remote Agents tools that were already present in
src/tools/remote_agents.ts but not in the top-level table.

Verified: every name in 'srv.tool("…")' across src/tools/*.ts appears in
the README table, and vice versa (87 ⇔ 87).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:18:37 -07:00
12 changed files with 270 additions and 440 deletions
+46 -99
View File
@@ -145,142 +145,89 @@ src/
## MCP Tool Registry
Full list of tools exposed by this server (87 total). Each is implemented in `src/tools/<name>.ts`.
Full list of tools exposed by this server. Each is implemented in `src/tools/<name>.ts`.
### Workspace Tools (8)
### Workspace Tools
| Tool | Description |
|------|-------------|
| `list_workspaces` | List all workspaces with their status, skills, and hierarchy |
| `create_workspace` | Create a new workspace node on the canvas |
| `get_workspace` | Get detailed information about a specific workspace |
| `update_workspace` | Update workspace fields (name, role, tier, parent_id, position) |
| `list_workspaces` | List all workspaces accessible to the authenticated user |
| `create_workspace` | Create a new workspace with name, role, tier, and template |
| `get_workspace` | Get workspace details by ID |
| `update_workspace` | Patch workspace fields (name, tier, parent_id, etc.) |
| `delete_workspace` | Delete a workspace (cascades to children) |
| `restart_workspace` | Restart an offline or failed workspace |
| `pause_workspace` | Pause a workspace (stops container, preserves config) |
| `resume_workspace` | Resume a paused workspace |
| `restart_workspace` | Restart all agents in a workspace (picks up new secrets/prompts) |
### Agent Tools (6)
### Agent Tools
| Tool | Description |
|------|-------------|
| `chat_with_agent` | Send a message to a workspace agent and get a response |
| `assign_agent` | Assign an AI model to a workspace |
| `replace_agent` | Replace the model on an existing workspace agent |
| `remove_agent` | Remove the agent from a workspace |
| `move_agent` | Move an agent from one workspace to another |
| `get_model` | Get current model configuration for a workspace |
| `list_agents` | List agents in a workspace |
| `get_agent` | Get agent details by ID |
| `send_message` | Send an A2A message to an agent (returns structured response) |
| `list_peers` | List peer agents discoverable by a given agent |
### Delegation Tools (8)
### Delegation Tools
| Tool | Description |
|------|-------------|
| `async_delegate` | Delegate a task to another workspace (non-blocking, returns delegation_id) |
| `check_delegations` | Check status of delegated tasks for a workspace |
| `record_delegation` | Register an agent-initiated delegation with the activity log |
| `update_delegation_status` | Mirror delegation status to activity_logs (completed or failed) |
| `report_activity` | Write an arbitrary activity log row from an agent |
| `list_activity` | List activity logs for a workspace (A2A, tasks, errors) |
| `notify_user` | Push a notification from the agent to the canvas via WebSocket |
| `list_traces` | List recent LLM traces from Langfuse for a workspace |
| `delegate_task` | Delegate a task to a child workspace (sync, waits for response) |
| `delegate_task_async` | Delegate a task to a child workspace (fire-and-forget, returns task_id) |
### Secrets Tools (6)
### Secrets Tools
| Tool | Description |
|------|-------------|
| `set_secret` | Set an API key or environment variable for a workspace |
| `list_secrets` | List secret keys for a workspace (values never exposed) |
| `delete_secret` | Delete a secret from a workspace |
| `list_global_secrets` | List global secret keys (values never exposed) |
| `set_global_secret` | Set a global secret (available to all workspaces) |
| `delete_global_secret` | Delete a global secret |
| `get_secret` | Retrieve a secret value for a workspace |
| `set_secret` | Set a key/value secret for a workspace |
| `delete_secret` | Delete a secret |
### Files Tools (7)
### Files Tools
| Tool | Description |
|------|-------------|
| `list_files` | List workspace config files (skills, prompts, config.yaml) |
| `read_file` | Read a workspace config file |
| `write_file` | Write or create a workspace config file |
| `delete_file` | Delete a workspace file or folder |
| `replace_all_files` | Replace all workspace config files at once |
| `get_config` | Get workspace runtime config as JSON |
| `update_config` | Update workspace runtime config |
| `list_files` | List files in a workspace container |
| `get_file` | Read a file's content |
| `put_file` | Write or update a file in the container |
| `delete_file` | Delete a file |
### Memory Tools (9)
### Memory Tools
| Tool | Description |
|------|-------------|
| `commit_memory` | Store a fact in workspace memory (LOCAL, TEAM, or GLOBAL scope) |
| `search_memory` | Search workspace memories |
| `delete_memory` | Delete a specific memory entry |
| `session_search` | Search recent session activity and memory (FTS) |
| `get_shared_context` | Get the shared-context blob for a workspace |
| `memory_set` | Set a key-value memory entry with optional TTL |
| `memory_get` | Read a single K/V memory entry |
| `memory_list` | List all K/V memory entries for a workspace |
| `memory_delete_kv` | Delete a single K/V memory entry |
| `commit_memory` | Commit a structured memory entry (with optional namespace) |
| `recall_memory` | Search previously committed memories |
### Plugins Tools (7)
### Plugins Tools
| Tool | Description |
|------|-------------|
| `list_plugin_registry` | List all available plugins from the registry |
| `list_installed_plugins` | List plugins installed in a workspace |
| `install_plugin` | Install a plugin into a workspace (auto-restarts) |
| `uninstall_plugin` | Remove a plugin from a workspace (auto-restarts) |
| `list_plugin_sources` | List registered plugin install-source schemes |
| `list_available_plugins` | List plugins from registry filtered by workspace runtime |
| `check_plugin_compatibility` | Preflight: which installed plugins would break if runtime changed? |
| `install_plugin` | Download and install a plugin into a workspace from the registry |
### Channels Tools (8)
### Channels Tools
| Tool | Description |
|------|-------------|
| `list_channel_adapters` | List available social channel adapters (Telegram, Slack, etc.) |
| `list_channels` | List social channels connected to a workspace |
| `add_channel` | Connect a social channel to a workspace |
| `update_channel` | Update a channel's config, enabled state, or allowed users |
| `remove_channel` | Remove a social channel from a workspace |
| `send_channel_message` | Send an outbound message from a workspace to a channel |
| `test_channel` | Send a test message to verify a channel connection |
| `discover_channel_chats` | Auto-detect chat IDs for a given bot token |
| `list_channels` | List communication channels |
| `get_channel` | Get channel details |
| `post_message` | Post a message to a channel |
### Schedules Tools (6)
### Schedules Tools
| Tool | Description |
|------|-------------|
| `list_schedules` | List cron schedules for a workspace |
| `create_schedule` | Create a cron schedule that fires a prompt on a recurring timer |
| `update_schedule` | Update fields on an existing schedule |
| `delete_schedule` | Delete a schedule |
| `run_schedule` | Fire a schedule manually, bypassing its cron expression |
| `get_schedule_history` | Get past runs of a schedule — status, start/end, output |
| `list_schedules` | List scheduled tasks |
| `create_schedule` | Create a new scheduled task |
| `delete_schedule` | Delete a scheduled task |
### Discovery Tools (14)
### Discovery Tools
| Tool | Description |
|------|-------------|
| `list_peers` | List reachable peer workspaces (siblings, children, parent) |
| `discover_workspace` | Resolve a workspace URL by ID (for A2A communication) |
| `check_access` | Check if two workspaces can communicate |
| `list_events` | List structure events (global or per workspace) |
| `list_templates` | List available workspace templates |
| `list_org_templates` | List available org templates |
| `import_org` | Import an org template to create an entire workspace hierarchy |
| `import_template` | Import agent files as a new workspace template |
| `export_bundle` | Export a workspace as a portable .bundle.json |
| `import_bundle` | Import a workspace from a bundle JSON object |
| `get_canvas_viewport` | Get the current canvas viewport (x, y, zoom) |
| `set_canvas_viewport` | Persist the canvas viewport (x, y, zoom) |
| `expand_team` | Expand a workspace into a team of sub-workspaces |
| `collapse_team` | Collapse a team back to a single workspace |
| `check_access` | Verify A2A access between two workspace IDs |
### Remote Agents Tools (4)
### Remote Agents Tools
| Tool | Description |
|------|-------------|
| `list_remote_agents` | List all workspaces with runtime='external' (Phase 30 remote agents) |
| `get_remote_agent_state` | Lightweight state poll for a remote workspace |
| `get_remote_agent_setup_command` | Build a bash command to register an agent on a remote machine |
| `check_remote_agent_freshness` | Check if a remote agent's heartbeat is recent |
| `get_remote_agent_info` | Get runtime info for a remote agent |
| `heartbeat` | Send a heartbeat to the platform |
### Approvals Tools (4)
### Approvals Tools
| Tool | Description |
|------|-------------|
| `list_pending_approvals` | List all pending approval requests across workspaces |
| `decide_approval` | Approve or deny a pending approval request |
| `create_approval` | Create an approval request for a workspace |
| `get_workspace_approvals` | List approval requests for a specific workspace |
| `list_approvals` | List pending approvals for a workspace |
| `approve` | Approve a pending item |
| `reject` | Reject a pending item |
## MCP Transport Gotchas
+181 -41
View File
@@ -1,25 +1,175 @@
# Molecule AI MCP Server
MCP server that exposes Molecule AI platform operations as tools for AI coding agents.
MCP server that exposes Molecule AI platform operations as tools for AI coding agents (Claude Code, Cursor, Codex, OpenCode).
## 87 Tools Available
## Tools Available
See the [full tool registry](CLAUDE.md#mcp-tool-registry) for all tools. Highlights:
The server registers **87 tools** across 12 categories. The previous README listed 20 — this section is regenerated from `src/tools/*.ts` to match the actual surface.
| Category | Tools |
|----------|-------|
| Workspace | list, create, get, update, delete, restart, pause, resume |
| Agent | chat_with, assign, replace, remove, move, get_model |
| Delegation | async_delegate, check_delegations, record_delegation, notify_user, list_activity |
| Secrets | set, list, delete (workspace + global variants) |
| Files | list, read, write, delete, replace_all, get_config, update_config |
| Memory | commit, search, delete (HMA scopes) + memory_set/get/list/delete (K/V) |
| Plugins | list registry, list installed, install, uninstall, list sources, check compatibility |
| Channels | list adapters, list, add, update, remove, send, test, discover chats |
| Schedules | list, create, update, delete, run, get history |
| Discovery | list peers, discover, check_access, list events, import/export, canvas viewport |
| Approvals | list pending, decide, create, get workspace approvals |
| Remote Agents | list (runtime=external), get state, setup command, check freshness |
| Category | Source file | Count |
|----------|-------------|-------|
| Workspaces | `src/tools/workspaces.ts` | 8 |
| Agents | `src/tools/agents.ts` | 6 |
| Secrets | `src/tools/secrets.ts` | 6 |
| Files | `src/tools/files.ts` | 7 |
| Memory | `src/tools/memory.ts` | 9 |
| Plugins | `src/tools/plugins.ts` | 7 |
| Channels | `src/tools/channels.ts` | 8 |
| Delegation & Activity | `src/tools/delegation.ts` | 8 |
| Schedules | `src/tools/schedules.ts` | 6 |
| Approvals | `src/tools/approvals.ts` | 4 |
| Discovery & Org | `src/tools/discovery.ts` | 14 |
| Remote Agents | `src/tools/remote_agents.ts` | 4 |
| **Total** | | **87** |
### Workspaces
| Tool | Description | Key params |
|------|-------------|------------|
| `list_workspaces` | List all workspaces with their status, skills, and hierarchy | _(none)_ |
| `create_workspace` | Create a new workspace node on the canvas | `name`, `role?`, `template?`, `tier?`, `parent_id?`, `runtime?`, `workspace_dir?`, `workspace_access?` |
| `get_workspace` | Get detailed information about a specific workspace | `workspace_id` |
| `delete_workspace` | Delete a workspace (cascades to children) | `workspace_id` |
| `restart_workspace` | Restart an offline or failed workspace | `workspace_id` |
| `update_workspace` | Update workspace fields (name, role, tier, parent_id, position) | `workspace_id`, `name?`, `role?`, `tier?`, `parent_id?` |
| `pause_workspace` | Pause a workspace (stops container, preserves config) | `workspace_id` |
| `resume_workspace` | Resume a paused workspace | `workspace_id` |
### Agents
| Tool | Description | Key params |
|------|-------------|------------|
| `chat_with_agent` | Send a message to a workspace agent and get a response | `workspace_id`, `message` |
| `assign_agent` | Assign an AI model to a workspace | `workspace_id`, `model` |
| `replace_agent` | Replace the model on an existing workspace agent | `workspace_id`, `model` |
| `remove_agent` | Remove the agent from a workspace | `workspace_id` |
| `move_agent` | Move an agent from one workspace to another | `workspace_id`, `target_workspace_id` |
| `get_model` | Get current model configuration for a workspace | `workspace_id` |
### Secrets
| Tool | Description | Key params |
|------|-------------|------------|
| `set_secret` | Set an API key or environment variable for a workspace | `workspace_id`, `key`, `value` |
| `list_secrets` | List secret keys for a workspace (values never exposed) | `workspace_id` |
| `delete_secret` | Delete a secret from a workspace | `workspace_id`, `key` |
| `list_global_secrets` | List global secret keys (values never exposed) | _(none)_ |
| `set_global_secret` | Set a global secret (available to all workspaces) | `key`, `value` |
| `delete_global_secret` | Delete a global secret | `key` |
### Files
| Tool | Description | Key params |
|------|-------------|------------|
| `list_files` | List workspace config files (skills, prompts, config.yaml) | `workspace_id` |
| `read_file` | Read a workspace config file | `workspace_id`, `path` |
| `write_file` | Write or create a workspace config file | `workspace_id`, `path`, `content` |
| `delete_file` | Delete a workspace file or folder | `workspace_id`, `path` |
| `replace_all_files` | Replace all workspace config files at once | `workspace_id`, `files` (path → content map) |
| `get_config` | Get workspace runtime config as JSON | `workspace_id` |
| `update_config` | Update workspace runtime config | `workspace_id`, `config` |
### Memory
| Tool | Description | Key params |
|------|-------------|------------|
| `commit_memory` | Store a fact in workspace memory (LOCAL, TEAM, or GLOBAL scope) | `workspace_id`, `content`, `scope` |
| `search_memory` | Search workspace memories | `workspace_id`, `query?`, `scope?` |
| `delete_memory` | Delete a specific memory entry | `workspace_id`, `memory_id` |
| `session_search` | Search a workspace's recent session activity and memory (FTS) | `workspace_id`, `q?`, `limit?` |
| `get_shared_context` | Get the shared-context blob for a workspace (persistent cross-turn context) | `workspace_id` |
| `memory_set` | Set a key-value memory entry with optional TTL (distinct from `commit_memory` which uses HMA scopes) | `workspace_id`, `key`, `value`, `ttl_seconds?` |
| `memory_get` | Read a single K/V memory entry | `workspace_id`, `key` |
| `memory_list` | List all K/V memory entries for a workspace | `workspace_id` |
| `memory_delete_kv` | Delete a single K/V memory entry | `workspace_id`, `key` |
### Plugins
| Tool | Description | Key params |
|------|-------------|------------|
| `list_plugin_registry` | List all available plugins from the registry | _(none)_ |
| `list_installed_plugins` | List plugins installed in a workspace | `workspace_id` |
| `install_plugin` | Install a plugin into a workspace from any registered source (auto-restarts) | `workspace_id`, `source` (e.g. `local://name`, `github://owner/repo[#ref]`) |
| `uninstall_plugin` | Remove a plugin from a workspace (auto-restarts) | `workspace_id`, `name` |
| `list_plugin_sources` | List registered plugin install-source schemes (e.g. local, github) | _(none)_ |
| `list_available_plugins` | List plugins from the registry filtered to ones supported by this workspace's runtime | `workspace_id` |
| `check_plugin_compatibility` | Preflight check: which installed plugins would break if this workspace switched runtime to `<runtime>`? | `workspace_id`, `runtime` |
### Channels
| Tool | Description | Key params |
|------|-------------|------------|
| `list_channel_adapters` | List available social channel adapters (Telegram, Slack, etc.) | _(none)_ |
| `list_channels` | List social channels connected to a workspace | `workspace_id` |
| `add_channel` | Connect a social channel to a workspace; messages on the channel forward to the agent | `workspace_id`, `channel_type`, `config` (JSON string), `allowed_users?` |
| `update_channel` | Update a social channel's config, enabled state, or allowed users (triggers hot reload) | `workspace_id`, `channel_id`, `config?`, `enabled?`, `allowed_users?` |
| `remove_channel` | Remove a social channel from a workspace | `workspace_id`, `channel_id` |
| `send_channel_message` | Send an outbound message from a workspace to its connected social channel | `workspace_id`, `channel_id`, `text` |
| `test_channel` | Send a test message to verify a social channel connection works | `workspace_id`, `channel_id` |
| `discover_channel_chats` | Auto-detect chat IDs / channels for a given bot token (e.g. Telegram) before creating a channel | `type`, `config` |
### Delegation & Activity
| Tool | Description | Key params |
|------|-------------|------------|
| `async_delegate` | Delegate a task to another workspace (non-blocking); returns a delegation_id immediately | `workspace_id`, `target_id`, `task` |
| `check_delegations` | Check status of delegated tasks for a workspace (pending/completed/failed + results) | `workspace_id` |
| `record_delegation` | Register an agent-initiated delegation with the platform's activity log | `workspace_id`, `target_id`, `task`, `delegation_id` |
| `update_delegation_status` | Mirror an agent-initiated delegation's status to activity_logs | `workspace_id`, `delegation_id`, `status`, `error?`, `response_preview?` |
| `report_activity` | Write an arbitrary activity log row from an agent (a2a events, tool calls, errors) | `workspace_id`, `activity_type`, `method?`, `summary?`, `status?`, `error_detail?`, `request_body?`, `response_body?`, `duration_ms?` |
| `list_activity` | List activity logs for a workspace (A2A communications, tasks, errors) | `workspace_id`, `type?`, `limit?` |
| `notify_user` | Push a notification from the agent to the canvas via WebSocket (toast / chat bubble) | `workspace_id`, `type` |
| `list_traces` | List recent LLM traces from Langfuse for a workspace | `workspace_id` |
### Schedules
| Tool | Description | Key params |
|------|-------------|------------|
| `list_schedules` | List cron schedules for a workspace | `workspace_id` |
| `create_schedule` | Create a cron schedule that fires a prompt on a recurring timer | `workspace_id`, `name`, `cron_expr`, `prompt`, `timezone?`, `enabled?` |
| `update_schedule` | Update fields on an existing schedule | `workspace_id`, `schedule_id`, `name?`, `cron_expr?`, `prompt?`, `timezone?`, `enabled?` |
| `delete_schedule` | Delete a schedule | `workspace_id`, `schedule_id` |
| `run_schedule` | Fire a schedule manually, bypassing its cron expression | `workspace_id`, `schedule_id` |
| `get_schedule_history` | Get past runs of a schedule (status, start/end, output preview) | `workspace_id`, `schedule_id` |
### Approvals
| Tool | Description | Key params |
|------|-------------|------------|
| `list_pending_approvals` | List all pending approval requests across workspaces | _(none)_ |
| `decide_approval` | Approve or deny a pending approval request | `workspace_id`, `approval_id`, `decision` (`approved` \| `denied`) |
| `create_approval` | Create an approval request for a workspace | `workspace_id`, `action`, `reason?` |
| `get_workspace_approvals` | List approval requests for a specific workspace | `workspace_id` |
### Discovery & Org
| Tool | Description | Key params |
|------|-------------|------------|
| `list_peers` | List reachable peer workspaces (siblings, children, parent) | `workspace_id` |
| `discover_workspace` | Resolve a workspace URL by ID (for A2A communication) | `workspace_id` |
| `check_access` | Check if two workspaces can communicate | `caller_id`, `target_id` |
| `list_events` | List structure events (global or per workspace) | `workspace_id?` |
| `list_templates` | List available workspace templates | _(none)_ |
| `list_org_templates` | List available org templates | _(none)_ |
| `import_org` | Import an org template to create an entire workspace hierarchy | `dir` |
| `import_template` | Import agent files as a new workspace template | `name`, `files` (path → content map) |
| `export_bundle` | Export a workspace as a portable `.bundle.json` | `workspace_id` |
| `import_bundle` | Import a workspace from a bundle JSON object | `bundle` |
| `get_canvas_viewport` | Get the current canvas viewport (x, y, zoom) persisted per-user | _(none)_ |
| `set_canvas_viewport` | Persist the canvas viewport (x, y, zoom) | `x`, `y`, `zoom` |
| `expand_team` | Expand a workspace into a team of sub-workspaces | `workspace_id` |
| `collapse_team` | Collapse a team back to a single workspace | `workspace_id` |
### Remote Agents (Phase 30)
Tools that surface workspaces with `runtime='external'` — agents that run on machines outside this platform's Docker network and join via HTTP.
| Tool | Description | Key params |
|------|-------------|------------|
| `list_remote_agents` | Filter the workspace list to remote agents only — id / name / status / url / `last_heartbeat_at` / uptime | _(none)_ |
| `get_remote_agent_state` | Lightweight `{status, paused, deleted, runtime, last_heartbeat_at}` projection — faster than `get_workspace` when you only need lifecycle | `workspace_id` |
| `get_remote_agent_setup_command` | Emit a `WORKSPACE_ID=… PLATFORM_URL=… python3 …` bash one-liner an operator can paste into a remote shell. Pass `platform_url_override` if the MCP server's `PLATFORM_URL` is localhost. | `workspace_id`, `platform_url_override?` |
| `check_remote_agent_freshness` | Compare `last_heartbeat_at` against a threshold (default 90 s) — returns `{fresh, seconds_since_heartbeat, threshold_seconds}` | `workspace_id`, `threshold_seconds?` |
## Setup
@@ -34,7 +184,7 @@ Add to your project's `.mcp.json`:
"command": "node",
"args": ["./mcp-server/dist/index.js"],
"env": {
"MOLECULE_API_URL": "http://localhost:8080"
"MOLECULE_URL": "http://localhost:8080"
}
}
}
@@ -52,7 +202,7 @@ Add to `.cursor/mcp.json`:
"command": "node",
"args": ["./mcp-server/dist/index.js"],
"env": {
"MOLECULE_API_URL": "http://localhost:8080"
"MOLECULE_URL": "http://localhost:8080"
}
}
}
@@ -62,22 +212,15 @@ Add to `.cursor/mcp.json`:
### Codex / OpenCode
```bash
MOLECULE_API_URL=http://localhost:8080 node mcp-server/dist/index.js
# Run directly
MOLECULE_URL=http://localhost:8080 node mcp-server/dist/index.js
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MOLECULE_API_URL` | `http://localhost:8080` | Platform API base URL |
| `MOLECULE_API_KEY` | — | API key for platform authentication |
| `MCP_SERVER_PORT` | `3000` | Port (for HTTP/SSE transport) |
## Quick Start
1. `npm install && npm run build`
2. Set `MOLECULE_API_URL` and `MOLECULE_API_KEY`
3. `npm start` (stdio mode) or use an MCP host config
| `MOLECULE_URL` | `http://localhost:8080` | Platform API URL |
## Examples
@@ -93,16 +236,13 @@ Agent: [calls chat_with_agent with message="Audit https://example.com for SEO"]
You: "What skills does the coding agent have?"
Agent: [calls get_workspace, reads agent_card.skills]
You: "Run the daily digest schedule now and show me the last 3 runs"
Agent: [calls run_schedule, then get_schedule_history]
You: "Connect the marketing workspace to our Telegram bot"
Agent: [calls discover_channel_chats, then add_channel with channel_type="telegram"]
You: "Are any of the remote agents stale?"
Agent: [calls list_remote_agents, then check_remote_agent_freshness for each]
```
## Remote Agents (Phase 30)
For agents running outside the platform's Docker network, the `get_remote_agent_setup_command`
tool generates a bash one-liner:
```bash
pip install molecule-ai-sdk
WORKSPACE_ID=... PLATFORM_URL=... python3 -c "from molecule_agent import RemoteAgentClient; ..."
```
See the full tool registry in `CLAUDE.md` for all 87 tools.
+14 -15
View File
@@ -177,24 +177,23 @@ test documenting the known `optional().nullable()` zod-to-json-schema quirk.
---
## KI-007 — MCP server heartbeat tools are read-only; actual heartbeat lives in the Python SDK
## KI-007 — Heartbeat cleanup fires after SSE stream closes
**File:** `src/tools/remote_agents.ts` (heartbeat tool)
**Status:** Resolved — clarified scope
**Status:** Identified
**Severity:** Low
### Clarification
The MCP server's remote-agent tools (`list_remote_agents`, `get_remote_agent_state`,
`check_remote_agent_freshness`, `get_remote_agent_setup_command`) are **read-only
queries** — they do not drive any background heartbeat loop. The actual
`run_heartbeat_loop()` that sends heartbeats from a remote agent lives in the
Python SDK (`molecule_sdk_python/molecule_agent/client.py`).
### Symptom
When using SSE transport, the heartbeat mechanism does not immediately clean up
when a stream closes. A background timer or goroutine may continue sending heartbeats
to workspaces whose SSE connections have been closed by the client.
The heartbeat cleanup issue (heartbeat loop continues after the controlling MCP
client disconnects) is tracked as **SDK KI-009** in `molecule-sdk-python/known-issues.md`.
### Impact
Orphaned heartbeat calls continue consuming platform API quota after the MCP client
has disconnected. Over time this can cause the workspace to accumulate heartbeat
sessions that never expire on the platform side.
### Suggested fix (SDK side)
Expose a `stop_event` parameter or `stop()` method on `RemoteAgentClient` so the
callers (MCP client, shell wrapper) can signal the loop to exit cleanly. The
Python SDK's `run_heartbeat_loop()` should check `threading.Event` or accept a
`stop_on: asyncio.Event` argument. See `molecule-sdk-python/known-issues.md`.
### Suggested fix
Attach a cleanup function to the SSE stream `close` event. Invalidate the heartbeat
timer when the stream ends so no further calls are made. Document the expected
SSE session lifecycle in the streaming convention section of CLAUDE.md.
+22 -17
View File
@@ -555,9 +555,10 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"version": "1.19.12",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz",
"integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
@@ -2554,11 +2555,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@@ -2594,9 +2596,9 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
@@ -2606,7 +2608,8 @@
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
],
"license": "BSD-3-Clause"
},
"node_modules/fb-watchman": {
"version": "2.0.2",
@@ -2902,9 +2905,10 @@
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="
},
"node_modules/hono": {
"version": "4.12.18",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"version": "4.12.10",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz",
"integrity": "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
@@ -3011,9 +3015,10 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
+1 -1
View File
@@ -29,6 +29,6 @@
},
"repository": {
"type": "git",
"url": "https://git.moleculesai.app/molecule-ai/molecule-mcp-server.git"
"url": "https://github.com/Molecule-AI/molecule-mcp-server.git"
}
}
+2 -2
View File
@@ -1,5 +1,3 @@
import { error as logError } from "./utils/logger.js";
// Read the platform API base URL from environment.
// Priority: MOLECULE_API_URL (canonical CLI/SDK env var, per platform docs)
//
@@ -21,6 +19,8 @@ export const PLATFORM_URL =
* Shape returned by apiCall when the request fails (network error, non-2xx,
* or non-JSON body with no error). Returned-by-value — apiCall never throws.
*/
import { error as logError } from "./utils/logger.js";
export type ApiError = { error: string; detail?: string; raw?: string; status?: number };
export function isApiError(v: unknown): v is ApiError {
+4 -5
View File
@@ -85,19 +85,18 @@ export async function handleGetRemoteAgentSetupCommand(params: {
const setupCmd = [
`# Run on the remote machine where the agent will live.`,
`# Requires Python 3.11+ and bash (the SDK invokes setup.sh via bash).`,
`pip install molecule-ai-sdk # (or: pip install -e <molecule-checkout>/molecule-sdk-python)`,
`pip install molecule-sdk # (or: pip install -e <molecule-checkout>/sdk/python)`,
``,
`WORKSPACE_ID=${w.id} \\`,
`PLATFORM_URL=${targetUrl} \\`,
`python3 -c "from molecule_agent import RemoteAgentClient; \\`,
` c = RemoteAgentClient(workspace_id='${w.id}', platform_url='${targetUrl}'); \\`,
` if c.load_token() is None: c.register(); \\`,
` c = RemoteAgentClient.register_from_env(); \\`,
` c.pull_secrets(); \\`,
` c.run_heartbeat_loop()"`,
``,
`# For a richer demo (logging, graceful shutdown) see`,
`# examples/remote-agent/run.py in the molecule-sdk-python checkout.`,
`# The agent will register (mint + cache bearer token at`,
`# sdk/python/examples/remote-agent/run.py in the molecule-monorepo checkout.`,
`# The agent will register, mint its bearer token (cached at`,
`# ~/.molecule/${w.id}/.auth_token), pull secrets, then heartbeat.`,
].join("\n");
return toMcpResult({
-96
View File
@@ -290,99 +290,3 @@ describe("platformGet", () => {
});
});
});
// ---------------------------------------------------------------------------
// remote_agents — handleGetRemoteAgentSetupCommand
// ---------------------------------------------------------------------------
// remote_agents.ts reads PLATFORM_URL at module-load time from process.env.
// We use jest.isolateModules so each test gets a fresh module context with
// the right env var set before the module is loaded.
const originalEnv = process.env.MOLECULE_API_URL;
describe("handleGetRemoteAgentSetupCommand", () => {
beforeEach(() => {
jest.resetModules();
process.env.MOLECULE_API_URL = "http://localhost:8080";
});
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.MOLECULE_API_URL;
} else {
process.env.MOLECULE_API_URL = originalEnv;
}
});
async function loadHandlerAndMock(workspace: Record<string, unknown>) {
let handler!: typeof import("../../src/tools/remote_agents").handleGetRemoteAgentSetupCommand;
let mockGet!: jest.Mock;
await new Promise<void>((resolve) => {
jest.isolateModules(() => {
mockGet = jest.fn().mockResolvedValue(workspace);
jest.mock("../../src/api", () => ({
...jest.requireActual("../../src/api"),
platformGet: mockGet,
}));
const mod = require("../../src/tools/remote_agents");
handler = mod.handleGetRemoteAgentSetupCommand;
resolve();
});
});
return { handler, mockGet };
}
it("generates valid Python command with constructor + register pattern", async () => {
const { handler } = await loadHandlerAndMock({
id: "ws-abc123",
name: "my-agent",
runtime: "external",
});
const result = await handler({ workspace_id: "ws-abc123" });
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.workspace_id).toBe("ws-abc123");
expect(parsed.workspace_name).toBe("my-agent");
expect(parsed.setup_command).toContain("RemoteAgentClient(workspace_id='ws-abc123'");
expect(parsed.setup_command).not.toContain("register_from_env");
expect(parsed.setup_command).toContain("register()");
});
it("warns when PLATFORM_URL is localhost and no override is given", async () => {
const { handler } = await loadHandlerAndMock({
id: "ws-abc123",
name: "my-agent",
runtime: "external",
});
const result = await handler({ workspace_id: "ws-abc123" });
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.warnings).toBeDefined();
expect(parsed.warnings![0]).toContain("localhost");
});
it("uses platform_url_override when provided", async () => {
const { handler } = await loadHandlerAndMock({
id: "ws-abc123",
name: "my-agent",
runtime: "external",
});
const result = await handler({
workspace_id: "ws-abc123",
platform_url_override: "https://platform.example.com",
});
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.setup_command).toContain("platform_url='https://platform.example.com'");
expect(parsed.warnings).toBeUndefined();
});
it("returns error when workspace is not runtime=external", async () => {
const { handler } = await loadHandlerAndMock({
id: "ws-abc123",
name: "my-agent",
runtime: "docker",
});
const result = await handler({ workspace_id: "ws-abc123" });
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.error).toContain("not external");
expect(parsed.setup_command).toBeUndefined();
});
});
-164
View File
@@ -1,164 +0,0 @@
/**
* Unit tests for src/tools/remote_agents.ts
*
* Tests handleGetRemoteAgentSetupCommand which generates a Python bootstrap
* command for remote agents. Key edge cases:
* - localhost warning when PLATFORM_URL is localhost and no override given
* - platform_url_override bypasses localhost warning
* - non-external runtime returns error
* - workspace not found returns error
*/
import { toMcpResult } from "../../src/api";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Factory so each fetch call gets a fresh Response (bodies can only be read once). */
function makeFetchResponse(body: unknown, init: ResponseInit = {}): Response {
const text = typeof body === "string" ? body : JSON.stringify(body);
return new Response(text, {
status: init.status ?? 200,
statusText: init.statusText,
headers: init.headers as HeadersInit,
});
}
type RemoteAgentsHandler = {
handleGetRemoteAgentSetupCommand: (
params: { workspace_id: string; platform_url_override?: string }
) => Promise<ReturnType<typeof toMcpResult>>;
};
/**
* Dynamically import the remote_agents module with a mocked platformGet.
* Must be called inside jest.isolateModules() with MOLECULE_API_URL set.
*/
async function loadHandlerWithMock(
mockPlatformGet: jest.Mock,
): Promise<RemoteAgentsHandler> {
let handler!: RemoteAgentsHandler;
await new Promise<void>((resolve) => {
jest.isolateModules(() => {
jest.mock("../../src/api", () => ({
...jest.requireActual("../../src/api"),
platformGet: mockPlatformGet,
}));
const mod = require("../../src/tools/remote_agents") as RemoteAgentsHandler;
handler = mod;
resolve();
});
});
return handler;
}
// ---------------------------------------------------------------------------
// handleGetRemoteAgentSetupCommand tests
// ---------------------------------------------------------------------------
describe("handleGetRemoteAgentSetupCommand", () => {
beforeEach(() => {
jest.resetModules();
});
it("returns a setup command with correct RemoteAgentClient API call", async () => {
const mockGet = jest.fn().mockResolvedValue({
id: "ws-abc123",
name: "test-agent",
runtime: "external",
});
const handler = await loadHandlerWithMock(mockGet);
const result = await handler.handleGetRemoteAgentSetupCommand({
workspace_id: "ws-abc123",
});
expect(result.content[0].text).toContain("ws-abc123");
expect(result.content[0].text).toContain("molecule_agent import RemoteAgentClient");
// Must use constructor + load_token pattern, NOT the non-existent register_from_env()
expect(result.content[0].text).not.toContain("register_from_env()");
expect(result.content[0].text).toContain("load_token()");
expect(result.content[0].text).toContain("pull_secrets()");
expect(result.content[0].text).toContain("run_heartbeat_loop()");
});
it("returns a localhost warning when PLATFORM_URL is localhost and no override given", async () => {
// Set localhost as the platform URL before loading the module
process.env.MOLECULE_API_URL = "http://localhost:8080";
const mockGet = jest.fn().mockResolvedValue({
id: "ws-abc123",
name: "test-agent",
runtime: "external",
});
const handler = await loadHandlerWithMock(mockGet);
const result = await handler.handleGetRemoteAgentSetupCommand({
workspace_id: "ws-abc123",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.warnings).toBeDefined();
expect(parsed.warnings[0]).toContain("localhost");
expect(parsed.warnings[0]).toContain("platform_url_override");
delete process.env.MOLECULE_API_URL;
});
it("platform_url_override bypasses the localhost warning", async () => {
// Even with localhost as the base URL, passing an override suppresses the warning
process.env.MOLECULE_API_URL = "http://localhost:8080";
const mockGet = jest.fn().mockResolvedValue({
id: "ws-abc123",
name: "test-agent",
runtime: "external",
});
const handler = await loadHandlerWithMock(mockGet);
const result = await handler.handleGetRemoteAgentSetupCommand({
workspace_id: "ws-abc123",
platform_url_override: "https://platform.example.com",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.warnings).toBeUndefined();
expect(parsed.platform_url).toBe("https://platform.example.com");
delete process.env.MOLECULE_API_URL;
});
it("returns error when workspace runtime is not 'external'", async () => {
const mockGet = jest.fn().mockResolvedValue({
id: "ws-abc123",
name: "docker-agent",
runtime: "docker",
});
const handler = await loadHandlerWithMock(mockGet);
const result = await handler.handleGetRemoteAgentSetupCommand({
workspace_id: "ws-abc123",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain("not external");
expect(parsed.error).toContain("runtime='external'");
expect(parsed.actual_runtime).toBe("docker");
});
it("returns error when workspace is not found", async () => {
const mockGet = jest.fn().mockResolvedValue({
error: "not found",
detail: "workspace ws-missing does not exist",
});
const handler = await loadHandlerWithMock(mockGet);
const result = await handler.handleGetRemoteAgentSetupCommand({
workspace_id: "ws-missing",
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toBeDefined();
});
});