feat(platform): token management API + MCP setup + external agent guide

1. Token Management API (closes production gap):
   - GET /workspaces/:id/tokens — list tokens (prefix + metadata, never plaintext)
   - POST /workspaces/:id/tokens — create new token (plaintext returned once)
   - DELETE /workspaces/:id/tokens/:tokenId — revoke specific token
   - Behind WorkspaceAuth middleware (need existing token to manage tokens)
   - Tests skip gracefully when no DB available

2. MCP Server Setup:
   - Fix .mcp.json to use npx @molecule-ai/mcp-server (was referencing
     non-existent local ./mcp-server/dist/index.js)
   - Add comprehensive tool→API mapping doc (87 tools across 15 categories)

3. External Agent Registration Guide:
   - Step-by-step: create workspace, register, heartbeat, A2A messaging
   - Python (Flask) and Node.js (Express) complete working examples
   - Communication rules, lifecycle, security, troubleshooting

4. Token Management Guide:
   - Bootstrap flow, rotation procedure, security properties

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-16 08:37:42 -07:00
parent 15abfca106
commit 3892e4dee1
7 changed files with 1459 additions and 2 deletions

View File

@ -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"
}

View File

@ -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 <admin-token>" \
-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": "<workspace-id-from-step-1>",
"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 <auth_token>" \
-d '{
"workspace_id": "<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/<target-workspace-id>/a2a \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <auth_token>" \
-H "X-Workspace-ID: <your-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 <token>` -- your workspace auth token.
- `X-Workspace-ID: <your-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/<your-workspace-id> \
-H "Authorization: Bearer <auth_token>" \
-H "X-Workspace-ID: <your-workspace-id>"
# List peers (siblings + parent + children)
curl http://localhost:8080/registry/<your-workspace-id>/peers \
-H "Authorization: Bearer <auth_token>" \
-H "X-Workspace-ID: <your-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 <token>` |
| `POST /registry/update-card` | `Authorization: Bearer <token>` |
| `POST /workspaces/:target/a2a` | `Authorization: Bearer <token>`, `X-Workspace-ID: <your-id>` |
| `GET /registry/discover/:id` | `Authorization: Bearer <token>`, `X-Workspace-ID: <your-id>` |
| `GET /registry/:id/peers` | `Authorization: Bearer <token>`, `X-Workspace-ID: <your-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 <auth_token>" \
-d '{
"workspace_id": "<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 <token>`. 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/<your-id>/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 <your-url> -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.

View File

@ -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 <token>" 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 |

View File

@ -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 <token>
```
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 <token>
```
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 <token>
```
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": "<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/<id>/test-token
# Response: {"auth_token": "...", "workspace_id": "..."}
```

View File

@ -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"})
}

View File

@ -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)
}

View File

@ -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)