feat: MCP server content + npm publish CI

This commit is contained in:
Hongming Wang 2026-04-16 03:50:00 -07:00
parent a1cef41f85
commit aa44c6b565
21 changed files with 8768 additions and 0 deletions

16
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Publish to npm
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', registry-url: 'https://registry.npmjs.org' }
- run: npm install
- run: npm run build
- run: npm test
- run: npm publish --access public
env: { NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' }

107
README.md Normal file
View File

@ -0,0 +1,107 @@
# Molecule AI MCP Server
MCP server that exposes Molecule AI platform operations as tools for AI coding agents.
## 20 Tools Available
| Tool | Description |
|------|-------------|
| `list_workspaces` | List all workspaces with status and skills |
| `create_workspace` | Create a new workspace (with optional template) |
| `get_workspace` | Get workspace details |
| `delete_workspace` | Delete workspace (cascades to children) |
| `restart_workspace` | Restart offline/failed workspace |
| `chat_with_agent` | Send message and get AI response |
| `assign_agent` | Assign model to workspace |
| `set_secret` | Set API key or env var |
| `list_secrets` | List secret keys (no values) |
| `list_files` | List workspace config files |
| `read_file` | Read a config file |
| `write_file` | Create or update a file |
| `delete_file` | Delete file or folder |
| `commit_memory` | Store fact (LOCAL/TEAM/GLOBAL) |
| `search_memory` | Search workspace memories |
| `list_templates` | List available templates |
| `expand_team` | Expand workspace to team |
| `collapse_team` | Collapse team to single workspace |
| `list_pending_approvals` | List pending approval requests |
| `decide_approval` | Approve or deny a request |
### Phase 30 — Remote agent (SaaS) management
Tools that surface workspaces with `runtime='external'` (agents that run on
machines outside this platform's Docker network and join via HTTP).
| Tool | Description |
|------|-------------|
| `list_remote_agents` | Filter the workspace list to remote agents only — id / status / url / heartbeat |
| `get_remote_agent_state` | Lightweight `{status, paused, deleted}` projection — faster than `get_workspace` when you only need lifecycle |
| `get_remote_agent_setup_command` | Emit a `WORKSPACE_ID=… PLATFORM_URL=… python3 …` bash one-liner an operator can paste into a remote shell |
| `check_remote_agent_freshness` | Compare `last_heartbeat_at` against a threshold (default 90s) — returns `{fresh, seconds_since_heartbeat}` |
## Setup
### Claude Code
Add to your project's `.mcp.json`:
```json
{
"mcpServers": {
"molecule": {
"command": "node",
"args": ["./mcp-server/dist/index.js"],
"env": {
"MOLECULE_URL": "http://localhost:8080"
}
}
}
}
```
### Cursor
Add to `.cursor/mcp.json`:
```json
{
"mcpServers": {
"molecule": {
"command": "node",
"args": ["./mcp-server/dist/index.js"],
"env": {
"MOLECULE_URL": "http://localhost:8080"
}
}
}
}
```
### Codex / OpenCode
```bash
# Run directly
MOLECULE_URL=http://localhost:8080 node mcp-server/dist/index.js
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MOLECULE_URL` | `http://localhost:8080` | Platform API URL |
## Examples
```
You: "Create an SEO agent workspace using the seo-agent template"
Agent: [calls create_workspace with template="seo-agent"]
You: "Set the OpenRouter API key for the SEO workspace"
Agent: [calls set_secret with key="OPENROUTER_API_KEY"]
You: "Ask the SEO agent to audit my homepage"
Agent: [calls chat_with_agent with message="Audit https://example.com for SEO"]
You: "What skills does the coding agent have?"
Agent: [calls get_workspace, reads agent_card.skills]
```

31
jest.config.cjs Normal file
View File

@ -0,0 +1,31 @@
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/__tests__/**/*.test.ts"],
moduleNameMapper: {
// Strip .js extensions from imports so ts-jest can resolve .ts files
"^(\\.{1,2}/.*)\\.js$": "$1",
// Map ESM-only MCP SDK imports to their CJS equivalents
"^@modelcontextprotocol/sdk/server/mcp\\.js$":
"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js",
"^@modelcontextprotocol/sdk/server/stdio\\.js$":
"<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/server/stdio.js",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
tsconfig: {
module: "CommonJS",
moduleResolution: "node",
esModuleInterop: true,
strict: true,
target: "ES2022",
isolatedModules: true,
},
diagnostics: false,
},
],
},
};

5559
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "@molecule-ai/mcp-server",
"version": "1.0.0",
"description": "MCP server for Molecule AI Agent Team \u2014 manage workspaces, agents, and skills from any AI coding tool",
"type": "module",
"bin": {
"molecule-mcp": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"test": "jest"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^20.0.0",
"jest": "^30.3.0",
"ts-jest": "^29.4.9",
"typescript": "^5.5.0"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/Molecule-AI/molecule-mcp-server.git"
}
}

1147
src/__tests__/index.test.ts Normal file

File diff suppressed because it is too large Load Diff

66
src/api.ts Normal file
View File

