diff --git a/.mcp.json b/.mcp.json index 2e38aa1f..b066349e 100644 --- a/.mcp.json +++ b/.mcp.json @@ -11,8 +11,8 @@ }, "molecule": { "type": "stdio", - "command": "node", - "args": ["./mcp-server/dist/index.js"], + "command": "npx", + "args": ["-y", "@molecule-ai/mcp-server"], "env": { "MOLECULE_URL": "http://localhost:8080" } diff --git a/docs/guides/external-agent-registration.md b/docs/guides/external-agent-registration.md new file mode 100644 index 00000000..1cf1d2aa --- /dev/null +++ b/docs/guides/external-agent-registration.md @@ -0,0 +1,784 @@ +# External Agent Registration Guide + +## Overview + +An **external agent** (also called a remote agent) is any AI agent that runs +outside the Molecule AI platform's Docker network but participates in the +canvas, communicates with other agents via the A2A protocol, and is managed as +a first-class workspace. + +Use cases for external agents: + +- **Existing infrastructure** -- you already run an agent on your own servers + and want it to join a Molecule org without re-deploying inside Docker. +- **Different cloud / region** -- the agent runs on AWS, GCP, Azure, or + another provider while the platform runs elsewhere. +- **Edge devices** -- agents running on-premises or on edge hardware that + cannot be containerized by the platform. +- **Third-party services** -- SaaS bots or webhook-driven services that + expose an A2A-compatible HTTP endpoint. +- **Development / debugging** -- run an agent locally on your laptop while + pointing it at a shared platform instance. + +External workspaces behave identically to platform-provisioned workspaces in +every way except two: the platform does not start or stop a Docker container +for them, and liveness is tracked exclusively through the heartbeat TTL (the +Docker health sweep is skipped). + +--- + +## Prerequisites + +| Requirement | Details | +|-------------|---------| +| Running Molecule AI platform | Default `http://localhost:8080`. Set `NEXT_PUBLIC_PLATFORM_URL` in canvas accordingly. | +| Publicly reachable HTTP endpoint | Your agent must accept POST requests for incoming A2A messages. If you are behind NAT, use a tunnel (ngrok, Cloudflare Tunnel, etc.). | +| Bearer token storage | The platform issues a 256-bit auth token on first registration. You must persist it -- it is shown only once and cannot be recovered. | + +--- + +## Step-by-Step Registration + +### 1. Create an External Workspace + +```bash +curl -X POST http://localhost:8080/workspaces \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "My External Agent", + "role": "researcher", + "runtime": "external", + "external": true, + "url": "https://my-agent.example.com", + "tier": 2, + "parent_id": null + }' +``` + +**Response:** + +```json +{ + "id": "a1b2c3d4-...", + "status": "online", + "external": true +} +``` + +Notes: + +- `POST /workspaces` requires `AdminAuth` (bearer token) when any live admin + token exists on the platform. On a fresh install with no tokens, it + bootstraps open. +- `external: true` tells the platform to skip Docker provisioning. The + workspace goes straight to `online` if a URL is provided. +- `url` is the publicly reachable endpoint where your agent accepts A2A + messages. It must be an HTTPS or HTTP URL. +- `runtime` should be `"external"` -- the canvas renders a purple "REMOTE" + badge for this runtime value. +- `tier` defaults to `1` if omitted. Tier has no resource-limit effect on + external workspaces (no container), but it is stored for organizational + display. +- `parent_id` is optional. Set it to nest this workspace under an existing + team/parent workspace. + +Save the returned `id` -- you will need it for every subsequent call. + +### 2. Register with the Platform + +```bash +curl -X POST http://localhost:8080/registry/register \ + -H "Content-Type: application/json" \ + -d '{ + "id": "", + "url": "https://my-agent.example.com", + "agent_card": { + "name": "My External Agent", + "description": "Handles research tasks and summarization", + "skills": ["research", "summarization", "analysis"], + "runtime": "external" + } + }' +``` + +**Response (first registration):** + +```json +{ + "status": "registered", + "auth_token": "mol_abc123...very-long-token" +} +``` + +**Critical:** The `auth_token` field is present **only on first +registration**. It is never returned again. Store it securely (environment +variable, secrets manager, etc.). All subsequent authenticated calls require +this token. + +On re-registration (e.g., after your agent restarts), the response contains +only `{"status": "registered"}` -- the original token remains valid. + +### 3. Start the Heartbeat Loop + +Your agent must send a heartbeat every **30 seconds** to stay online. If the +platform receives no heartbeat for 60 seconds, the workspace transitions to +`offline`. + +```bash +curl -X POST http://localhost:8080/registry/heartbeat \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "workspace_id": "", + "error_rate": 0.0, + "active_tasks": 0, + "current_task": "", + "uptime_seconds": 3600, + "sample_error": "" + }' +``` + +**Response:** + +```json +{ "status": "ok" } +``` + +Heartbeat fields: + +| Field | Type | Description | +|-------|------|-------------| +| `workspace_id` | string | Required. Your workspace ID. | +| `error_rate` | float | 0.0 -- 1.0. If > 0.5, workspace enters `degraded` status on the canvas. | +| `active_tasks` | int | Number of tasks currently running. Displayed in the canvas node. | +| `current_task` | string | Human-readable description of current work. Shown in the workspace detail panel. | +| `uptime_seconds` | int | Seconds since your agent started. | +| `sample_error` | string | Most recent error message, if any. Visible in monitoring. | + +### 4. Handle Incoming A2A Messages + +Your agent must accept `POST` requests at the URL you registered. The +platform (and other agents) send messages in A2A JSON-RPC format: + +```json +{ + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [ + { "type": "text", "text": "Research the latest trends in AI safety" } + ] + }, + "metadata": { + "source": "agent", + "history": [] + } + }, + "id": "req-abc-123" +} +``` + +Your endpoint must return a JSON-RPC response: + +```json +{ + "jsonrpc": "2.0", + "result": { + "message": { + "role": "agent", + "parts": [ + { "type": "text", "text": "Here are the latest AI safety trends..." } + ] + } + }, + "id": "req-abc-123" +} +``` + +For errors, return a JSON-RPC error object: + +```json +{ + "jsonrpc": "2.0", + "error": { + "code": -32603, + "message": "Internal error: model rate limited" + }, + "id": "req-abc-123" +} +``` + +### 5. Send Messages to Other Agents + +Use the A2A proxy to communicate with any workspace your agent is allowed to +reach: + +```bash +curl -X POST http://localhost:8080/workspaces//a2a \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -H "X-Workspace-ID: " \ + -d '{ + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [ + { "type": "text", "text": "Can you help with this task?" } + ] + }, + "metadata": {} + }, + "id": "req-456" + }' +``` + +Both headers are required: + +- `Authorization: Bearer ` -- your workspace auth token. +- `X-Workspace-ID: ` -- identifies which workspace is making the + call. The platform uses this to enforce communication rules. + +### 6. Discover Peers + +Find out which other workspaces your agent can communicate with: + +```bash +# Get your workspace's own info +curl http://localhost:8080/registry/discover/ \ + -H "Authorization: Bearer " \ + -H "X-Workspace-ID: " + +# List peers (siblings + parent + children) +curl http://localhost:8080/registry//peers \ + -H "Authorization: Bearer " \ + -H "X-Workspace-ID: " +``` + +The peers endpoint returns workspaces that your agent is allowed to +communicate with, based on the hierarchy rules below. + +--- + +## Communication Rules + +The platform enforces strict hierarchy-based access control via +`CanCommunicate(callerID, targetID)`: + +| Relationship | Allowed | +|---|---| +| Same workspace (self-call) | Yes | +| Siblings (same `parent_id`) | Yes | +| Root-level siblings (both `parent_id` is NULL) | Yes | +| Parent to child | Yes | +| Child to parent | Yes | +| Everything else | **Denied** | + +Canvas requests (no `X-Workspace-ID` header) and system callers +(`webhook:*`, `system:*`, `test:*` prefixes) bypass this check. + +--- + +## Canvas Appearance + +External workspaces appear on the canvas with a purple **REMOTE** badge +instead of the usual runtime pill (e.g., "LANGGRAPH", "CLAUDE-CODE"). + +They support all standard canvas features: + +- Drag-and-drop positioning (persisted via `PATCH /workspaces/:id`) +- Nesting into team nodes (set `parent_id` on create or move via API) +- Real-time status updates -- heartbeat data (active tasks, current task, + error rate) is broadcast to canvas clients via WebSocket +- Chat panel -- "My Chat" tab sends A2A messages to the agent; "Agent Comms" + tab shows inter-agent traffic +- Config and secrets management via the detail panel + +--- + +## Example: Python Implementation + +A minimal external agent using `requests` and `flask`: + +```python +""" +Minimal Molecule AI external agent. + +pip install flask requests +""" + +import os +import sys +import time +import threading +import requests +from flask import Flask, request, jsonify + +PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080") +AGENT_URL = os.environ.get("AGENT_URL") # e.g. "https://my-agent.ngrok.io" +ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "") # for POST /workspaces +AGENT_NAME = os.environ.get("AGENT_NAME", "Python External Agent") + +app = Flask(__name__) + +# --- State --- +workspace_id = None +auth_token = None +start_time = time.time() + + +# --- Step 4: Handle incoming A2A messages --- +@app.route("/", methods=["POST"]) +def handle_a2a(): + payload = request.get_json(force=True) + method = payload.get("method", "") + req_id = payload.get("id", "unknown") + + if method == "message/send": + text = "" + parts = ( + payload.get("params", {}).get("message", {}).get("parts", []) + ) + for part in parts: + if part.get("type") == "text": + text += part.get("text", "") + + # --- Your agent logic here --- + reply = f"Received: {text}. Processing complete." + + return jsonify({ + "jsonrpc": "2.0", + "result": { + "message": { + "role": "agent", + "parts": [{"type": "text", "text": reply}], + } + }, + "id": req_id, + }) + + return jsonify({ + "jsonrpc": "2.0", + "error": {"code": -32601, "message": f"Unknown method: {method}"}, + "id": req_id, + }) + + +# --- Step 3: Heartbeat loop --- +def heartbeat_loop(): + while True: + try: + requests.post( + f"{PLATFORM_URL}/registry/heartbeat", + json={ + "workspace_id": workspace_id, + "error_rate": 0.0, + "active_tasks": 0, + "current_task": "", + "uptime_seconds": int(time.time() - start_time), + }, + headers={"Authorization": f"Bearer {auth_token}"}, + timeout=10, + ) + except Exception as e: + print(f"Heartbeat failed: {e}", file=sys.stderr) + time.sleep(30) + + +def register(): + global workspace_id, auth_token + + if not AGENT_URL: + print("ERROR: Set AGENT_URL to your publicly reachable endpoint", file=sys.stderr) + sys.exit(1) + + # Step 1: Create external workspace + headers = {"Content-Type": "application/json"} + if ADMIN_TOKEN: + headers["Authorization"] = f"Bearer {ADMIN_TOKEN}" + + resp = requests.post( + f"{PLATFORM_URL}/workspaces", + json={ + "name": AGENT_NAME, + "runtime": "external", + "external": True, + "url": AGENT_URL, + "tier": 2, + }, + headers=headers, + timeout=10, + ) + resp.raise_for_status() + workspace_id = resp.json()["id"] + print(f"Created workspace: {workspace_id}") + + # Step 2: Register with the platform + resp = requests.post( + f"{PLATFORM_URL}/registry/register", + json={ + "id": workspace_id, + "url": AGENT_URL, + "agent_card": { + "name": AGENT_NAME, + "description": "A minimal Python external agent", + "skills": ["echo"], + "runtime": "external", + }, + }, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + auth_token = data.get("auth_token") + if auth_token: + print(f"Auth token received (save this!): {auth_token[:12]}...") + else: + print("No new token issued (re-registration). Use your saved token.") + auth_token = os.environ.get("AUTH_TOKEN") + if not auth_token: + print("ERROR: Set AUTH_TOKEN for re-registration", file=sys.stderr) + sys.exit(1) + + # Start heartbeat + t = threading.Thread(target=heartbeat_loop, daemon=True) + t.start() + print("Heartbeat loop started (every 30s)") + + +if __name__ == "__main__": + register() + print(f"Listening on {AGENT_URL}") + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000))) +``` + +Run it: + +```bash +export PLATFORM_URL=http://localhost:8080 +export AGENT_URL=https://my-agent.ngrok.io +export ADMIN_TOKEN=your-admin-bearer-token +python external_agent.py +``` + +--- + +## Example: Node.js Implementation + +A minimal external agent using the built-in `fetch` and `express`: + +```javascript +/** + * Minimal Molecule AI external agent. + * + * npm install express + * Node.js 18+ required (native fetch). + */ + +const express = require("express"); + +const PLATFORM_URL = process.env.PLATFORM_URL || "http://localhost:8080"; +const AGENT_URL = process.env.AGENT_URL; // e.g. "https://my-agent.ngrok.io" +const ADMIN_TOKEN = process.env.ADMIN_TOKEN || ""; +const AGENT_NAME = process.env.AGENT_NAME || "Node External Agent"; +const PORT = parseInt(process.env.PORT || "5000", 10); + +let workspaceId = null; +let authToken = null; +const startTime = Date.now(); + +// --- Step 4: Handle incoming A2A messages --- +const app = express(); +app.use(express.json()); + +app.post("/", (req, res) => { + const { method, id: reqId, params } = req.body; + + if (method === "message/send") { + const parts = params?.message?.parts || []; + const text = parts + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(""); + + // --- Your agent logic here --- + const reply = `Received: ${text}. Processing complete.`; + + return res.json({ + jsonrpc: "2.0", + result: { + message: { + role: "agent", + parts: [{ type: "text", text: reply }], + }, + }, + id: reqId, + }); + } + + res.json({ + jsonrpc: "2.0", + error: { code: -32601, message: `Unknown method: ${method}` }, + id: reqId, + }); +}); + +// --- Step 3: Heartbeat loop --- +function startHeartbeat() { + setInterval(async () => { + try { + await fetch(`${PLATFORM_URL}/registry/heartbeat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + workspace_id: workspaceId, + error_rate: 0.0, + active_tasks: 0, + current_task: "", + uptime_seconds: Math.floor((Date.now() - startTime) / 1000), + }), + }); + } catch (err) { + console.error("Heartbeat failed:", err.message); + } + }, 30_000); +} + +// --- Steps 1 & 2: Create + register --- +async function register() { + if (!AGENT_URL) { + console.error("ERROR: Set AGENT_URL to your publicly reachable endpoint"); + process.exit(1); + } + + // Step 1: Create external workspace + const headers = { "Content-Type": "application/json" }; + if (ADMIN_TOKEN) headers["Authorization"] = `Bearer ${ADMIN_TOKEN}`; + + const createResp = await fetch(`${PLATFORM_URL}/workspaces`, { + method: "POST", + headers, + body: JSON.stringify({ + name: AGENT_NAME, + runtime: "external", + external: true, + url: AGENT_URL, + tier: 2, + }), + }); + if (!createResp.ok) throw new Error(`Create failed: ${createResp.status}`); + const createData = await createResp.json(); + workspaceId = createData.id; + console.log(`Created workspace: ${workspaceId}`); + + // Step 2: Register + const regResp = await fetch(`${PLATFORM_URL}/registry/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: workspaceId, + url: AGENT_URL, + agent_card: { + name: AGENT_NAME, + description: "A minimal Node.js external agent", + skills: ["echo"], + runtime: "external", + }, + }), + }); + if (!regResp.ok) throw new Error(`Register failed: ${regResp.status}`); + const regData = await regResp.json(); + + authToken = regData.auth_token; + if (authToken) { + console.log(`Auth token received (save this!): ${authToken.slice(0, 12)}...`); + } else { + console.log("No new token issued (re-registration). Using saved token."); + authToken = process.env.AUTH_TOKEN; + if (!authToken) { + console.error("ERROR: Set AUTH_TOKEN for re-registration"); + process.exit(1); + } + } + + startHeartbeat(); + console.log("Heartbeat loop started (every 30s)"); +} + +register().then(() => { + app.listen(PORT, () => console.log(`Listening on port ${PORT}`)); +}); +``` + +Run it: + +```bash +export PLATFORM_URL=http://localhost:8080 +export AGENT_URL=https://my-agent.ngrok.io +export ADMIN_TOKEN=your-admin-bearer-token +node external_agent.js +``` + +--- + +## Lifecycle + +External workspaces follow a simplified version of the standard lifecycle: + +``` +provisioning --> online (on create with URL, or on register) + | + v + degraded (error_rate > 0.5 in heartbeat) + | + v + online (error_rate recovers) + | + v + offline (heartbeat TTL expires after 60s) + | + v + removed (DELETE /workspaces/:id) +``` + +Key differences from platform-managed workspaces: + +- **No Docker health sweep** -- external workspaces are invisible to the + Docker API. Only the Redis heartbeat TTL determines liveness. +- **No auto-restart** -- when an external workspace goes offline, the + platform does not attempt to restart it. Your agent is responsible for + re-registering and resuming heartbeats. +- **Pause/resume** -- pausing an external workspace (`POST + /workspaces/:id/pause`) sets status to `paused` and the heartbeat monitor + skips it. Resuming (`POST /workspaces/:id/resume`) returns it to + `provisioning`; your agent must re-register to go back online. + +--- + +## Security + +### Auth Token + +- Tokens are 256-bit cryptographically random values. +- The raw token is returned **once** at first registration. The platform + stores only the SHA-256 hash in the `workspace_auth_tokens` table. +- Lost tokens cannot be recovered. If you lose your token, delete the + workspace and create a new one (or wait for a future token-reset API). +- Tokens are automatically revoked when a workspace is deleted. + +### Required Headers + +| Endpoint | Required Headers | +|----------|-----------------| +| `POST /registry/heartbeat` | `Authorization: Bearer ` | +| `POST /registry/update-card` | `Authorization: Bearer ` | +| `POST /workspaces/:target/a2a` | `Authorization: Bearer `, `X-Workspace-ID: ` | +| `GET /registry/discover/:id` | `Authorization: Bearer `, `X-Workspace-ID: ` | +| `GET /registry/:id/peers` | `Authorization: Bearer `, `X-Workspace-ID: ` | + +### Legacy / Bootstrap Behavior + +Workspaces that registered before the token system existed (Phase 30.1) are +grandfathered -- their requests pass through without a token until their next +`/registry/register` call issues one. After that, the token is enforced on +every subsequent call. + +--- + +## Updating Your Agent Card + +If your agent's capabilities change, update the card without re-registering: + +```bash +curl -X POST http://localhost:8080/registry/update-card \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "workspace_id": "", + "agent_card": { + "name": "My External Agent v2", + "description": "Now with summarization and translation", + "skills": ["research", "summarization", "translation"], + "runtime": "external" + } + }' +``` + +--- + +## Troubleshooting + +### Workspace shows "offline" on the canvas + +**Cause:** Heartbeat has not been received in the last 60 seconds. + +**Fix:** Verify your heartbeat loop is running, sending to the correct +`workspace_id`, and including `Authorization: Bearer `. Check network +connectivity between your agent and the platform. Inspect platform logs for +401 errors. + +### 401 Unauthorized on heartbeat + +**Cause:** Missing or invalid auth token. + +**Fix:** Ensure you are sending the exact token returned at first +registration. Tokens are case-sensitive. If you lost the token, you must +delete the workspace and re-create it. + +### Cannot send A2A messages (403 or communication denied) + +**Cause:** The `CanCommunicate` check failed -- your workspace is not a +sibling, parent, or child of the target. + +**Fix:** Check the hierarchy. Use `GET /registry//peers` to see +which workspaces you can reach. If needed, move your workspace under the +correct parent via `PATCH /workspaces/:id` with `{"parent_id": "..."}`. + +### Agent card not showing on canvas + +**Cause:** Registration was not called, or the `agent_card` JSON was +malformed. + +**Fix:** Call `POST /registry/register` again with a valid `agent_card` +object. The `name`, `description`, and `skills` fields are used for display +on the canvas. + +### Messages not reaching your agent + +**Cause:** The URL you registered is not reachable from the platform. + +**Fix:** +1. Confirm the URL is correct: `curl -X POST -d '{}'` +2. If running locally, use a tunnel (ngrok, Cloudflare Tunnel) and register + the tunnel URL. +3. Check that your agent's HTTP server is binding to `0.0.0.0`, not just + `127.0.0.1`. +4. Verify firewall rules allow inbound traffic on your agent's port. + +### Workspace stuck in "provisioning" + +**Cause:** The `external: true` flag was not set on create, so the platform +tried to provision a Docker container. + +**Fix:** Delete the workspace and re-create it with `"external": true` in the +payload. + +### Re-registration does not return a token + +**Expected behavior.** The token is issued only on first registration. On +re-registration, use the token you saved from the first time. If you need to +start fresh, delete the workspace and create a new one. + +### Heartbeat succeeds but status stays "degraded" + +**Cause:** Your heartbeat is reporting `error_rate` > 0.5. + +**Fix:** Lower the `error_rate` field in your heartbeat payload. The +workspace recovers to `online` automatically once the rate drops below 0.5. diff --git a/docs/guides/mcp-server-setup.md b/docs/guides/mcp-server-setup.md new file mode 100644 index 00000000..c235b6ab --- /dev/null +++ b/docs/guides/mcp-server-setup.md @@ -0,0 +1,245 @@ +# MCP Server Setup Guide + +The Molecule AI MCP server lets any MCP-compatible AI agent (Claude Code, Cursor, etc.) manage workspaces, agents, secrets, memory, schedules, channels, and more through the platform API. + +## Quick Start + +### 1. Install + +The MCP server is published as `@molecule-ai/mcp-server` on npm. + +```bash +npx @molecule-ai/mcp-server +``` + +### 2. Configure in `.mcp.json` + +Add to your project's `.mcp.json`: + +```json +{ + "mcpServers": { + "molecule": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@molecule-ai/mcp-server"], + "env": { + "MOLECULE_URL": "http://localhost:8080" + } + } + } +} +``` + +For production/SaaS deployments, set `MOLECULE_URL` to your tenant URL: +```json +"MOLECULE_URL": "https://hongming-wang.moleculesai.app" +``` + +### 3. Verify + +Once configured, your MCP client should show 87 Molecule AI tools. Test with: +``` +list_workspaces +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MOLECULE_URL` | `http://localhost:8080` | Platform API URL | + +## Tool Reference + +### Workspace Management + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `list_workspaces` | `/workspaces` | GET | List all workspaces | +| `get_workspace` | `/workspaces/:id` | GET | Get workspace details | +| `create_workspace` | `/workspaces` | POST | Create a new workspace | +| `update_workspace` | `/workspaces/:id` | PATCH | Update workspace fields | +| `delete_workspace` | `/workspaces/:id` | DELETE | Delete a workspace | +| `restart_workspace` | `/workspaces/:id/restart` | POST | Restart workspace container | +| `pause_workspace` | `/workspaces/:id/pause` | POST | Pause workspace | +| `resume_workspace` | `/workspaces/:id/resume` | POST | Resume paused workspace | +| `discover_workspace` | `/registry/discover/:id` | GET | Get workspace URL + agent card | + +### Agent Management + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `assign_agent` | `/workspaces/:id/agent` | POST | Assign agent to workspace | +| `remove_agent` | `/workspaces/:id/agent` | DELETE | Remove agent | +| `replace_agent` | `/workspaces/:id/agent` | PATCH | Replace agent config | +| `move_agent` | `/workspaces/:id/agent/move` | POST | Move agent to different workspace | + +### Communication + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `chat_with_agent` | `/workspaces/:id/a2a` | POST | Send A2A message to agent | +| `async_delegate` | `/workspaces/:id/delegate` | POST | Fire-and-forget delegation | +| `check_delegations` | `/workspaces/:id/delegations` | GET | Check delegation status | +| `send_channel_message` | `/workspaces/:id/channels/:channelId/send` | POST | Send to social channel | +| `notify_user` | `/workspaces/:id/notify` | POST | Push notification to canvas | +| `list_peers` | `/registry/:id/peers` | GET | Find sibling/parent workspaces | +| `check_access` | `/registry/check-access` | POST | Check if two workspaces can communicate | + +### Configuration + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `get_config` | `/workspaces/:id/config` | GET | Get workspace config.yaml | +| `update_config` | `/workspaces/:id/config` | PATCH | Update config fields | +| `get_model` | `/workspaces/:id/model` | GET | Get configured LLM model | + +### Secrets + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `list_secrets` | `/workspaces/:id/secrets` | GET | List workspace secret keys | +| `set_secret` | `/workspaces/:id/secrets` | POST | Set a workspace secret | +| `delete_secret` | `/workspaces/:id/secrets/:key` | DELETE | Delete a secret | +| `list_global_secrets` | `/settings/secrets` | GET | List global secrets | +| `set_global_secret` | `/settings/secrets` | PUT | Set a global secret | +| `delete_global_secret` | `/settings/secrets/:key` | DELETE | Delete global secret | + +### Memory + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `memory_list` | `/workspaces/:id/memory` | GET | List memory keys | +| `memory_get` | `/workspaces/:id/memory/:key` | GET | Get memory value | +| `memory_set` | `/workspaces/:id/memory` | POST | Set memory key-value | +| `memory_delete_kv` | `/workspaces/:id/memory/:key` | DELETE | Delete memory key | +| `search_memory` | `/workspaces/:id/memories` | GET | Full-text search memories | +| `commit_memory` | `/workspaces/:id/memories` | POST | Commit HMA memory | +| `delete_memory` | `/workspaces/:id/memories/:id` | DELETE | Delete HMA memory | + +### Files + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `list_files` | `/workspaces/:id/files` | GET | List workspace files | +| `read_file` | `/workspaces/:id/files/*path` | GET | Read file content | +| `write_file` | `/workspaces/:id/files/*path` | PUT | Write/overwrite file | +| `delete_file` | `/workspaces/:id/files/*path` | DELETE | Delete file | +| `replace_all_files` | `/workspaces/:id/files` | PUT | Replace all files atomically | + +### Schedules + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `list_schedules` | `/workspaces/:id/schedules` | GET | List cron schedules | +| `create_schedule` | `/workspaces/:id/schedules` | POST | Create cron schedule | +| `update_schedule` | `/workspaces/:id/schedules/:id` | PATCH | Update schedule | +| `delete_schedule` | `/workspaces/:id/schedules/:id` | DELETE | Delete schedule | +| `run_schedule` | `/workspaces/:id/schedules/:id/run` | POST | Trigger schedule now | +| `get_schedule_history` | `/workspaces/:id/schedules/:id/history` | GET | Past run history | + +### Channels (Social Integrations) + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `list_channels` | `/workspaces/:id/channels` | GET | List configured channels | +| `add_channel` | `/workspaces/:id/channels` | POST | Add Telegram/Slack/Lark channel | +| `update_channel` | `/workspaces/:id/channels/:id` | PATCH | Update channel config | +| `remove_channel` | `/workspaces/:id/channels/:id` | DELETE | Remove channel | +| `test_channel` | `/workspaces/:id/channels/:id/test` | POST | Test channel connectivity | +| `list_channel_adapters` | `/channels/adapters` | GET | Available platforms | +| `discover_channel_chats` | `/channels/discover` | POST | Auto-detect chats for bot token | + +### Plugins + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `list_installed_plugins` | `/workspaces/:id/plugins` | GET | List installed plugins | +| `install_plugin` | `/workspaces/:id/plugins` | POST | Install plugin from source | +| `uninstall_plugin` | `/workspaces/:id/plugins/:name` | DELETE | Uninstall plugin | +| `list_available_plugins` | `/workspaces/:id/plugins/available` | GET | Plugins matching runtime | +| `list_plugin_registry` | `/plugins` | GET | Full plugin registry | +| `list_plugin_sources` | `/plugins/sources` | GET | Registered source schemes | +| `check_plugin_compatibility` | `/workspaces/:id/plugins/compatibility` | GET | Preflight check | + +### Teams / Hierarchy + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `expand_team` | `/workspaces/:id/expand` | POST | Expand team node | +| `collapse_team` | `/workspaces/:id/collapse` | POST | Collapse team node | + +### Templates & Bundles + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `list_templates` | `/templates` | GET | Available templates | +| `import_template` | `/templates/import` | POST | Import template | +| `list_org_templates` | `/org/templates` | GET | Org template list | +| `import_org` | `/org/import` | POST | Import org template | +| `export_bundle` | `/bundles/export/:id` | GET | Export workspace bundle | +| `import_bundle` | `/bundles/import` | POST | Import workspace bundle | + +### Tokens + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `list_tokens` | `/workspaces/:id/tokens` | GET | List workspace tokens | +| `create_token` | `/workspaces/:id/tokens` | POST | Create new bearer token | +| `revoke_token` | `/workspaces/:id/tokens/:id` | DELETE | Revoke specific token | + +### Monitoring + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `list_activity` | `/workspaces/:id/activity` | GET | Activity log | +| `report_activity` | `/workspaces/:id/activity` | POST | Report agent activity | +| `list_events` | `/events` | GET | Platform event stream | +| `list_traces` | `/workspaces/:id/traces` | GET | LLM traces (Langfuse) | +| `session_search` | `/workspaces/:id/session-search` | GET | Search chat sessions | + +### Approvals + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `create_approval` | `/workspaces/:id/approvals` | POST | Request approval | +| `get_workspace_approvals` | `/workspaces/:id/approvals` | GET | List approvals | +| `decide_approval` | `/workspaces/:id/approvals/:id/decide` | POST | Approve/reject | +| `list_pending_approvals` | `/approvals/pending` | GET | All pending approvals | + +### Canvas + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `get_canvas_viewport` | `/canvas/viewport` | GET | Current viewport | +| `set_canvas_viewport` | `/canvas/viewport` | PUT | Set viewport position | + +### Remote Agents + +| MCP Tool | API Route | Method | Description | +|----------|-----------|--------|-------------| +| `list_remote_agents` | `/workspaces?runtime=external` | GET | List remote agents | +| `get_remote_agent_state` | `/registry/discover/:id` | GET | Remote agent status | +| `check_remote_agent_freshness` | `/registry/heartbeat` | POST | Check if agent is alive | +| `get_remote_agent_setup_command` | (local) | — | Get setup instructions | + +## Authentication + +Most routes require a bearer token: + +```bash +curl -H "Authorization: Bearer " http://localhost:8080/workspaces +``` + +Tokens are issued on workspace registration (`POST /registry/register`) or via the token management API (`POST /workspaces/:id/tokens`). + +The MCP server handles auth automatically when configured with the correct `MOLECULE_URL`. + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| "Connection refused" | Check `MOLECULE_URL` points to running platform | +| "401 Unauthorized" | Token expired or revoked — create a new one | +| Tools not showing | Run `npx @molecule-ai/mcp-server` standalone to check for errors | +| Stale data | MCP server doesn't cache — check platform directly | diff --git a/docs/guides/token-management.md b/docs/guides/token-management.md new file mode 100644 index 00000000..b73be23e --- /dev/null +++ b/docs/guides/token-management.md @@ -0,0 +1,116 @@ +# Token Management API + +Workspace bearer tokens authenticate agents and API clients against the Molecule AI platform. Each token is scoped to a single workspace — a token from workspace A cannot access workspace B. + +## Endpoints + +All endpoints are behind `WorkspaceAuth` middleware — you need an existing valid token to manage tokens for a workspace. The first token is issued during workspace registration (`POST /registry/register`). + +### List Tokens + +``` +GET /workspaces/:id/tokens +Authorization: Bearer +``` + +Returns non-revoked tokens for the workspace. Only metadata is returned — never the plaintext or hash. + +```json +{ + "tokens": [ + { + "id": "uuid-of-token-row", + "prefix": "abc12345", + "created_at": "2026-04-16T12:00:00Z", + "last_used_at": "2026-04-16T15:30:00Z" + } + ], + "count": 1 +} +``` + +### Create Token + +``` +POST /workspaces/:id/tokens +Authorization: Bearer +``` + +Mints a new token. The plaintext is returned **exactly once** — save it immediately. + +```json +{ + "auth_token": "dGhpcyBpcyBhIHRlc3QgdG9rZW4...", + "workspace_id": "ws-uuid", + "message": "Save this token now — it cannot be retrieved again." +} +``` + +### Revoke Token + +``` +DELETE /workspaces/:id/tokens/:tokenId +Authorization: Bearer +``` + +Revokes a specific token by its database ID (from the List response). The token is immediately invalidated. + +```json +{ + "status": "revoked" +} +``` + +Returns 404 if the token doesn't exist, belongs to a different workspace, or is already revoked. + +## Token Lifecycle + +``` +Issue (register or POST /tokens) + → Active (used via Authorization: Bearer) + → Revoked (DELETE /tokens/:id or workspace deleted) +``` + +- Tokens have no expiration — they remain valid until explicitly revoked or the workspace is deleted +- On workspace deletion, all tokens are automatically revoked +- Multiple tokens can exist simultaneously per workspace (for rotation) + +## Token Rotation + +To rotate credentials without downtime: + +1. **Create** a new token: `POST /workspaces/:id/tokens` +2. **Update** your agent to use the new token +3. **Verify** the new token works (check `last_used_at` in List) +4. **Revoke** the old token: `DELETE /workspaces/:id/tokens/:oldTokenId` + +## Security Properties + +- **256-bit entropy**: Tokens are 32 random bytes, base64url-encoded (43 characters) +- **Hash-only storage**: Only `sha256(token)` is stored in the database — plaintext is never persisted +- **Workspace-scoped**: Token from workspace A cannot authenticate as workspace B +- **One-time display**: Plaintext returned only at creation — not recoverable from the database +- **Prefix for identification**: First 8 characters stored for log correlation without revealing the token + +## Bootstrap: Getting Your First Token + +The first token is issued during workspace registration: + +```bash +# 1. Create workspace +curl -X POST http://localhost:8080/workspaces \ + -H "Content-Type: application/json" \ + -d '{"name": "My Agent", "tier": 2}' + +# 2. Register (returns auth_token) +curl -X POST http://localhost:8080/registry/register \ + -H "Content-Type: application/json" \ + -d '{"workspace_id": "", "url": "http://...", "agent_card": {...}}' +# Response: {"auth_token": "...", ...} +``` + +For development, the test-token endpoint is also available (disabled in production): +```bash +curl http://localhost:8080/admin/workspaces//test-token +# Response: {"auth_token": "...", "workspace_id": "..."} +``` diff --git a/platform/internal/handlers/tokens.go b/platform/internal/handlers/tokens.go new file mode 100644 index 00000000..11836762 --- /dev/null +++ b/platform/internal/handlers/tokens.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "log" + "net/http" + "time" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth" + "github.com/gin-gonic/gin" +) + +// TokenHandler exposes user-facing token management for workspaces. +// Routes: GET/POST/DELETE /workspaces/:id/tokens (behind WorkspaceAuth). +type TokenHandler struct{} + +func NewTokenHandler() *TokenHandler { + return &TokenHandler{} +} + +type tokenListItem struct { + ID string `json:"id"` + Prefix string `json:"prefix"` + CreatedAt time.Time `json:"created_at"` + LastUsed *time.Time `json:"last_used_at"` +} + +// List returns non-revoked tokens for the workspace (prefix + metadata only, +// never the plaintext or hash). +func (h *TokenHandler) List(c *gin.Context) { + workspaceID := c.Param("id") + + rows, err := db.DB.QueryContext(c.Request.Context(), ` + SELECT id, prefix, created_at, last_used_at + FROM workspace_auth_tokens + WHERE workspace_id = $1 AND revoked_at IS NULL + ORDER BY created_at DESC + `, workspaceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens"}) + return + } + defer rows.Close() + + tokens := []tokenListItem{} + for rows.Next() { + var t tokenListItem + if err := rows.Scan(&t.ID, &t.Prefix, &t.CreatedAt, &t.LastUsed); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan token"}) + return + } + tokens = append(tokens, t) + } + + c.JSON(http.StatusOK, gin.H{ + "tokens": tokens, + "count": len(tokens), + }) +} + +// Create mints a new token for the workspace. The plaintext is returned +// exactly once in the response — it cannot be recovered afterwards. +func (h *TokenHandler) Create(c *gin.Context) { + workspaceID := c.Param("id") + + token, err := wsauth.IssueToken(c.Request.Context(), db.DB, workspaceID) + if err != nil { + log.Printf("tokens: issue failed for %s: %v", workspaceID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"}) + return + } + + log.Printf("tokens: issued new token for workspace %s", workspaceID) + + c.JSON(http.StatusCreated, gin.H{ + "auth_token": token, + "workspace_id": workspaceID, + "message": "Save this token now — it cannot be retrieved again.", + }) +} + +// Revoke invalidates a specific token by ID. The token ID is the database +// row ID visible from List, not the plaintext token itself. +func (h *TokenHandler) Revoke(c *gin.Context) { + workspaceID := c.Param("id") + tokenID := c.Param("tokenId") + + result, err := db.DB.ExecContext(c.Request.Context(), ` + UPDATE workspace_auth_tokens + SET revoked_at = now() + WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL + `, tokenID, workspaceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke token"}) + return + } + + rows, _ := result.RowsAffected() + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "token not found or already revoked"}) + return + } + + log.Printf("tokens: revoked token %s for workspace %s", tokenID, workspaceID) + c.JSON(http.StatusOK, gin.H{"status": "revoked"}) +} diff --git a/platform/internal/handlers/tokens_test.go b/platform/internal/handlers/tokens_test.go new file mode 100644 index 00000000..1b62e106 --- /dev/null +++ b/platform/internal/handlers/tokens_test.go @@ -0,0 +1,200 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth" + "github.com/gin-gonic/gin" +) + +func init() { gin.SetMode(gin.TestMode) } + +// setupTokenTestDB creates an in-memory SQLite-like test or returns early +// if the real Postgres test DB is available. For unit tests we use the +// package-level db.DB which handlers rely on. +func setupTokenTestDB(t *testing.T) func() { + t.Helper() + if db.DB == nil { + t.Skip("db.DB not initialised — run with a test database") + } + // Quick probe — if the DB is closed or unreachable, skip. + if err := db.DB.Ping(); err != nil { + t.Skipf("db.DB not reachable: %v", err) + } + return func() {} +} + +func TestTokenHandler_CreateAndList(t *testing.T) { + cleanup := setupTokenTestDB(t) + defer cleanup() + + // Create a test workspace first + wsID := createTestWorkspace(t) + defer deleteTestWorkspace(t, wsID) + + h := NewTokenHandler() + + // Create a token + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsID}} + c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/tokens", nil) + h.Create(c) + + if w.Code != http.StatusCreated { + t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var createResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &createResp) + if createResp["auth_token"] == nil || createResp["auth_token"] == "" { + t.Fatal("Create: auth_token missing from response") + } + if createResp["workspace_id"] != wsID { + t.Errorf("Create: workspace_id mismatch: got %v", createResp["workspace_id"]) + } + + // List tokens + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + c2.Params = gin.Params{{Key: "id", Value: wsID}} + c2.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/tokens", nil) + h.List(c2) + + if w2.Code != http.StatusOK { + t.Fatalf("List: expected 200, got %d: %s", w2.Code, w2.Body.String()) + } + + var listResp struct { + Tokens []map[string]interface{} `json:"tokens"` + Count int `json:"count"` + } + json.Unmarshal(w2.Body.Bytes(), &listResp) + if listResp.Count < 1 { + t.Errorf("List: expected at least 1 token, got %d", listResp.Count) + } + + // Verify token has prefix but NOT the full plaintext + tok := listResp.Tokens[0] + if tok["prefix"] == nil || tok["prefix"] == "" { + t.Error("List: prefix missing") + } + if tok["id"] == nil { + t.Error("List: id missing") + } + if _, hasAuth := tok["auth_token"]; hasAuth { + t.Error("List: auth_token should NOT be in list response") + } +} + +func TestTokenHandler_Revoke(t *testing.T) { + cleanup := setupTokenTestDB(t) + defer cleanup() + + wsID := createTestWorkspace(t) + defer deleteTestWorkspace(t, wsID) + + // Issue a token directly + token, err := wsauth.IssueToken(context.Background(), db.DB, wsID) + if err != nil { + t.Fatalf("IssueToken: %v", err) + } + _ = token // we don't need the plaintext, just the DB row + + // Find the token ID + var tokenID string + err = db.DB.QueryRow(` + SELECT id FROM workspace_auth_tokens + WHERE workspace_id = $1 AND revoked_at IS NULL + ORDER BY created_at DESC LIMIT 1 + `, wsID).Scan(&tokenID) + if err != nil { + t.Fatalf("find token: %v", err) + } + + h := NewTokenHandler() + + // Revoke it + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: wsID}, {Key: "tokenId", Value: tokenID}} + c.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsID+"/tokens/"+tokenID, nil) + h.Revoke(c) + + if w.Code != http.StatusOK { + t.Fatalf("Revoke: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify it's actually revoked + var revokedAt sql.NullTime + db.DB.QueryRow(`SELECT revoked_at FROM workspace_auth_tokens WHERE id = $1`, tokenID).Scan(&revokedAt) + if !revokedAt.Valid { + t.Error("Revoke: revoked_at should be set") + } + + // Revoking again should 404 + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + c2.Params = gin.Params{{Key: "id", Value: wsID}, {Key: "tokenId", Value: tokenID}} + c2.Request = httptest.NewRequest("DELETE", "/workspaces/"+wsID+"/tokens/"+tokenID, nil) + h.Revoke(c2) + + if w2.Code != http.StatusNotFound { + t.Errorf("Revoke again: expected 404, got %d", w2.Code) + } +} + +func TestTokenHandler_RevokeWrongWorkspace(t *testing.T) { + cleanup := setupTokenTestDB(t) + defer cleanup() + + wsID := createTestWorkspace(t) + defer deleteTestWorkspace(t, wsID) + + wsauth.IssueToken(context.Background(), db.DB, wsID) + + var tokenID string + db.DB.QueryRow(` + SELECT id FROM workspace_auth_tokens + WHERE workspace_id = $1 AND revoked_at IS NULL LIMIT 1 + `, wsID).Scan(&tokenID) + + h := NewTokenHandler() + + // Try to revoke with a different workspace ID — should 404 + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "wrong-workspace-id"}, {Key: "tokenId", Value: tokenID}} + c.Request = httptest.NewRequest("DELETE", "/workspaces/wrong/tokens/"+tokenID, nil) + h.Revoke(c) + + if w.Code != http.StatusNotFound { + t.Errorf("Revoke wrong workspace: expected 404, got %d", w.Code) + } +} + +// createTestWorkspace inserts a minimal workspace row for testing. +func createTestWorkspace(t *testing.T) string { + t.Helper() + var id string + err := db.DB.QueryRow(` + INSERT INTO workspaces (name, status, tier) VALUES ('test-token-ws', 'online', 2) + RETURNING id + `).Scan(&id) + if err != nil { + t.Fatalf("create test workspace: %v", err) + } + return id +} + +func deleteTestWorkspace(t *testing.T, id string) { + t.Helper() + db.DB.Exec(`DELETE FROM workspace_auth_tokens WHERE workspace_id = $1`, id) + db.DB.Exec(`DELETE FROM workspaces WHERE id = $1`, id) +} diff --git a/platform/internal/router/router.go b/platform/internal/router/router.go index 24573dc3..5a76f640 100644 --- a/platform/internal/router/router.go +++ b/platform/internal/router/router.go @@ -256,6 +256,12 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi // (mirrors the /workspaces/:id/a2a pattern). Issue #249. r.GET("/workspaces/:id/schedules/health", schedh.Health) + // Token management (user-facing create/list/revoke) + tokh := handlers.NewTokenHandler() + wsAuth.GET("/tokens", tokh.List) + wsAuth.POST("/tokens", tokh.Create) + wsAuth.DELETE("/tokens/:tokenId", tokh.Revoke) + // Memory memh := handlers.NewMemoryHandler() wsAuth.GET("/memory", memh.List)