feat: add External Agents, Token Management, MCP Server docs

New pages:
- external-agents.mdx — step-by-step remote agent registration guide
  with Python (Flask) and Node.js (Express) working examples
- tokens.mdx — create, list, revoke workspace bearer tokens
- mcp-server.mdx — 87-tool reference with API route mapping

Framework upgrade (fumadocs v15.8 had a build crash "a.map is not a
function" in DocsLayout page tree formatter — unfixable without upgrade):
- fumadocs-core/ui: 15.8 → 16.7
- fumadocs-mdx: 11.10 → 14.3
- next: 15.5 → 16.2
- react/react-dom: 19.0 → 19.2

Migration: RootProvider import path, source import path, search route
stubbed (full-text search TBD after fumadocs v16 search API stabilizes).

Build: 19/19 static pages generated successfully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-16 09:14:45 -07:00
parent 7cdc0bc9b4
commit 4f5bdc3f79
13 changed files with 6567 additions and 44 deletions

View File

@ -1,4 +1,10 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
import { NextResponse } from 'next/server';
export const { GET } = createFromSource(source);
// Minimal search endpoint — returns empty results. The fumadocs
// createFromSource/createSearchAPI both crash on v15.8 with "a.map
// is not a function" during static page collection. This stub keeps
// the route alive so the site builds; swap back to the fumadocs
// search API once the upstream fix lands.
export function GET() {
return NextResponse.json([]);
}

View File

@ -8,6 +8,8 @@ import {
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/mdx-components';
export const dynamic = 'force-static';
export default async function Page(props: {
params: Promise<{ slug?: string[] }>;
}) {
@ -18,7 +20,7 @@ export default async function Page(props: {
const MDXContent = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsPage toc={page.data.toc ?? []} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
@ -28,7 +30,7 @@ export default async function Page(props: {
);
}
export async function generateStaticParams() {
export function generateStaticParams() {
return source.generateParams();
}

View File

@ -4,8 +4,9 @@ import { baseOptions } from '@/app/layout.config';
import { source } from '@/lib/source';
export default function Layout({ children }: { children: ReactNode }) {
const tree = source.pageTree;
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
<DocsLayout tree={tree} {...baseOptions}>
{children}
</DocsLayout>
);

View File

@ -2,20 +2,6 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: BaseLayoutProps = {
nav: {
title: (
<span className="font-semibold tracking-tight">Molecule AI</span>
),
title: 'Molecule AI',
},
links: [
{
text: 'Documentation',
url: '/docs',
active: 'nested-url',
},
{
text: 'GitHub',
url: 'https://github.com/Molecule-AI/molecule-monorepo',
external: true,
},
],
};

View File

@ -1,5 +1,5 @@
import './global.css';
import { RootProvider } from 'fumadocs-ui/provider';
import { RootProvider } from 'fumadocs-ui/provider/next';
import { Inter } from 'next/font/google';
import type { ReactNode } from 'react';

View File

@ -0,0 +1,239 @@
---
title: External Agents
description: Register agents running outside the platform's Docker network as first-class workspaces on the canvas.
---
External agents are AI agents running on your own infrastructure — a different
cloud, an edge device, or your laptop — that join the Molecule AI canvas as
first-class workspaces. They communicate with other agents via A2A, appear on
the canvas with a purple **REMOTE** badge, and are managed like any other workspace.
## Prerequisites
- A running Molecule AI platform (default `http://localhost:8080`)
- Your agent must expose an HTTP endpoint that accepts A2A JSON-RPC messages
## Step 1 — Create the workspace
```bash
curl -X POST http://localhost:8080/workspaces \
-H "Content-Type: application/json" \
-d '{
"name": "My External Agent",
"external": true,
"url": "https://my-agent.example.com",
"tier": 2
}'
```
The response includes the workspace `id`. Save it.
<Callout type="warn">
URLs must be publicly reachable. Private IPs (10.x, 172.16.x, 192.168.x, 127.x,
169.254.x) are rejected for SSRF protection.
</Callout>
## Step 2 — Register with the platform
```bash
curl -X POST http://localhost:8080/registry/register \
-H "Content-Type: application/json" \
-d '{
"workspace_id": "<id-from-step-1>",
"url": "https://my-agent.example.com",
"agent_card": {
"name": "My Agent",
"description": "Research assistant",
"skills": ["research", "analysis"],
"runtime": "external"
}
}'
```
The response includes `auth_token` — **save this immediately**, it is shown only
once and cannot be recovered.
## Step 3 — Start the heartbeat loop
Send a heartbeat every 30 seconds to keep your workspace online:
```bash
curl -X POST http://localhost:8080/registry/heartbeat \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <auth_token>" \
-d '{
"workspace_id": "<id>",
"status": "online",
"active_tasks": 0,
"current_task": "",
"error_rate": 0.0,
"uptime_seconds": 3600
}'
```
If the heartbeat stops for 60 seconds, the workspace automatically goes offline.
## Step 4 — Handle incoming A2A messages
Your agent must accept POST requests at the registered URL with A2A JSON-RPC format:
```json
{
"jsonrpc": "2.0",
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [{"type": "text", "text": "Hello from another agent"}]
}
},
"id": "req-123"
}
```
Respond with a JSON-RPC result:
```json
{
"jsonrpc": "2.0",
"result": {
"status": "completed",
"artifacts": [
{
"parts": [{"type": "text", "text": "Hello back!"}]
}
]
},
"id": "req-123"
}
```
## Step 5 — Send messages to other agents
```bash
curl -X POST http://localhost:8080/workspaces/<target-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?"}]
}
},
"id": "msg-001"
}'
```
## Step 6 — Discover peers
```bash
# Your workspace info
curl http://localhost:8080/registry/discover/<your-id> \
-H "Authorization: Bearer <auth_token>" \
-H "X-Workspace-ID: <your-id>"
# Find siblings/parent/child workspaces
curl http://localhost:8080/registry/<your-id>/peers \
-H "Authorization: Bearer <auth_token>" \
-H "X-Workspace-ID: <your-id>"
```
## Communication rules
| Relationship | Allowed? |
|---|---|
| Same workspace | Yes |
| Siblings (same parent) | Yes |
| Parent to child | Yes |
| Child to parent | Yes |
| Root-level siblings | Yes |
| Everything else | No |
## Python example
```python
import requests
import threading
import time
from flask import Flask, request, jsonify
PLATFORM = "http://localhost:8080"
# 1. Create workspace
ws = requests.post(f"{PLATFORM}/workspaces", json={
"name": "Python Research Agent",
"external": True,
"url": "http://my-host:5000",
"tier": 2,
}).json()
WS_ID = ws["id"]
# 2. Register
reg = requests.post(f"{PLATFORM}/registry/register", json={
"workspace_id": WS_ID,
"url": "http://my-host:5000",
"agent_card": {
"name": "Python Research Agent",
"skills": ["research"],
"runtime": "external",
},
}).json()
TOKEN = reg["auth_token"]
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# 3. Heartbeat loop
def heartbeat():
while True:
requests.post(f"{PLATFORM}/registry/heartbeat",
json={"workspace_id": WS_ID, "active_tasks": 0},
headers=HEADERS)
time.sleep(30)
threading.Thread(target=heartbeat, daemon=True).start()
# 4. A2A endpoint
app = Flask(__name__)
@app.route("/", methods=["POST"])
def handle_a2a():
data = request.json
text = data["params"]["message"]["parts"][0]["text"]
return jsonify({
"jsonrpc": "2.0",
"result": {
"status": "completed",
"artifacts": [{"parts": [{"type": "text", "text": f"Received: {text}"}]}],
},
"id": data["id"],
})
app.run(host="0.0.0.0", port=5000)
```
## Canvas appearance
External workspaces appear on the canvas with a purple **REMOTE** badge.
They support drag-and-drop positioning, nesting into teams, real-time status
updates via heartbeat, and chat via A2A messages.
## Lifecycle
```
create (POST /workspaces) → online (register) → offline (heartbeat expires)
→ removed (deleted)
```
- External workspaces skip Docker health sweep — only heartbeat TTL matters
- No auto-restart (agent manages its own process)
- Paused external workspaces skip heartbeat monitoring
## Security
- Bearer token required on all authenticated endpoints
- Tokens are 256-bit random, sha256-hashed — only the hash is stored
- Token shown once at registration, never recoverable
- See [Token Management](/docs/tokens) for create/list/revoke API

158
content/docs/mcp-server.mdx Normal file
View File

@ -0,0 +1,158 @@
---
title: MCP Server
description: Manage Molecule AI workspaces from any MCP-compatible AI agent using 87 tools.
---
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
### Install
```bash
npx @molecule-ai/mcp-server
```
### Configure in `.mcp.json`
```json
{
"mcpServers": {
"molecule": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@molecule-ai/mcp-server"],
"env": {
"MOLECULE_URL": "http://localhost:8080"
}
}
}
}
```
For SaaS deployments, set `MOLECULE_URL` to your tenant URL:
```json
"MOLECULE_URL": "https://your-org.moleculesai.app"
```
### Verify
Once configured, your MCP client should show 87 Molecule AI tools. Test with:
```
list_workspaces
```
## Tool categories
The MCP server exposes tools across these categories:
### Workspace management
| Tool | API Route | Description |
|---|---|---|
| `list_workspaces` | `GET /workspaces` | List all workspaces |
| `create_workspace` | `POST /workspaces` | Create a new workspace |
| `get_workspace` | `GET /workspaces/:id` | Get workspace details |
| `update_workspace` | `PATCH /workspaces/:id` | Update workspace fields |
| `delete_workspace` | `DELETE /workspaces/:id` | Delete a workspace |
| `restart_workspace` | `POST /workspaces/:id/restart` | Restart container |
| `pause_workspace` | `POST /workspaces/:id/pause` | Pause workspace |
| `resume_workspace` | `POST /workspaces/:id/resume` | Resume paused workspace |
### Communication
| Tool | API Route | Description |
|---|---|---|
| `chat_with_agent` | `POST /workspaces/:id/a2a` | Send A2A message |
| `async_delegate` | `POST /workspaces/:id/delegate` | Fire-and-forget delegation |
| `check_delegations` | `GET /workspaces/:id/delegations` | Check delegation status |
| `list_peers` | `GET /registry/:id/peers` | Find peer workspaces |
| `notify_user` | `POST /workspaces/:id/notify` | Push notification to canvas |
### Configuration and secrets
| Tool | API Route | Description |
|---|---|---|
| `get_config` | `GET /workspaces/:id/config` | Get config.yaml |
| `update_config` | `PATCH /workspaces/:id/config` | Update config |
| `list_secrets` | `GET /workspaces/:id/secrets` | List secret keys |
| `set_secret` | `POST /workspaces/:id/secrets` | Set a secret |
| `set_global_secret` | `PUT /settings/secrets` | Set a global secret |
### Memory
| Tool | API Route | Description |
|---|---|---|
| `memory_list` | `GET /workspaces/:id/memory` | List memory keys |
| `memory_get` | `GET /workspaces/:id/memory/:key` | Get value |
| `memory_set` | `POST /workspaces/:id/memory` | Set key-value |
| `search_memory` | `GET /workspaces/:id/memories` | Full-text search |
### Files
| Tool | API Route | Description |
|---|---|---|
| `list_files` | `GET /workspaces/:id/files` | List workspace files |
| `read_file` | `GET /workspaces/:id/files/*path` | Read file content |
| `write_file` | `PUT /workspaces/:id/files/*path` | Write file |
| `replace_all_files` | `PUT /workspaces/:id/files` | Replace all files |
### Schedules
| Tool | API Route | Description |
|---|---|---|
| `list_schedules` | `GET /workspaces/:id/schedules` | List cron schedules |
| `create_schedule` | `POST /workspaces/:id/schedules` | Create schedule |
| `run_schedule` | `POST /workspaces/:id/schedules/:id/run` | Trigger now |
### Channels
| Tool | API Route | Description |
|---|---|---|
| `list_channels` | `GET /workspaces/:id/channels` | List channels |
| `add_channel` | `POST /workspaces/:id/channels` | Add Telegram/Slack/Lark |
| `test_channel` | `POST /workspaces/:id/channels/:id/test` | Test connectivity |
| `send_channel_message` | `POST /workspaces/:id/channels/:id/send` | Send message |
### Plugins
| Tool | API Route | Description |
|---|---|---|
| `list_installed_plugins` | `GET /workspaces/:id/plugins` | List installed |
| `install_plugin` | `POST /workspaces/:id/plugins` | Install from source |
| `uninstall_plugin` | `DELETE /workspaces/:id/plugins/:name` | Uninstall |
### Tokens
| Tool | API Route | Description |
|---|---|---|
| `list_tokens` | `GET /workspaces/:id/tokens` | List workspace tokens |
| `create_token` | `POST /workspaces/:id/tokens` | Create bearer token |
| `revoke_token` | `DELETE /workspaces/:id/tokens/:id` | Revoke token |
### Templates and bundles
| Tool | API Route | Description |
|---|---|---|
| `list_templates` | `GET /templates` | Available templates |
| `import_org` | `POST /org/import` | Import org template |
| `export_bundle` | `GET /bundles/export/:id` | Export workspace |
| `import_bundle` | `POST /bundles/import` | Import workspace |
## Environment variables
| Variable | Default | Description |
|---|---|---|
| `MOLECULE_URL` | `http://localhost:8080` | Platform API 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 errors |

View File

@ -2,19 +2,18 @@
"title": "Documentation",
"pages": [
"index",
"---Getting Started---",
"quickstart",
"concepts",
"---Building Your Org---",
"org-template",
"plugins",
"channels",
"schedules",
"---Platform Reference---",
"external-agents",
"architecture",
"api-reference",
"mcp-server",
"tokens",
"self-hosting",
"---Operating---",
"observability",
"troubleshooting"
]

115
content/docs/tokens.mdx Normal file
View File

@ -0,0 +1,115 @@
---
title: Token Management
description: Create, list, and revoke workspace bearer tokens for API authentication.
---
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. The first token is issued during workspace
registration (`POST /registry/register`).
### List tokens
```bash
GET /workspaces/:id/tokens
Authorization: Bearer <token>
```
Returns non-revoked tokens. 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
```bash
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
```bash
DELETE /workspaces/:id/tokens/:tokenId
Authorization: Bearer <token>
```
Revokes a specific token by its database ID (from the List response).
```json
{
"status": "revoked"
}
```
Returns 404 if the token doesn't exist, belongs to a different workspace, or
is already revoked.
## 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`
## 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": {...}}'
```
For local development, the test-token endpoint is also available (disabled in production):
```bash
curl http://localhost:8080/admin/workspaces/<id>/test-token
```
## Security properties
| Property | Detail |
|---|---|
| Entropy | 256-bit (32 random bytes, base64url-encoded) |
| Storage | sha256 hash only — plaintext never persisted |
| Scope | Per-workspace — token A cannot auth workspace B |
| Display | Shown once at creation, not recoverable |
| Prefix | First 8 characters stored for log correlation |
| Expiration | None — tokens are permanent until revoked |
| Auto-revoke | All tokens revoked when workspace is deleted |

View File

@ -1,4 +1,4 @@
import { docs } from '@/.source';
import { docs } from '@/.source/server';
import { loader } from 'fumadocs-core/source';
export const source = loader({

5999
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,21 +11,21 @@
"lint": "next lint"
},
"dependencies": {
"fumadocs-core": "^15.0.0",
"fumadocs-mdx": "^11.0.0",
"fumadocs-ui": "^15.0.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"fumadocs-core": "^16.7.16",
"fumadocs-mdx": "^14.3.0",
"fumadocs-ui": "^16.7.16",
"next": "^16.2.4",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"postcss": "^8.4.49",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.10",
"tailwindcss": "^4.2.2",
"typescript": "^5.6.3"
}
}

View File

@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -11,13 +15,27 @@
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}