@ -0,0 +1,66 @@
// Prefer MOLECULE_URL (the canonical MCP env var), fall back to PLATFORM_URL
// (what the workspace runtime already injects for heartbeat/register), and
// only then to localhost:8080. Injecting MOLECULE_URL at container provision
// is handled by platform/internal/provisioner/provisioner.go; this fallback
// chain protects older containers and host-side users alike. Fixes #67.
export const PLATFORM_URL =
process.env.MOLECULE_URL ||
process.env.PLATFORM_URL ||
"http://localhost:8080";
/**
* Shape returned by apiCall when the request fails (network error, non-2xx,
* or non-JSON body with no error). Returned-by-value apiCall never throws.
*/
export type ApiError = { error: string; detail?: string; raw?: string; status?: number };
export function isApiError(v: unknown): v is ApiError {
return !!v && typeof v === "object" && "error" in (v as object);
}
/**
* Wrap arbitrary JSON-serialisable data in the MCP content envelope that
* tool handlers must return. Centralised so every handler uses the exact
* same shape (and a future switch to e.g. structured content happens once).
*/
export function toMcpResult(data: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}
/**
* Wrap a plain string (file contents, assistant reply text, error message)
* in the MCP content envelope without JSON-stringifying it. For the handful
* of handlers that return raw text rather than a JSON blob.
*/
export function toMcpText(text: string) {
return { content: [{ type: "text" as const, text }] };
}
export async function apiCall<T = unknown>(
method: string,
path: string,
body?: unknown,
): Promise<T | ApiError> {
try {
const res = await fetch(`${PLATFORM_URL}${path}`, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
return { error: `HTTP ${res.status}`, detail: text };
}
const text = await res.text();
try {
return JSON.parse(text) as T;
} catch {
return { raw: text, status: res.status } as ApiError;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// stdio MCP servers must log to stderr; stdout is the protocol channel.
console.error(`Molecule AI API error (${method} ${path}): ${msg}`);
return { error: `Platform unreachable at ${PLATFORM_URL}`, detail: msg };
}
}

216
src/index.ts Normal file
View File

@ -0,0 +1,216 @@
#!/usr/bin/env node
/**
* Molecule AI MCP Server
*
* Exposes Molecule AI platform operations as MCP tools so any AI coding agent
* (Claude Code, Cursor, Codex, OpenCode) can manage workspaces, agents,
* skills, and memory.
*
* Transport: stdio (for local CLI integration)
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { PLATFORM_URL, apiCall } from "./api.js";
import { registerWorkspaceTools } from "./tools/workspaces.js";
import { registerAgentTools } from "./tools/agents.js";
import { registerSecretTools } from "./tools/secrets.js";
import { registerFileTools } from "./tools/files.js";
import { registerMemoryTools } from "./tools/memory.js";
import { registerPluginTools } from "./tools/plugins.js";
import { registerChannelTools } from "./tools/channels.js";
import { registerDelegationTools } from "./tools/delegation.js";
import { registerScheduleTools } from "./tools/schedules.js";
import { registerApprovalTools } from "./tools/approvals.js";
import { registerDiscoveryTools } from "./tools/discovery.js";
import { registerRemoteAgentTools } from "./tools/remote_agents.js";
// Re-exports so existing importers (tests, SDK consumers) keep working.
// Explicit names (not `export *`) so tree-shakers and TS readers can see
// exactly which handlers are part of the public surface, and a missing
// export triggers a compile error instead of a silent undefined at import.
export { PLATFORM_URL, apiCall, isApiError, toMcpResult, toMcpText } from "./api.js";
export type { ApiError } from "./api.js";
export {
registerWorkspaceTools,
handleListWorkspaces,
handleCreateWorkspace,
handleGetWorkspace,
handleDeleteWorkspace,
handleRestartWorkspace,
handleUpdateWorkspace,
handlePauseWorkspace,
handleResumeWorkspace,
} from "./tools/workspaces.js";
export {
registerAgentTools,
handleChatWithAgent,
handleAssignAgent,
handleReplaceAgent,
handleRemoveAgent,
handleMoveAgent,
handleGetModel,
} from "./tools/agents.js";
export {
registerSecretTools,
handleSetSecret,
handleListSecrets,
handleDeleteSecret,
handleListGlobalSecrets,
handleSetGlobalSecret,
handleDeleteGlobalSecret,
} from "./tools/secrets.js";
export {
registerFileTools,
handleListFiles,
handleReadFile,
handleWriteFile,
handleDeleteFile,
handleReplaceAllFiles,
handleGetConfig,
handleUpdateConfig,
} from "./tools/files.js";
export {
registerMemoryTools,
handleCommitMemory,
handleSearchMemory,
handleDeleteMemory,
handleSessionSearch,
handleGetSharedContext,
handleSetKV,
handleGetKV,
handleListKV,
handleDeleteKV,
} from "./tools/memory.js";
export {
registerPluginTools,
handleListPluginRegistry,
handleListInstalledPlugins,
handleInstallPlugin,
handleUninstallPlugin,
handleListPluginSources,
handleListAvailablePlugins,
handleCheckPluginCompatibility,
} from "./tools/plugins.js";
export {
registerChannelTools,
handleListChannelAdapters,
handleListChannels,
handleAddChannel,
handleUpdateChannel,
handleRemoveChannel,
handleSendChannelMessage,
handleTestChannel,
handleDiscoverChannelChats,
} from "./tools/channels.js";
export {
registerDelegationTools,
handleAsyncDelegate,
handleCheckDelegations,
handleRecordDelegation,
handleUpdateDelegationStatus,
handleReportActivity,
handleListActivity,
handleNotifyUser,
handleListTraces,
} from "./tools/delegation.js";
export {
registerScheduleTools,
handleListSchedules,
handleCreateSchedule,
handleUpdateSchedule,
handleDeleteSchedule,
handleRunSchedule,
handleGetScheduleHistory,
} from "./tools/schedules.js";
export {
registerApprovalTools,
handleListPendingApprovals,
handleDecideApproval,
handleCreateApproval,
handleGetWorkspaceApprovals,
} from "./tools/approvals.js";
export {
registerDiscoveryTools,
handleListPeers,
handleDiscoverWorkspace,
handleCheckAccess,
handleListEvents,
handleListTemplates,
handleListOrgTemplates,
handleImportOrg,
handleImportTemplate,
handleExportBundle,
handleImportBundle,
handleGetViewport,
handleSetViewport,
handleExpandTeam,
handleCollapseTeam,
} from "./tools/discovery.js";
export {
registerRemoteAgentTools,
handleListRemoteAgents,
handleGetRemoteAgentState,
handleGetRemoteAgentSetupCommand,
handleCheckRemoteAgentFreshness,
} from "./tools/remote_agents.js";
export function createServer() {
const srv = new McpServer({
name: "molecule",
version: "1.0.0",
});
registerWorkspaceTools(srv);
registerAgentTools(srv);
registerSecretTools(srv);
registerFileTools(srv);
registerMemoryTools(srv);
registerPluginTools(srv);
registerChannelTools(srv);
registerDelegationTools(srv);
registerScheduleTools(srv);
registerApprovalTools(srv);
registerDiscoveryTools(srv);
registerRemoteAgentTools(srv);
return srv;
}
async function main() {
// Validate platform connectivity on startup
try {
const res = await fetch(`${PLATFORM_URL}/health`);
if (res.ok) {
console.error(`Molecule AI platform connected: ${PLATFORM_URL}`);
} else {
console.error(`WARNING: Molecule AI platform at ${PLATFORM_URL} returned ${res.status}. Tools may fail.`);
}
} catch {
console.error(`WARNING: Cannot reach Molecule AI platform at ${PLATFORM_URL}. Start it with: cd platform && go run ./cmd/server`);
}
const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Molecule AI MCP server running on stdio (87 tools available)");
}
// Only auto-start when run directly (not when imported for testing).
// JEST_WORKER_ID is set automatically by Jest in every worker process.
if (!process.env.JEST_WORKER_ID) {
main().catch(console.error);
}

101
src/tools/agents.ts Normal file
View File

@ -0,0 +1,101 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult, toMcpText } from "../api.js";
export async function handleChatWithAgent(params: { workspace_id: string; message: string }) {
const { workspace_id, message } = params;
const data = await apiCall<{ result?: { parts?: Array<{ kind?: string; text?: string }> } }>(
"POST",
`/workspaces/${workspace_id}/a2a`,
{
method: "message/send",
params: {
message: { role: "user", parts: [{ type: "text", text: message }] },
},
},
);
const parts = (data as { result?: { parts?: Array<{ kind?: string; text?: string }> } } | null)?.result?.parts || [];
const text = parts
.filter((p) => p.kind === "text")
.map((p) => p.text || "")
.join("\n");
return text ? toMcpText(text) : toMcpResult(data);
}
export async function handleAssignAgent(params: { workspace_id: string; model: string }) {
const { workspace_id, model } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/agent`, { model });
return toMcpResult(data);
}
export async function handleReplaceAgent(params: { workspace_id: string; model: string }) {
const { workspace_id, model } = params;
const data = await apiCall("PATCH", `/workspaces/${workspace_id}/agent`, { model });
return toMcpResult(data);
}
export async function handleRemoveAgent(params: { workspace_id: string }) {
const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/agent`);
return toMcpResult(data);
}
export async function handleMoveAgent(params: { workspace_id: string; target_workspace_id: string }) {
const { workspace_id, target_workspace_id } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/agent/move`, { target_workspace_id });
return toMcpResult(data);
}
export async function handleGetModel(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/model`);
return toMcpResult(data);
}
export function registerAgentTools(srv: McpServer) {
srv.tool(
"chat_with_agent",
"Send a message to a workspace agent and get a response",
{
workspace_id: z.string().describe("Workspace ID"),
message: z.string().describe("Message to send"),
},
handleChatWithAgent
);
srv.tool(
"assign_agent",
"Assign an AI model to a workspace",
{
workspace_id: z.string().describe("Workspace ID"),
model: z.string().describe("Model string (e.g., openrouter:anthropic/claude-3.5-haiku)"),
},
handleAssignAgent
);
srv.tool(
"replace_agent",
"Replace the model on an existing workspace agent",
{ workspace_id: z.string(), model: z.string() },
handleReplaceAgent
);
srv.tool(
"remove_agent",
"Remove the agent from a workspace",
{ workspace_id: z.string() },
handleRemoveAgent
);
srv.tool(
"move_agent",
"Move an agent from one workspace to another",
{ workspace_id: z.string(), target_workspace_id: z.string() },
handleMoveAgent
);
srv.tool(
"get_model",
"Get current model configuration for a workspace",
{ workspace_id: z.string() },
handleGetModel
);
}

75
src/tools/approvals.ts Normal file
View File

@ -0,0 +1,75 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult } from "../api.js";
export async function handleListPendingApprovals() {
const data = await apiCall("GET", "/approvals/pending");
return toMcpResult(data);
}
export async function handleDecideApproval(params: {
workspace_id: string;
approval_id: string;
decision: "approved" | "denied";
}) {
const { workspace_id, approval_id, decision } = params;
const data = await apiCall(
"POST",
`/workspaces/${workspace_id}/approvals/${approval_id}/decide`,
{ decision, decided_by: "mcp-client" }
);
return toMcpResult(data);
}
export async function handleCreateApproval(params: {
workspace_id: string;
action: string;
reason?: string;
}) {
const { workspace_id, action, reason } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/approvals`, { action, reason });
return toMcpResult(data);
}
export async function handleGetWorkspaceApprovals(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/approvals`);
return toMcpResult(data);
}
export function registerApprovalTools(srv: McpServer) {
srv.tool(
"list_pending_approvals",
"List all pending approval requests across workspaces",
{},
handleListPendingApprovals
);
srv.tool(
"decide_approval",
"Approve or deny a pending approval request",
{
workspace_id: z.string().describe("Workspace ID"),
approval_id: z.string().describe("Approval ID"),
decision: z.enum(["approved", "denied"]).describe("Decision"),
},
handleDecideApproval
);
srv.tool(
"create_approval",
"Create an approval request for a workspace",
{
workspace_id: z.string(),
action: z.string().describe("What needs approval"),
reason: z.string().optional().describe("Why it's needed"),
},
handleCreateApproval
);
srv.tool(
"get_workspace_approvals",
"List approval requests for a specific workspace",
{ workspace_id: z.string() },
handleGetWorkspaceApprovals
);
}

142
src/tools/channels.ts Normal file
View File

@ -0,0 +1,142 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult, toMcpText } from "../api.js";
export async function handleListChannelAdapters() {
const data = await apiCall("GET", `/channels/adapters`);
return toMcpResult(data);
}
export async function handleListChannels(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/channels`);
return toMcpResult(data);
}
export async function handleAddChannel(params: {
workspace_id: string;
channel_type: string;
config: string;
allowed_users?: string;
}) {
let config: unknown;
try { config = JSON.parse(params.config); } catch { return toMcpText("Error: config is not valid JSON"); }
const allowed_users = params.allowed_users ? params.allowed_users.split(",").map((s) => s.trim()).filter(Boolean) : [];
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/channels`, {
channel_type: params.channel_type,
config,
allowed_users,
});
return toMcpResult(data);
}
export async function handleUpdateChannel(params: {
workspace_id: string;
channel_id: string;
config?: string;
enabled?: boolean;
allowed_users?: string;
}) {
const body: Record<string, unknown> = {};
if (params.config) {
try { body.config = JSON.parse(params.config); } catch { return toMcpText("Error: config is not valid JSON"); }
}
if (params.enabled !== undefined) body.enabled = params.enabled;
if (params.allowed_users !== undefined) {
body.allowed_users = params.allowed_users.split(",").map((s) => s.trim()).filter(Boolean);
}
const data = await apiCall("PATCH", `/workspaces/${params.workspace_id}/channels/${params.channel_id}`, body);
return toMcpResult(data);
}
export async function handleRemoveChannel(params: { workspace_id: string; channel_id: string }) {
const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/channels/${params.channel_id}`);
return toMcpResult(data);
}
export async function handleSendChannelMessage(params: {
workspace_id: string;
channel_id: string;
text: string;
}) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/channels/${params.channel_id}/send`, {
text: params.text,
});
return toMcpResult(data);
}
export async function handleTestChannel(params: { workspace_id: string; channel_id: string }) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/channels/${params.channel_id}/test`, {});
return toMcpResult(data);
}
export async function handleDiscoverChannelChats(params: {
type: string;
config: Record<string, unknown>;
}) {
const data = await apiCall("POST", "/channels/discover", params);
return toMcpResult(data);
}
export function registerChannelTools(srv: McpServer) {
srv.tool("list_channel_adapters", "List available social channel adapters (Telegram, Slack, etc.)", {}, handleListChannelAdapters);
srv.tool("list_channels", "List social channels connected to a workspace", {
workspace_id: z.string().describe("Workspace ID"),
}, handleListChannels);
srv.tool(
"add_channel",
"Connect a social channel (Telegram, Slack, etc.) to a workspace. Messages on the channel will be forwarded to the agent.",
{
workspace_id: z.string().describe("Workspace ID"),
channel_type: z.string().describe("Channel type (e.g., 'telegram')"),
config: z.string().describe('Channel config as JSON string (e.g., \'{"bot_token":"123:ABC","chat_id":"-100"}\')'),
allowed_users: z.string().optional().describe("Comma-separated user IDs allowed to message (empty = allow all)"),
},
handleAddChannel
);
srv.tool(
"update_channel",
"Update a social channel's config, enabled state, or allowed users. Triggers hot reload.",
{
workspace_id: z.string().describe("Workspace ID"),
channel_id: z.string().describe("Channel ID"),
config: z.string().optional().describe("Updated config as JSON string"),
enabled: z.boolean().optional().describe("Enable or disable the channel"),
allowed_users: z.string().optional().describe("Comma-separated user IDs (replaces existing list)"),
},
handleUpdateChannel
);
srv.tool("remove_channel", "Remove a social channel from a workspace", {
workspace_id: z.string().describe("Workspace ID"),
channel_id: z.string().describe("Channel ID"),
}, handleRemoveChannel);
srv.tool(
"send_channel_message",
"Send an outbound message from a workspace to its connected social channel (e.g., proactive Telegram message).",
{
workspace_id: z.string().describe("Workspace ID"),
channel_id: z.string().describe("Channel ID"),
text: z.string().describe("Message text to send"),
},
handleSendChannelMessage
);
srv.tool("test_channel", "Send a test message to verify a social channel connection works", {
workspace_id: z.string().describe("Workspace ID"),
channel_id: z.string().describe("Channel ID"),
}, handleTestChannel);
srv.tool(
"discover_channel_chats",
"Auto-detect chat IDs / channels for a given bot token (e.g. Telegram). Useful before creating a workspace channel.",
{
type: z.string().describe("Channel type (telegram, slack, etc.)"),
config: z.record(z.unknown()).describe("Adapter-specific config (bot_token, etc.)"),
},
handleDiscoverChannelChats,
);
}

183
src/tools/delegation.ts Normal file
View File

@ -0,0 +1,183 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult } from "../api.js";
export async function handleAsyncDelegate(params: {
workspace_id: string;
target_id: string;
task: string;
}) {
const { workspace_id, target_id, task } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/delegate`, { target_id, task });
return toMcpResult(data);
}
export async function handleCheckDelegations(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/delegations`);
return toMcpResult(data);
}
export async function handleRecordDelegation(params: {
workspace_id: string;
target_id: string;
task: string;
delegation_id: string;
}) {
const { workspace_id, ...body } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/delegations/record`, body);
return toMcpResult(data);
}
export async function handleUpdateDelegationStatus(params: {
workspace_id: string;
delegation_id: string;
status: "completed" | "failed";
error?: string;
response_preview?: string;
}) {
const { workspace_id, delegation_id, ...body } = params;
const data = await apiCall(
"POST",
`/workspaces/${workspace_id}/delegations/${delegation_id}/update`,
body,
);
return toMcpResult(data);
}
export async function handleReportActivity(params: {
workspace_id: string;
activity_type: string;
method?: string;
summary?: string;
status?: string;
error_detail?: string;
request_body?: unknown;
response_body?: unknown;
duration_ms?: number;
}) {
const { workspace_id, ...body } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/activity`, body);
return toMcpResult(data);
}
export async function handleListActivity(params: {
workspace_id: string;
type?: "a2a_receive" | "a2a_send" | "task_update" | "agent_log" | "error";
limit?: number;
}) {
const { workspace_id, type, limit } = params;
const urlParams = new URLSearchParams();
if (type) urlParams.set("type", type);
if (limit) urlParams.set("limit", String(limit));
const qs = urlParams.toString() ? `?${urlParams.toString()}` : "";
const data = await apiCall("GET", `/workspaces/${workspace_id}/activity${qs}`);
return toMcpResult(data);
}
export async function handleNotifyUser(params: {
workspace_id: string;
type: string;
[k: string]: unknown;
}) {
const { workspace_id, ...body } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/notify`, body);
return toMcpResult(data);
}
export async function handleListTraces(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/traces`);
return toMcpResult(data);
}
export function registerDelegationTools(srv: McpServer) {
srv.tool(
"async_delegate",
"Delegate a task to another workspace (non-blocking). Returns immediately with a delegation_id. The target workspace processes the task in the background. Use check_delegations to poll for results.",
{
workspace_id: z.string().describe("Source workspace ID (the delegator)"),
target_id: z.string().describe("Target workspace ID to delegate to"),
task: z.string().describe("Task description to send"),
},
handleAsyncDelegate
);
srv.tool(
"check_delegations",
"Check status of delegated tasks for a workspace. Returns recent delegations with their status (pending/completed/failed) and results.",
{ workspace_id: z.string().describe("Workspace ID") },
handleCheckDelegations
);
srv.tool(
"record_delegation",
"Register an agent-initiated delegation with the platform's activity log. Used by agent tooling so GET /delegations sees the same set as check_delegation_status.",
{
workspace_id: z.string().describe("Source workspace ID (the delegator)"),
target_id: z.string().describe("Target workspace ID (the delegate)"),
task: z.string().describe("Task description sent to the target"),
delegation_id: z.string().describe("Agent-generated task_id to correlate with local state"),
},
handleRecordDelegation,
);
srv.tool(
"update_delegation_status",
"Mirror an agent-initiated delegation's status to activity_logs (completed or failed).",
{
workspace_id: z.string().describe("Source workspace ID"),
delegation_id: z.string().describe("Delegation ID previously registered via record_delegation"),
status: z.enum(["completed", "failed"]),
error: z.string().optional(),
response_preview: z.string().optional().describe("Response text (truncated to 500 chars server-side)"),
},
handleUpdateDelegationStatus,
);
srv.tool(
"report_activity",
"Write an arbitrary activity log row from an agent (a2a events, tool calls, errors).",
{
workspace_id: z.string(),
activity_type: z.string().describe("a2a_receive / a2a_send / tool_call / task_complete / error / ..."),
method: z.string().optional(),
summary: z.string().optional(),
status: z.string().optional().describe("ok / error / pending"),
error_detail: z.string().optional(),
request_body: z.unknown().optional(),
response_body: z.unknown().optional(),
duration_ms: z.number().optional(),
},
handleReportActivity,
);
srv.tool(
"list_activity",
"List activity logs for a workspace (A2A communications, tasks, errors)",
{
workspace_id: z.string(),
type: z
.enum(["a2a_receive", "a2a_send", "task_update", "agent_log", "error"])
.optional()
.describe("Filter by activity type"),
limit: z.number().optional().describe("Max entries to return (default 100, max 500)"),
},
handleListActivity
);
srv.tool(
"notify_user",
"Push a notification from the agent to the canvas via WebSocket — appears as a toast / chat bubble.",
{
workspace_id: z.string(),
type: z.string().describe("Notification category (e.g. 'delegation_complete', 'approval_needed')"),
},
handleNotifyUser,
);
srv.tool(
"list_traces",
"List recent LLM traces from Langfuse for a workspace",
{ workspace_id: z.string() },
handleListTraces
);
}

173
src/tools/discovery.ts Normal file
View File

@ -0,0 +1,173 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult } from "../api.js";
export async function handleListPeers(params: { workspace_id: string }) {
const data = await apiCall("GET", `/registry/${params.workspace_id}/peers`);
return toMcpResult(data);
}
export async function handleDiscoverWorkspace(params: { workspace_id: string }) {
const data = await apiCall("GET", `/registry/discover/${params.workspace_id}`);
return toMcpResult(data);
}
export async function handleCheckAccess(params: { caller_id: string; target_id: string }) {
const { caller_id, target_id } = params;
const data = await apiCall("POST", `/registry/check-access`, { caller_id, target_id });
return toMcpResult(data);
}
export async function handleListEvents(params: { workspace_id?: string }) {
const path = params.workspace_id ? `/events/${params.workspace_id}` : "/events";
const data = await apiCall("GET", path);
return toMcpResult(data);
}
export async function handleListTemplates() {
const data = await apiCall("GET", "/templates");
return toMcpResult(data);
}
export async function handleListOrgTemplates() {
const data = await apiCall("GET", "/org/templates");
return toMcpResult(data);
}
export async function handleImportOrg(params: { dir: string }) {
const data = await apiCall("POST", "/org/import", { dir: params.dir });
return toMcpResult(data);
}
export async function handleImportTemplate(params: { name: string; files: Record<string, string> }) {
const { name, files } = params;
const data = await apiCall("POST", `/templates/import`, { name, files });
return toMcpResult(data);
}
export async function handleExportBundle(params: { workspace_id: string }) {
const data = await apiCall("GET", `/bundles/export/${params.workspace_id}`);
return toMcpResult(data);
}
export async function handleImportBundle(params: { bundle: Record<string, unknown> }) {
const data = await apiCall("POST", `/bundles/import`, params.bundle);
return toMcpResult(data);
}
export async function handleGetViewport() {
const data = await apiCall("GET", "/canvas/viewport");
return toMcpResult(data);
}
export async function handleSetViewport(params: { x: number; y: number; zoom: number }) {
const data = await apiCall("PUT", "/canvas/viewport", params);
return toMcpResult(data);
}
export async function handleExpandTeam(params: { workspace_id: string }) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/expand`, {});
return toMcpResult(data);
}
export async function handleCollapseTeam(params: { workspace_id: string }) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/collapse`, {});
return toMcpResult(data);
}
export function registerDiscoveryTools(srv: McpServer) {
srv.tool(
"list_peers",
"List reachable peer workspaces (siblings, children, parent)",
{ workspace_id: z.string() },
handleListPeers
);
srv.tool(
"discover_workspace",
"Resolve a workspace URL by ID (for A2A communication)",
{ workspace_id: z.string() },
handleDiscoverWorkspace
);
srv.tool(
"check_access",
"Check if two workspaces can communicate",
{ caller_id: z.string(), target_id: z.string() },
handleCheckAccess
);
srv.tool(
"list_events",
"List structure events (global or per workspace)",
{ workspace_id: z.string().optional().describe("Filter to workspace, or omit for all") },
handleListEvents
);
srv.tool("list_templates", "List available workspace templates", {}, handleListTemplates);
srv.tool("list_org_templates", "List available org templates", {}, handleListOrgTemplates);
srv.tool(
"import_org",
"Import an org template to create an entire workspace hierarchy",
{ dir: z.string().describe("Org template directory name (e.g., 'molecule-dev')") },
handleImportOrg
);
srv.tool(
"import_template",
"Import agent files as a new workspace template",
{
name: z.string().describe("Template name"),
files: z.record(z.string()).describe("Map of file path → content"),
},
handleImportTemplate
);
srv.tool(
"export_bundle",
"Export a workspace as a portable .bundle.json",
{ workspace_id: z.string() },
handleExportBundle
);
srv.tool(
"import_bundle",
"Import a workspace from a bundle JSON object",
{ bundle: z.record(z.unknown()).describe("Bundle JSON object") },
handleImportBundle
);
srv.tool(
"get_canvas_viewport",
"Get the current canvas viewport (x, y, zoom) persisted per-user.",
{},
handleGetViewport,
);
srv.tool(
"set_canvas_viewport",
"Persist the canvas viewport (x, y, zoom).",
{
x: z.number(),
y: z.number(),
zoom: z.number(),
},
handleSetViewport,
);
srv.tool(
"expand_team",
"Expand a workspace into a team of sub-workspaces",
{ workspace_id: z.string().describe("Workspace ID to expand") },
handleExpandTeam
);
srv.tool(
"collapse_team",
"Collapse a team back to a single workspace",
{ workspace_id: z.string().describe("Workspace ID to collapse") },
handleCollapseTeam
);
}

111
src/tools/files.ts Normal file
View File

@ -0,0 +1,111 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult, toMcpText } from "../api.js";
export async function handleListFiles(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/files`);
return toMcpResult(data);
}
export async function handleReadFile(params: { workspace_id: string; path: string }) {
const { workspace_id, path } = params;
const data = await apiCall<{ content?: string }>("GET", `/workspaces/${workspace_id}/files/${path}`);
const fileText = (data as { content?: string } | null)?.content;
return fileText ? toMcpText(fileText) : toMcpResult(data);
}
export async function handleWriteFile(params: { workspace_id: string; path: string; content: string }) {
const { workspace_id, path, content } = params;
const data = await apiCall("PUT", `/workspaces/${workspace_id}/files/${path}`, { content });
return toMcpResult(data);
}
export async function handleDeleteFile(params: { workspace_id: string; path: string }) {
const { workspace_id, path } = params;
const data = await apiCall("DELETE", `/workspaces/${workspace_id}/files/${path}`);
return toMcpResult(data);
}
export async function handleReplaceAllFiles(params: {
workspace_id: string;
files: Record<string, string>;
}) {
const { workspace_id, files } = params;
const data = await apiCall("PUT", `/workspaces/${workspace_id}/files`, { files });
return toMcpResult(data);
}
export async function handleGetConfig(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/config`);
return toMcpResult(data);
}
export async function handleUpdateConfig(params: { workspace_id: string; config: Record<string, unknown> }) {
const { workspace_id, config } = params;
const data = await apiCall("PATCH", `/workspaces/${workspace_id}/config`, config);
return toMcpResult(data);
}
export function registerFileTools(srv: McpServer) {
srv.tool(
"list_files",
"List workspace config files (skills, prompts, config.yaml)",
{ workspace_id: z.string().describe("Workspace ID") },
handleListFiles
);
srv.tool(
"read_file",
"Read a workspace config file",
{
workspace_id: z.string().describe("Workspace ID"),
path: z.string().describe("File path (e.g., system-prompt.md, skills/seo/SKILL.md)"),
},
handleReadFile
);
srv.tool(
"write_file",
"Write or create a workspace config file",
{
workspace_id: z.string().describe("Workspace ID"),
path: z.string().describe("File path"),
content: z.string().describe("File content"),
},
handleWriteFile
);
srv.tool(
"delete_file",
"Delete a workspace file or folder",
{
workspace_id: z.string().describe("Workspace ID"),
path: z.string().describe("File or folder path"),
},
handleDeleteFile
);
srv.tool(
"replace_all_files",
"Replace all workspace config files at once",
{
workspace_id: z.string(),
files: z.record(z.string()).describe("Map of file path → content"),
},
handleReplaceAllFiles
);
srv.tool(
"get_config",
"Get workspace runtime config as JSON",
{ workspace_id: z.string() },
handleGetConfig
);
srv.tool(
"update_config",
"Update workspace runtime config",
{ workspace_id: z.string(), config: z.record(z.unknown()).describe("Config fields to update") },
handleUpdateConfig
);
}

165
src/tools/memory.ts Normal file
View File

@ -0,0 +1,165 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult } from "../api.js";
export async function handleCommitMemory(params: {
workspace_id: string;
content: string;
scope: "LOCAL" | "TEAM" | "GLOBAL";
}) {
const { workspace_id, content, scope } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/memories`, { content, scope });
return toMcpResult(data);
}
export async function handleSearchMemory(params: {
workspace_id: string;
query?: string;
scope?: "LOCAL" | "TEAM" | "GLOBAL" | "";
}) {
const { workspace_id, query, scope } = params;
const urlParams = new URLSearchParams();
if (query) urlParams.set("q", query);
if (scope) urlParams.set("scope", scope);
const data = await apiCall("GET", `/workspaces/${workspace_id}/memories?${urlParams}`);
return toMcpResult(data);
}
export async function handleDeleteMemory(params: { workspace_id: string; memory_id: string }) {
const { workspace_id, memory_id } = params;
const data = await apiCall("DELETE", `/workspaces/${workspace_id}/memories/${memory_id}`);
return toMcpResult(data);
}
export async function handleSessionSearch(params: {
workspace_id: string;
q?: string;
limit?: number;
}) {
const { workspace_id, q, limit } = params;
const qs = new URLSearchParams();
if (q) qs.set("q", q);
if (limit) qs.set("limit", String(limit));
const suffix = qs.toString() ? `?${qs.toString()}` : "";
const data = await apiCall("GET", `/workspaces/${workspace_id}/session-search${suffix}`);
return toMcpResult(data);
}
export async function handleGetSharedContext(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/shared-context`);
return toMcpResult(data);
}
export async function handleSetKV(params: {
workspace_id: string;
key: string;
value: string;
ttl_seconds?: number;
}) {
const { workspace_id, ...body } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/memory`, body);
return toMcpResult(data);
}
export async function handleGetKV(params: { workspace_id: string; key: string }) {
const data = await apiCall(
"GET",
`/workspaces/${params.workspace_id}/memory/${encodeURIComponent(params.key)}`,
);
return toMcpResult(data);
}
export async function handleListKV(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/memory`);
return toMcpResult(data);
}
export async function handleDeleteKV(params: { workspace_id: string; key: string }) {
const data = await apiCall(
"DELETE",
`/workspaces/${params.workspace_id}/memory/${encodeURIComponent(params.key)}`,
);
return toMcpResult(data);
}
export function registerMemoryTools(srv: McpServer) {
srv.tool(
"commit_memory",
"Store a fact in workspace memory (LOCAL, TEAM, or GLOBAL scope)",
{
workspace_id: z.string().describe("Workspace ID"),
content: z.string().describe("Fact to remember"),
scope: z.enum(["LOCAL", "TEAM", "GLOBAL"]).default("LOCAL").describe("Memory scope"),
},
handleCommitMemory
);
srv.tool(
"search_memory",
"Search workspace memories",
{
workspace_id: z.string().describe("Workspace ID"),
query: z.string().optional().describe("Search query"),
scope: z.enum(["LOCAL", "TEAM", "GLOBAL", ""]).optional().describe("Filter by scope"),
},
handleSearchMemory
);
srv.tool(
"delete_memory",
"Delete a specific memory entry",
{ workspace_id: z.string(), memory_id: z.string() },
handleDeleteMemory
);
srv.tool(
"session_search",
"Search a workspace's recent session activity and memory (FTS). Useful for 'did I tell you about X'.",
{
workspace_id: z.string(),
q: z.string().optional(),
limit: z.number().optional(),
},
handleSessionSearch,
);
srv.tool(
"get_shared_context",
"Get the shared-context blob for a workspace (persistent cross-turn context).",
{ workspace_id: z.string() },
handleGetSharedContext,
);
srv.tool(
"memory_set",
"Set a key-value memory entry with optional TTL. Distinct from commit_memory which uses HMA scopes.",
{
workspace_id: z.string(),
key: z.string(),
value: z.string(),
ttl_seconds: z.number().optional(),
},
handleSetKV,
);
srv.tool(
"memory_get",
"Read a single K/V memory entry.",
{ workspace_id: z.string(), key: z.string() },
handleGetKV,
);
srv.tool(
"memory_list",
"List all K/V memory entries for a workspace.",
{ workspace_id: z.string() },
handleListKV,
);
srv.tool(
"memory_delete_kv",
"Delete a single K/V memory entry.",
{ workspace_id: z.string(), key: z.string() },
handleDeleteKV,
);
}

106
src/tools/plugins.ts Normal file
View File

@ -0,0 +1,106 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult } from "../api.js";
export async function handleListPluginRegistry() {
const data = await apiCall("GET", "/plugins");
return toMcpResult(data);
}
export async function handleListInstalledPlugins(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/plugins`);
return toMcpResult(data);
}
export async function handleInstallPlugin(params: { workspace_id: string; source: string }) {
const { workspace_id, source } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/plugins`, { source });
return toMcpResult(data);
}
export async function handleUninstallPlugin(params: { workspace_id: string; name: string }) {
const { workspace_id, name } = params;
const data = await apiCall("DELETE", `/workspaces/${workspace_id}/plugins/${name}`);
return toMcpResult(data);
}
export async function handleListPluginSources() {
const data = await apiCall("GET", "/plugins/sources");
return toMcpResult(data);
}
export async function handleListAvailablePlugins(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/plugins/available`);
return toMcpResult(data);
}
export async function handleCheckPluginCompatibility(params: {
workspace_id: string;
runtime: string;
}) {
const { workspace_id, runtime } = params;
const data = await apiCall(
"GET",
`/workspaces/${workspace_id}/plugins/compatibility?runtime=${encodeURIComponent(runtime)}`,
);
return toMcpResult(data);
}
export function registerPluginTools(srv: McpServer) {
srv.tool("list_plugin_registry", "List all available plugins from the registry", {}, handleListPluginRegistry);
srv.tool(
"list_installed_plugins",
"List plugins installed in a workspace",
{ workspace_id: z.string().describe("Workspace ID") },
handleListInstalledPlugins
);
srv.tool(
"install_plugin",
"Install a plugin into a workspace from any registered source (auto-restarts). Use GET /plugins/sources to list schemes.",
{
workspace_id: z.string().describe("Workspace ID"),
source: z
.string()
.describe(
"Source URL: 'local://<name>' for platform registry, 'github://<owner>/<repo>[#<ref>]' for GitHub, or any registered scheme."
),
},
handleInstallPlugin
);
srv.tool(
"uninstall_plugin",
"Remove a plugin from a workspace (auto-restarts)",
{
workspace_id: z.string().describe("Workspace ID"),
name: z.string().describe("Plugin name to remove"),
},
handleUninstallPlugin
);
srv.tool(
"list_plugin_sources",
"List registered plugin install-source schemes (e.g. local, github).",
{},
handleListPluginSources,
);
srv.tool(
"list_available_plugins",
"List plugins from the registry filtered to ones supported by this workspace's runtime.",
{ workspace_id: z.string() },
handleListAvailablePlugins,
);
srv.tool(
"check_plugin_compatibility",
"Preflight check: which installed plugins would break if this workspace switched runtime to <runtime>?",
{
workspace_id: z.string(),
runtime: z.string().describe("Target runtime (claude-code, deepagents, langgraph, ...)"),
},
handleCheckPluginCompatibility,
);
}

172
src/tools/remote_agents.ts Normal file
View File

@ -0,0 +1,172 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, PLATFORM_URL, toMcpResult, isApiError } from "../api.js";
// Fetch the workspace list, filter to runtime='external'. The platform
// has no dedicated /remote-agents endpoint — we filter client-side
// because the workspace list is small (tens to low-hundreds, never
// pagination scale) and adding a server endpoint would be a separate PR.
export async function handleListRemoteAgents() {
const data = await apiCall("GET", "/workspaces");
if (!Array.isArray(data)) {
return toMcpResult(data);
}
const remote = data
.filter((w: { runtime?: string }) => w.runtime === "external")
.map((w: Record<string, unknown>) => ({
id: w.id,
name: w.name,
status: w.status,
url: w.url,
last_heartbeat_at: w.last_heartbeat_at,
uptime_seconds: w.uptime_seconds,
tier: w.tier,
}));
return toMcpResult({ count: remote.length, agents: remote });
}
// Phase 30.4 — token-gated; from MCP we don't have a workspace bearer
// (we're an operator surface), so we hit the lightweight unauthenticated
// /workspaces/:id endpoint and project the same shape. Still useful as
// a focused tool that doesn't dump the full workspace blob.
export async function handleGetRemoteAgentState(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}`);
if (isApiError(data)) {
return toMcpResult(data);
}
const w = data as Record<string, unknown>;
const projected = {
workspace_id: w.id,
status: w.status,
paused: w.status === "paused",
deleted: w.status === "removed",
runtime: w.runtime,
last_heartbeat_at: w.last_heartbeat_at,
};
return toMcpResult(projected);
}
export async function handleGetRemoteAgentSetupCommand(params: {
workspace_id: string;
platform_url_override?: string;
}) {
// Verify the workspace exists and is runtime='external' before generating
// the command — saves the operator from pasting a bash line that will
// fail because the workspace was a Docker workspace they typed by mistake.
const ws = await apiCall("GET", `/workspaces/${params.workspace_id}`);
if (isApiError(ws)) {
return toMcpResult(ws);
}
const w = ws as { id: string; name: string; runtime?: string };
if (w.runtime !== "external") {
return toMcpResult({
error: "workspace is not external; setup command only applies to runtime='external'",
workspace_id: w.id,
actual_runtime: w.runtime,
});
}
// The MCP server's PLATFORM_URL is whatever Claude Desktop / the host
// injected — usually localhost when an operator runs us locally. That
// URL is useless inside a remote-agent shell on a different machine.
// If the caller passes platform_url_override we use it; otherwise we
// detect localhost and surface a warning so the operator knows to
// substitute the real public URL before pasting the command.
const targetUrl = params.platform_url_override?.trim() || PLATFORM_URL;
const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(targetUrl);
const warnings: string[] = [];
if (isLocalhost && !params.platform_url_override) {
warnings.push(
`PLATFORM_URL is ${targetUrl} — this only works if the remote agent is on the same machine as the platform. ` +
`Pass platform_url_override with the agent-reachable URL (e.g. https://your-platform.example.com) before pasting on a different host.`
);
}
const setupCmd = [
`# Run on the remote machine where the agent will live.`,
`# Requires Python 3.11+ and bash (the SDK invokes setup.sh via bash).`,
`pip install molecule-sdk # (or: pip install -e <molecule-checkout>/sdk/python)`,
``,
`WORKSPACE_ID=${w.id} \\`,
`PLATFORM_URL=${targetUrl} \\`,
`python3 -c "from molecule_agent import RemoteAgentClient; \\`,
` c = RemoteAgentClient.register_from_env(); \\`,
` c.pull_secrets(); \\`,
` c.run_heartbeat_loop()"`,
``,
`# For a richer demo (logging, graceful shutdown) see`,
`# sdk/python/examples/remote-agent/run.py in the molecule-monorepo checkout.`,
`# The agent will register, mint its bearer token (cached at`,
`# ~/.molecule/${w.id}/.auth_token), pull secrets, then heartbeat.`,
].join("\n");
return toMcpResult({
workspace_id: w.id,
workspace_name: w.name,
platform_url: targetUrl,
setup_command: setupCmd,
...(warnings.length > 0 ? { warnings } : {}),
});
}
export async function handleCheckRemoteAgentFreshness(params: {
workspace_id: string;
threshold_seconds?: number;
}) {
const ws = await apiCall("GET", `/workspaces/${params.workspace_id}`);
if (isApiError(ws)) {
return toMcpResult(ws);
}
const w = ws as { last_heartbeat_at?: string; status?: string; runtime?: string };
const threshold = params.threshold_seconds ?? 90;
const heartbeatStr = w.last_heartbeat_at;
let secondsSince: number | null = null;
if (heartbeatStr) {
const heartbeatMs = Date.parse(heartbeatStr);
if (!isNaN(heartbeatMs)) {
secondsSince = Math.floor((Date.now() - heartbeatMs) / 1000);
}
}
const fresh = secondsSince !== null && secondsSince <= threshold;
return toMcpResult({
workspace_id: params.workspace_id,
status: w.status,
runtime: w.runtime,
last_heartbeat_at: heartbeatStr,
seconds_since_heartbeat: secondsSince,
threshold_seconds: threshold,
fresh,
});
}
export function registerRemoteAgentTools(srv: McpServer) {
srv.tool(
"list_remote_agents",
"List all workspaces with runtime='external' (Phase 30 remote agents). Returns id, name, status, last_heartbeat_at, url. Useful for spotting offline remote agents from a Claude session.",
{},
handleListRemoteAgents,
);
srv.tool(
"get_remote_agent_state",
"Phase 30.4 lightweight state poll for a remote workspace. Returns {status, paused, deleted}. Faster than get_workspace because it doesn't include config/agent_card. Useful when you only need to know whether a remote agent is alive.",
{ workspace_id: z.string() },
handleGetRemoteAgentState,
);
srv.tool(
"get_remote_agent_setup_command",
"Build a one-shot bash command an operator can paste into a remote machine to register an agent against this Molecule AI platform. Returns a string like `WORKSPACE_ID=... PLATFORM_URL=... python3 -m molecule_agent.bootstrap`. Pass platform_url_override when the MCP server's PLATFORM_URL is localhost (the agent will live on a different host and needs the platform's public URL). The workspace must exist and be runtime='external'.",
{
workspace_id: z.string(),
platform_url_override: z.string().optional(),
},
handleGetRemoteAgentSetupCommand,
);
srv.tool(
"check_remote_agent_freshness",
"Compare a remote workspace's last_heartbeat_at against now. Returns {seconds_since_heartbeat, fresh, threshold_seconds} where `fresh` is true if the agent heartbeated within the platform's stale-after window. Useful for pre-flight checks before delegating work.",
{ workspace_id: z.string(), threshold_seconds: z.number().optional() },
handleCheckRemoteAgentFreshness,
);
}

131
src/tools/schedules.ts Normal file
View File

@ -0,0 +1,131 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult } from "../api.js";
export async function handleListSchedules(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/schedules`);
return toMcpResult(data);
}
export async function handleCreateSchedule(params: {
workspace_id: string;
name: string;
cron_expr: string;
prompt: string;
timezone?: string;
enabled?: boolean;
}) {
const { workspace_id, ...body } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/schedules`, body);
return toMcpResult(data);
}
export async function handleUpdateSchedule(params: {
workspace_id: string;
schedule_id: string;
name?: string;
cron_expr?: string;
prompt?: string;
timezone?: string;
enabled?: boolean;
}) {
const { workspace_id, schedule_id, ...body } = params;
const data = await apiCall(
"PATCH",
`/workspaces/${workspace_id}/schedules/${schedule_id}`,
body,
);
return toMcpResult(data);
}
export async function handleDeleteSchedule(params: {
workspace_id: string;
schedule_id: string;
}) {
const data = await apiCall(
"DELETE",
`/workspaces/${params.workspace_id}/schedules/${params.schedule_id}`,
);
return toMcpResult(data);
}
export async function handleRunSchedule(params: {
workspace_id: string;
schedule_id: string;
}) {
const data = await apiCall(
"POST",
`/workspaces/${params.workspace_id}/schedules/${params.schedule_id}/run`,
);
return toMcpResult(data);
}
export async function handleGetScheduleHistory(params: {
workspace_id: string;
schedule_id: string;
}) {
const data = await apiCall(
"GET",
`/workspaces/${params.workspace_id}/schedules/${params.schedule_id}/history`,
);
return toMcpResult(data);
}
export function registerScheduleTools(srv: McpServer) {
srv.tool(
"list_schedules",
"List cron schedules for a workspace.",
{ workspace_id: z.string() },
handleListSchedules,
);
srv.tool(
"create_schedule",
"Create a cron schedule that fires a prompt on a recurring timer.",
{
workspace_id: z.string(),
name: z.string(),
cron_expr: z.string().describe("5-field cron (e.g. '0 9 * * 1-5')"),
prompt: z.string(),
timezone: z.string().optional(),
enabled: z.boolean().optional(),
},
handleCreateSchedule,
);
srv.tool(
"update_schedule",
"Update fields on an existing schedule.",
{
workspace_id: z.string(),
schedule_id: z.string(),
name: z.string().optional(),
cron_expr: z.string().optional(),
prompt: z.string().optional(),
timezone: z.string().optional(),
enabled: z.boolean().optional(),
},
handleUpdateSchedule,
);
srv.tool(
"delete_schedule",
"Delete a schedule.",
{ workspace_id: z.string(), schedule_id: z.string() },
handleDeleteSchedule,
);
srv.tool(
"run_schedule",
"Fire a schedule manually, bypassing its cron expression.",
{ workspace_id: z.string(), schedule_id: z.string() },
handleRunSchedule,
);
srv.tool(
"get_schedule_history",
"Get past runs of a schedule — status, start/end, output preview.",
{ workspace_id: z.string(), schedule_id: z.string() },
handleGetScheduleHistory,
);
}

82
src/tools/secrets.ts Normal file
View File

@ -0,0 +1,82 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult } from "../api.js";
export async function handleSetSecret(params: { workspace_id: string; key: string; value: string }) {
const { workspace_id, key, value } = params;
const data = await apiCall("POST", `/workspaces/${workspace_id}/secrets`, { key, value });
return toMcpResult(data);
}
export async function handleListSecrets(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}/secrets`);
return toMcpResult(data);
}
export async function handleDeleteSecret(params: { workspace_id: string; key: string }) {
const { workspace_id, key } = params;
const data = await apiCall("DELETE", `/workspaces/${workspace_id}/secrets/${encodeURIComponent(key)}`);
return toMcpResult(data);
}
export async function handleListGlobalSecrets() {
const data = await apiCall("GET", "/settings/secrets");
return toMcpResult(data);
}
export async function handleSetGlobalSecret(params: { key: string; value: string }) {
const { key, value } = params;
const data = await apiCall("PUT", "/settings/secrets", { key, value });
return toMcpResult(data);
}
export async function handleDeleteGlobalSecret(params: { key: string }) {
const data = await apiCall("DELETE", `/settings/secrets/${params.key}`);
return toMcpResult(data);
}
export function registerSecretTools(srv: McpServer) {
srv.tool(
"set_secret",
"Set an API key or environment variable for a workspace",
{
workspace_id: z.string().describe("Workspace ID"),
key: z.string().describe("Secret key (e.g., ANTHROPIC_API_KEY)"),
value: z.string().describe("Secret value"),
},
handleSetSecret
);
srv.tool(
"list_secrets",
"List secret keys for a workspace (values never exposed)",
{ workspace_id: z.string().describe("Workspace ID") },
handleListSecrets
);
srv.tool(
"delete_secret",
"Delete a secret from a workspace",
{ workspace_id: z.string(), key: z.string() },
handleDeleteSecret
);
srv.tool("list_global_secrets", "List global secret keys (values never exposed)", {}, handleListGlobalSecrets);
srv.tool(
"set_global_secret",
"Set a global secret (available to all workspaces)",
{
key: z.string().describe("Secret key (e.g., GITHUB_TOKEN)"),
value: z.string().describe("Secret value"),
},
handleSetGlobalSecret
);
srv.tool(
"delete_global_secret",
"Delete a global secret",
{ key: z.string().describe("Secret key") },
handleDeleteGlobalSecret
);
}

140
src/tools/workspaces.ts Normal file
View File

@ -0,0 +1,140 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { apiCall, toMcpResult } from "../api.js";
export async function handleListWorkspaces() {
const data = await apiCall("GET", "/workspaces");
return toMcpResult(data);
}
// Random canvas seeding so MCP-created workspaces don't all stack at (0,0).
// The platform stores these; canvas drag-drop overrides them immediately.
function initialCanvasPosition() {
return { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 };
}
export async function handleCreateWorkspace(params: {
name: string;
role?: string;
template?: string;
tier?: number;
parent_id?: string;
runtime?: string;
workspace_dir?: string;
workspace_access?: "none" | "read_only" | "read_write";
}) {
const { name, role, template, tier, parent_id, runtime, workspace_dir, workspace_access } = params;
const data = await apiCall("POST", "/workspaces", {
name, role, template, tier, parent_id, runtime,
workspace_dir, workspace_access,
canvas: initialCanvasPosition(),
});
return toMcpResult(data);
}
export async function handleGetWorkspace(params: { workspace_id: string }) {
const data = await apiCall("GET", `/workspaces/${params.workspace_id}`);
return toMcpResult(data);
}
export async function handleDeleteWorkspace(params: { workspace_id: string }) {
const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}?confirm=true`);
return toMcpResult(data);
}
export async function handleRestartWorkspace(params: { workspace_id: string }) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/restart`, {});
return toMcpResult(data);
}
export async function handleUpdateWorkspace(params: {
workspace_id: string;
name?: string;
role?: string;
tier?: number;
parent_id?: string | null;
workspace_dir?: string;
workspace_access?: "none" | "read_only" | "read_write";
}) {
const { workspace_id, ...fields } = params;
const data = await apiCall("PATCH", `/workspaces/${workspace_id}`, fields);
return toMcpResult(data);
}
export async function handlePauseWorkspace(params: { workspace_id: string }) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/pause`, {});
return toMcpResult(data);
}
export async function handleResumeWorkspace(params: { workspace_id: string }) {
const data = await apiCall("POST", `/workspaces/${params.workspace_id}/resume`, {});
return toMcpResult(data);
}
export function registerWorkspaceTools(srv: McpServer) {
srv.tool("list_workspaces", "List all workspaces with their status, skills, and hierarchy", {}, handleListWorkspaces);
srv.tool(
"create_workspace",
"Create a new workspace node on the canvas",
{
name: z.string().describe("Workspace name"),
role: z.string().optional().describe("Role description"),
template: z.string().optional().describe("Template name from workspace-configs-templates/"),
tier: z.number().min(1).max(4).default(1).describe("Tier (1=basic, 2=browser, 3=desktop, 4=VM)"),
parent_id: z.string().optional().describe("Parent workspace ID for nesting"),
runtime: z.string().optional().describe("Runtime: claude-code, langgraph, openclaw, deepagents, autogen, crewai, hermes, external"),
workspace_dir: z.string().optional().describe("Host path to bind-mount at /workspace (PM only by convention)"),
workspace_access: z.enum(["none", "read_only", "read_write"]).optional().describe("Filesystem access mode for /workspace"),
},
handleCreateWorkspace
);
srv.tool(
"get_workspace",
"Get detailed information about a specific workspace",
{ workspace_id: z.string().describe("Workspace ID") },
handleGetWorkspace
);
srv.tool(
"delete_workspace",
"Delete a workspace (cascades to children)",
{ workspace_id: z.string().describe("Workspace ID") },
handleDeleteWorkspace
);
srv.tool(
"restart_workspace",
"Restart an offline or failed workspace",
{ workspace_id: z.string().describe("Workspace ID") },
handleRestartWorkspace
);
srv.tool(
"update_workspace",
"Update workspace fields (name, role, tier, parent_id, position)",
{
workspace_id: z.string(),
name: z.string().optional(),
role: z.string().optional(),
tier: z.number().optional(),
parent_id: z.string().nullable().optional().describe("Set parent for nesting, null to un-nest"),
},
handleUpdateWorkspace
);
srv.tool(
"pause_workspace",
"Pause a workspace (stops container, preserves config)",
{ workspace_id: z.string().describe("Workspace ID") },
handlePauseWorkspace
);
srv.tool(
"resume_workspace",
"Resume a paused workspace",
{ workspace_id: z.string().describe("Workspace ID") },
handleResumeWorkspace
);
}

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"declaration": true
},
"include": ["src"]
}