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:
parent
15abfca106
commit
3892e4dee1
@ -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"
|
||||
}
|
||||
|
||||
784
docs/guides/external-agent-registration.md
Normal file
784
docs/guides/external-agent-registration.md
Normal 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.
|
||||
245
docs/guides/mcp-server-setup.md
Normal file
245
docs/guides/mcp-server-setup.md
Normal 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 |
|
||||
116
docs/guides/token-management.md
Normal file
116
docs/guides/token-management.md
Normal 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": "..."}
|
||||
```
|
||||
106
platform/internal/handlers/tokens.go
Normal file
106
platform/internal/handlers/tokens.go
Normal 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"})
|
||||
}
|
||||
200
platform/internal/handlers/tokens_test.go
Normal file
200
platform/internal/handlers/tokens_test.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user