From 1512e7ce62c520bf0dab20229ab213979cf56e05 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 13 Apr 2026 13:27:04 -0700 Subject: [PATCH 001/722] refactor(mcp-server): split 1697-line index.ts into per-domain modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure mechanical split, no behavior changes. Pulls the 70+ tool handlers out of one monolith into api.ts (PLATFORM_URL + apiCall) plus 12 tools/*.ts files grouped by domain (workspaces, agents, secrets, files, memory, plugins, channels, delegation, schedules, approvals, discovery, remote_agents). Each module exports its handlers and a registerXxxTools(srv) function; createServer() wires them up. index.ts drops from 1697 → 89 lines. Largest new file is 183 lines. All handlers still re-exported from index.ts so existing tests that import them via "../index.js" keep working. Build clean; jest results unchanged from pre-refactor baseline. Co-Authored-By: Claude Opus 4.6 (1M context) --- mcp-server/src/api.ts | 33 + mcp-server/src/index.ts | 1688 +------------------------ mcp-server/src/tools/agents.ts | 97 ++ mcp-server/src/tools/approvals.ts | 75 ++ mcp-server/src/tools/channels.ts | 142 +++ mcp-server/src/tools/delegation.ts | 183 +++ mcp-server/src/tools/discovery.ts | 173 +++ mcp-server/src/tools/files.ts | 110 ++ mcp-server/src/tools/memory.ts | 165 +++ mcp-server/src/tools/plugins.ts | 106 ++ mcp-server/src/tools/remote_agents.ts | 182 +++ mcp-server/src/tools/schedules.ts | 131 ++ mcp-server/src/tools/secrets.ts | 82 ++ mcp-server/src/tools/workspaces.ts | 131 ++ 14 files changed, 1650 insertions(+), 1648 deletions(-) create mode 100644 mcp-server/src/api.ts create mode 100644 mcp-server/src/tools/agents.ts create mode 100644 mcp-server/src/tools/approvals.ts create mode 100644 mcp-server/src/tools/channels.ts create mode 100644 mcp-server/src/tools/delegation.ts create mode 100644 mcp-server/src/tools/discovery.ts create mode 100644 mcp-server/src/tools/files.ts create mode 100644 mcp-server/src/tools/memory.ts create mode 100644 mcp-server/src/tools/plugins.ts create mode 100644 mcp-server/src/tools/remote_agents.ts create mode 100644 mcp-server/src/tools/schedules.ts create mode 100644 mcp-server/src/tools/secrets.ts create mode 100644 mcp-server/src/tools/workspaces.ts diff --git a/mcp-server/src/api.ts b/mcp-server/src/api.ts new file mode 100644 index 00000000..20428a97 --- /dev/null +++ b/mcp-server/src/api.ts @@ -0,0 +1,33 @@ +// 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"; + +export async function apiCall(method: string, path: string, body?: unknown) { + 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); + } catch { + return { raw: text, status: res.status }; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Molecule AI API error (${method} ${path}): ${msg}`); + return { error: `Platform unreachable at ${PLATFORM_URL}`, detail: msg }; + } +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 167a1899..a6d1c735 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -11,879 +11,35 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; -// 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"; - -export async function apiCall(method: string, path: string, body?: unknown) { - 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); - } catch { - return { raw: text, status: res.status }; - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`Molecule AI API error (${method} ${path}): ${msg}`); - return { error: `Platform unreachable at ${PLATFORM_URL}`, detail: msg }; - } -} - -// ============================================================ -// Tool handler functions (exported for unit testing) -// ============================================================ - -export async function handleListWorkspaces() { - const data = await apiCall("GET", "/workspaces"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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"; // #65 -}) { - 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: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 }, - }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleGetWorkspace(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleDeleteWorkspace(params: { workspace_id: string }) { - const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}?confirm=true`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleRestartWorkspace(params: { workspace_id: string }) { - const data = await apiCall("POST", `/workspaces/${params.workspace_id}/restart`, {}); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleChatWithAgent(params: { workspace_id: string; message: string }) { - const { workspace_id, message } = params; - const data = await apiCall("POST", `/workspaces/${workspace_id}/a2a`, { - method: "message/send", - params: { - message: { role: "user", parts: [{ type: "text", text: message }] }, - }, - }); - // Extract text from response - const parts = data?.result?.parts || []; - const text = parts - .filter((p: { kind?: string }) => p.kind === "text") - .map((p: { text?: string }) => p.text || "") - .join("\n"); - return { content: [{ type: "text" as const, text: text || JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleListSecrets(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/secrets`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleListFiles(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/files`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleReadFile(params: { workspace_id: string; path: string }) { - const { workspace_id, path } = params; - const data = await apiCall("GET", `/workspaces/${workspace_id}/files/${path}`); - return { content: [{ type: "text" as const, text: data?.content || JSON.stringify(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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleListTemplates() { - const data = await apiCall("GET", "/templates"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleExpandTeam(params: { workspace_id: string }) { - const data = await apiCall("POST", `/workspaces/${params.workspace_id}/expand`, {}); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleCollapseTeam(params: { workspace_id: string }) { - const data = await apiCall("POST", `/workspaces/${params.workspace_id}/collapse`, {}); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleListPendingApprovals() { - const data = await apiCall("GET", "/approvals/pending"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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"; // #65 -}) { - const { workspace_id, ...fields } = params; - const data = await apiCall("PATCH", `/workspaces/${workspace_id}`, fields); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleRemoveAgent(params: { workspace_id: string }) { - const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/agent`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleGetConfig(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/config`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleUpdateConfig(params: { workspace_id: string; config: Record }) { - const { workspace_id, config } = params; - const data = await apiCall("PATCH", `/workspaces/${workspace_id}/config`, config); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleListPeers(params: { workspace_id: string }) { - const data = await apiCall("GET", `/registry/${params.workspace_id}/peers`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleDiscoverWorkspace(params: { workspace_id: string }) { - const data = await apiCall("GET", `/registry/discover/${params.workspace_id}`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleExportBundle(params: { workspace_id: string }) { - const data = await apiCall("GET", `/bundles/export/${params.workspace_id}`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleImportBundle(params: { bundle: Record }) { - const data = await apiCall("POST", `/bundles/import`, params.bundle); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleImportTemplate(params: { name: string; files: Record }) { - const { name, files } = params; - const data = await apiCall("POST", `/templates/import`, { name, files }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleReplaceAllFiles(params: { - workspace_id: string; - files: Record; -}) { - const { workspace_id, files } = params; - const data = await apiCall("PUT", `/workspaces/${workspace_id}/files`, { files }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleListTraces(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/traces`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleGetModel(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/model`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleGetWorkspaceApprovals(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/approvals`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// === PLUGINS === - -export async function handleListPluginRegistry() { - const data = await apiCall("GET", "/plugins"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleListInstalledPlugins(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/plugins`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// === GLOBAL SECRETS === - -export async function handleListGlobalSecrets() { - const data = await apiCall("GET", "/settings/secrets"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleSetGlobalSecret(params: { key: string; value: string }) { - const { key, value } = params; - const data = await apiCall("PUT", "/settings/secrets", { key, value }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleDeleteGlobalSecret(params: { key: string }) { - const data = await apiCall("DELETE", `/settings/secrets/${params.key}`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// === PAUSE / RESUME === - -export async function handlePauseWorkspace(params: { workspace_id: string }) { - const data = await apiCall("POST", `/workspaces/${params.workspace_id}/pause`, {}); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleResumeWorkspace(params: { workspace_id: string }) { - const data = await apiCall("POST", `/workspaces/${params.workspace_id}/resume`, {}); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// === ORG TEMPLATES === - -export async function handleListOrgTemplates() { - const data = await apiCall("GET", "/org/templates"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleImportOrg(params: { dir: string }) { - const data = await apiCall("POST", "/org/import", { dir: params.dir }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// === ASYNC DELEGATION === - -// === CHANNEL HANDLERS === - -export async function handleListChannelAdapters() { - const data = await apiCall("GET", `/channels/adapters`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleListChannels(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/channels`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: "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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleUpdateChannel(params: { - workspace_id: string; - channel_id: string; - config?: string; - enabled?: boolean; - allowed_users?: string; -}) { - const body: Record = {}; - if (params.config) { - try { body.config = JSON.parse(params.config); } catch { return { content: [{ type: "text" as const, text: "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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// === DELEGATION HANDLERS === - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleCheckDelegations(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/delegations`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// ============================================================ -// Coverage completion — additions to reach 100% platform parity -// ============================================================ - -// --- Delegations (#64: Record/UpdateStatus) --- - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// --- Activity (POST /activity, POST /notify) --- - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// --- Canvas viewport --- - -export async function handleGetViewport() { - const data = await apiCall("GET", "/canvas/viewport"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleSetViewport(params: { x: number; y: number; zoom: number }) { - const data = await apiCall("PUT", "/canvas/viewport", params); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// --- Channels (platform-wide discover) --- - -export async function handleDiscoverChannelChats(params: { - type: string; - config: Record; -}) { - const data = await apiCall("POST", "/channels/discover", params); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// --- Plugins (sources, available, compatibility) --- - -export async function handleListPluginSources() { - const data = await apiCall("GET", "/plugins/sources"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleListAvailablePlugins(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/plugins/available`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// --- Schedules (cron) --- - -export async function handleListSchedules(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/schedules`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// --- Session search + shared context --- - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleGetSharedContext(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/shared-context`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// --- K/V memory (per-workspace key-value, separate from HMA scope memories) --- - -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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleGetKV(params: { workspace_id: string; key: string }) { - const data = await apiCall( - "GET", - `/workspaces/${params.workspace_id}/memory/${encodeURIComponent(params.key)}`, - ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleListKV(params: { workspace_id: string }) { - const data = await apiCall("GET", `/workspaces/${params.workspace_id}/memory`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -export async function handleDeleteKV(params: { workspace_id: string; key: string }) { - const data = await apiCall( - "DELETE", - `/workspaces/${params.workspace_id}/memory/${encodeURIComponent(params.key)}`, - ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; -} - -// ============================================================ -// Phase 30 — Remote agent management handlers -// ============================================================ - -// 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; - } - const remote = data - .filter((w: { runtime?: string }) => w.runtime === "external") - .map((w: Record) => ({ - 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 { content: [{ type: "text" as const, text: JSON.stringify({ count: remote.length, agents: remote }, null, 2) }] }; -} - -// 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 (data && typeof data === "object" && "error" in data) { - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; - } - const w = data as Record; - 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 { content: [{ type: "text" as const, text: JSON.stringify(projected, null, 2) }] }; -} - -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 (ws && typeof ws === "object" && "error" in ws) { - return { content: [{ type: "text" as const, text: JSON.stringify(ws, null, 2) }] }; - } - const w = ws as { id: string; name: string; runtime?: string }; - if (w.runtime !== "external") { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: "workspace is not external; setup command only applies to runtime='external'", - workspace_id: w.id, - actual_runtime: w.runtime, - }, null, 2), - }], - }; - } - - // 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 /sdk/python)`, - ``, - `WORKSPACE_ID=${w.id} \\`, - `PLATFORM_URL=${targetUrl} \\`, - `python3 -m examples.remote-agent.run`, - ``, - `# The agent will register, mint its bearer token (cached at`, - `# ~/.molecule/${w.id}/.auth_token), pull secrets, then heartbeat.`, - ].join("\n"); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - workspace_id: w.id, - workspace_name: w.name, - platform_url: targetUrl, - setup_command: setupCmd, - ...(warnings.length > 0 ? { warnings } : {}), - }, null, 2), - }], - }; -} - -export async function handleCheckRemoteAgentFreshness(params: { - workspace_id: string; - threshold_seconds?: number; -}) { - const ws = await apiCall("GET", `/workspaces/${params.workspace_id}`); - if (ws && typeof ws === "object" && "error" in ws) { - return { content: [{ type: "text" as const, text: JSON.stringify(ws, null, 2) }] }; - } - 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 { - content: [{ - type: "text" as const, - text: JSON.stringify({ - workspace_id: params.workspace_id, - status: w.status, - runtime: w.runtime, - last_heartbeat_at: heartbeatStr, - seconds_since_heartbeat: secondsSince, - threshold_seconds: threshold, - fresh, - }, null, 2), - }], - }; -} - -// ============================================================ -// MCP Server registration -// ============================================================ +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. +export { PLATFORM_URL, apiCall }; +export * from "./tools/workspaces.js"; +export * from "./tools/agents.js"; +export * from "./tools/secrets.js"; +export * from "./tools/files.js"; +export * from "./tools/memory.js"; +export * from "./tools/plugins.js"; +export * from "./tools/channels.js"; +export * from "./tools/delegation.js"; +export * from "./tools/schedules.js"; +export * from "./tools/approvals.js"; +export * from "./tools/discovery.js"; +export * from "./tools/remote_agents.js"; export function createServer() { const srv = new McpServer({ @@ -891,786 +47,22 @@ export function createServer() { version: "1.0.0", }); - // === WORKSPACE TOOLS === - - 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"), - }, - 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 - ); - - // === CHAT / A2A === - - 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 - ); - - // === AGENT MANAGEMENT === - - 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 - ); - - // === SECRETS === - - 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 - ); - - // === FILES === - - 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 - ); - - // === MEMORY (HMA) === - - 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 - ); - - // === TEMPLATES === - - srv.tool("list_templates", "List available workspace templates", {}, handleListTemplates); - - // === TEAM EXPANSION === - - 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 - ); - - // === APPROVALS === - - 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 - ); - - // === MISSING TOOLS — FULL COVERAGE === - - 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( - "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( - "delete_secret", - "Delete a secret from a workspace", - { workspace_id: z.string(), key: z.string() }, - handleDeleteSecret - ); - - 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 - ); - - 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( - "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( - "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( - "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( - "list_traces", - "List recent LLM traces from Langfuse for a workspace", - { workspace_id: z.string() }, - handleListTraces - ); - - 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( - "delete_memory", - "Delete a specific memory entry", - { workspace_id: z.string(), memory_id: z.string() }, - handleDeleteMemory - ); - - srv.tool( - "get_model", - "Get current model configuration for a workspace", - { workspace_id: z.string() }, - handleGetModel - ); - - 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 - ); - - // === PLUGINS === - - 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://' for platform registry, 'github:///[#]' 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 - ); - - // === GLOBAL SECRETS === - - 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 - ); - - // === PAUSE / RESUME === - - 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 - ); - - // === ORG TEMPLATES === - - 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 - ); - - // === SOCIAL CHANNELS === - - 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); - - // === ASYNC DELEGATION === - - 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 - ); - - // ==================================================== - // Coverage completion — 100% platform parity (#64/#65 + gaps) - // ==================================================== - - // Delegations (#64): agents mirror direct-A2A delegations to activity_logs - 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, - ); - - // Activity - 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( - "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, - ); - - // Canvas viewport - 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, - ); - - // Channel platform-level discovery - 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, - ); - - // Plugins — sources + per-workspace availability + compatibility - 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 ?", - { - workspace_id: z.string(), - runtime: z.string().describe("Target runtime (claude-code, deepagents, langgraph, ...)"), - }, - handleCheckPluginCompatibility, - ); - - // Schedules (cron) - 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, - ); - - // Session search + shared context - 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, - ); - - // K/V memory - 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, - ); - - // ========================================================== - // Phase 30 — Remote agent management (SaaS surface) - // ========================================================== - 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, - ); + 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; } -// ============================================================ -// Main entry point — only runs when executed directly -// ============================================================ - async function main() { // Validate platform connectivity on startup try { diff --git a/mcp-server/src/tools/agents.ts b/mcp-server/src/tools/agents.ts new file mode 100644 index 00000000..f0cd5dd0 --- /dev/null +++ b/mcp-server/src/tools/agents.ts @@ -0,0 +1,97 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } from "../api.js"; + +export async function handleChatWithAgent(params: { workspace_id: string; message: string }) { + const { workspace_id, message } = params; + const data = await apiCall("POST", `/workspaces/${workspace_id}/a2a`, { + method: "message/send", + params: { + message: { role: "user", parts: [{ type: "text", text: message }] }, + }, + }); + const parts = data?.result?.parts || []; + const text = parts + .filter((p: { kind?: string }) => p.kind === "text") + .map((p: { text?: string }) => p.text || "") + .join("\n"); + return { content: [{ type: "text" as const, text: text || JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleRemoveAgent(params: { workspace_id: string }) { + const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/agent`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleGetModel(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/model`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 + ); +} diff --git a/mcp-server/src/tools/approvals.ts b/mcp-server/src/tools/approvals.ts new file mode 100644 index 00000000..3d4178b1 --- /dev/null +++ b/mcp-server/src/tools/approvals.ts @@ -0,0 +1,75 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } from "../api.js"; + +export async function handleListPendingApprovals() { + const data = await apiCall("GET", "/approvals/pending"); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleGetWorkspaceApprovals(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/approvals`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 + ); +} diff --git a/mcp-server/src/tools/channels.ts b/mcp-server/src/tools/channels.ts new file mode 100644 index 00000000..c8070e58 --- /dev/null +++ b/mcp-server/src/tools/channels.ts @@ -0,0 +1,142 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } from "../api.js"; + +export async function handleListChannelAdapters() { + const data = await apiCall("GET", `/channels/adapters`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleListChannels(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/channels`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: "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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleUpdateChannel(params: { + workspace_id: string; + channel_id: string; + config?: string; + enabled?: boolean; + allowed_users?: string; +}) { + const body: Record = {}; + if (params.config) { + try { body.config = JSON.parse(params.config); } catch { return { content: [{ type: "text" as const, text: "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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleDiscoverChannelChats(params: { + type: string; + config: Record; +}) { + const data = await apiCall("POST", "/channels/discover", params); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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, + ); +} diff --git a/mcp-server/src/tools/delegation.ts b/mcp-server/src/tools/delegation.ts new file mode 100644 index 00000000..6e892f1f --- /dev/null +++ b/mcp-server/src/tools/delegation.ts @@ -0,0 +1,183 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleCheckDelegations(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/delegations`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleListTraces(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/traces`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 + ); +} diff --git a/mcp-server/src/tools/discovery.ts b/mcp-server/src/tools/discovery.ts new file mode 100644 index 00000000..5c3936f9 --- /dev/null +++ b/mcp-server/src/tools/discovery.ts @@ -0,0 +1,173 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } from "../api.js"; + +export async function handleListPeers(params: { workspace_id: string }) { + const data = await apiCall("GET", `/registry/${params.workspace_id}/peers`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleDiscoverWorkspace(params: { workspace_id: string }) { + const data = await apiCall("GET", `/registry/discover/${params.workspace_id}`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleListTemplates() { + const data = await apiCall("GET", "/templates"); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleListOrgTemplates() { + const data = await apiCall("GET", "/org/templates"); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleImportOrg(params: { dir: string }) { + const data = await apiCall("POST", "/org/import", { dir: params.dir }); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleImportTemplate(params: { name: string; files: Record }) { + const { name, files } = params; + const data = await apiCall("POST", `/templates/import`, { name, files }); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleExportBundle(params: { workspace_id: string }) { + const data = await apiCall("GET", `/bundles/export/${params.workspace_id}`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleImportBundle(params: { bundle: Record }) { + const data = await apiCall("POST", `/bundles/import`, params.bundle); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleGetViewport() { + const data = await apiCall("GET", "/canvas/viewport"); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleSetViewport(params: { x: number; y: number; zoom: number }) { + const data = await apiCall("PUT", "/canvas/viewport", params); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleExpandTeam(params: { workspace_id: string }) { + const data = await apiCall("POST", `/workspaces/${params.workspace_id}/expand`, {}); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleCollapseTeam(params: { workspace_id: string }) { + const data = await apiCall("POST", `/workspaces/${params.workspace_id}/collapse`, {}); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 + ); +} diff --git a/mcp-server/src/tools/files.ts b/mcp-server/src/tools/files.ts new file mode 100644 index 00000000..132ed63c --- /dev/null +++ b/mcp-server/src/tools/files.ts @@ -0,0 +1,110 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } from "../api.js"; + +export async function handleListFiles(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/files`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleReadFile(params: { workspace_id: string; path: string }) { + const { workspace_id, path } = params; + const data = await apiCall("GET", `/workspaces/${workspace_id}/files/${path}`); + return { content: [{ type: "text" as const, text: data?.content || JSON.stringify(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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleReplaceAllFiles(params: { + workspace_id: string; + files: Record; +}) { + const { workspace_id, files } = params; + const data = await apiCall("PUT", `/workspaces/${workspace_id}/files`, { files }); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleGetConfig(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/config`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleUpdateConfig(params: { workspace_id: string; config: Record }) { + const { workspace_id, config } = params; + const data = await apiCall("PATCH", `/workspaces/${workspace_id}/config`, config); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 + ); +} diff --git a/mcp-server/src/tools/memory.ts b/mcp-server/src/tools/memory.ts new file mode 100644 index 00000000..19c4d419 --- /dev/null +++ b/mcp-server/src/tools/memory.ts @@ -0,0 +1,165 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleGetSharedContext(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/shared-context`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleGetKV(params: { workspace_id: string; key: string }) { + const data = await apiCall( + "GET", + `/workspaces/${params.workspace_id}/memory/${encodeURIComponent(params.key)}`, + ); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleListKV(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/memory`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleDeleteKV(params: { workspace_id: string; key: string }) { + const data = await apiCall( + "DELETE", + `/workspaces/${params.workspace_id}/memory/${encodeURIComponent(params.key)}`, + ); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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, + ); +} diff --git a/mcp-server/src/tools/plugins.ts b/mcp-server/src/tools/plugins.ts new file mode 100644 index 00000000..963211b0 --- /dev/null +++ b/mcp-server/src/tools/plugins.ts @@ -0,0 +1,106 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } from "../api.js"; + +export async function handleListPluginRegistry() { + const data = await apiCall("GET", "/plugins"); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleListInstalledPlugins(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/plugins`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleListPluginSources() { + const data = await apiCall("GET", "/plugins/sources"); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleListAvailablePlugins(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/plugins/available`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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://' for platform registry, 'github:///[#]' 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 ?", + { + workspace_id: z.string(), + runtime: z.string().describe("Target runtime (claude-code, deepagents, langgraph, ...)"), + }, + handleCheckPluginCompatibility, + ); +} diff --git a/mcp-server/src/tools/remote_agents.ts b/mcp-server/src/tools/remote_agents.ts new file mode 100644 index 00000000..48171a57 --- /dev/null +++ b/mcp-server/src/tools/remote_agents.ts @@ -0,0 +1,182 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall, PLATFORM_URL } 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + } + const remote = data + .filter((w: { runtime?: string }) => w.runtime === "external") + .map((w: Record) => ({ + 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 { content: [{ type: "text" as const, text: JSON.stringify({ count: remote.length, agents: remote }, null, 2) }] }; +} + +// 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 (data && typeof data === "object" && "error" in data) { + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + } + const w = data as Record; + 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 { content: [{ type: "text" as const, text: JSON.stringify(projected, null, 2) }] }; +} + +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 (ws && typeof ws === "object" && "error" in ws) { + return { content: [{ type: "text" as const, text: JSON.stringify(ws, null, 2) }] }; + } + const w = ws as { id: string; name: string; runtime?: string }; + if (w.runtime !== "external") { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + error: "workspace is not external; setup command only applies to runtime='external'", + workspace_id: w.id, + actual_runtime: w.runtime, + }, null, 2), + }], + }; + } + + // 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 /sdk/python)`, + ``, + `WORKSPACE_ID=${w.id} \\`, + `PLATFORM_URL=${targetUrl} \\`, + `python3 -m examples.remote-agent.run`, + ``, + `# The agent will register, mint its bearer token (cached at`, + `# ~/.molecule/${w.id}/.auth_token), pull secrets, then heartbeat.`, + ].join("\n"); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + workspace_id: w.id, + workspace_name: w.name, + platform_url: targetUrl, + setup_command: setupCmd, + ...(warnings.length > 0 ? { warnings } : {}), + }, null, 2), + }], + }; +} + +export async function handleCheckRemoteAgentFreshness(params: { + workspace_id: string; + threshold_seconds?: number; +}) { + const ws = await apiCall("GET", `/workspaces/${params.workspace_id}`); + if (ws && typeof ws === "object" && "error" in ws) { + return { content: [{ type: "text" as const, text: JSON.stringify(ws, null, 2) }] }; + } + 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 { + content: [{ + type: "text" as const, + text: JSON.stringify({ + workspace_id: params.workspace_id, + status: w.status, + runtime: w.runtime, + last_heartbeat_at: heartbeatStr, + seconds_since_heartbeat: secondsSince, + threshold_seconds: threshold, + fresh, + }, null, 2), + }], + }; +} + +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, + ); +} diff --git a/mcp-server/src/tools/schedules.ts b/mcp-server/src/tools/schedules.ts new file mode 100644 index 00000000..3892a7e6 --- /dev/null +++ b/mcp-server/src/tools/schedules.ts @@ -0,0 +1,131 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } from "../api.js"; + +export async function handleListSchedules(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/schedules`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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, + ); +} diff --git a/mcp-server/src/tools/secrets.ts b/mcp-server/src/tools/secrets.ts new file mode 100644 index 00000000..97e14824 --- /dev/null +++ b/mcp-server/src/tools/secrets.ts @@ -0,0 +1,82 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleListSecrets(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}/secrets`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleListGlobalSecrets() { + const data = await apiCall("GET", "/settings/secrets"); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleSetGlobalSecret(params: { key: string; value: string }) { + const { key, value } = params; + const data = await apiCall("PUT", "/settings/secrets", { key, value }); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleDeleteGlobalSecret(params: { key: string }) { + const data = await apiCall("DELETE", `/settings/secrets/${params.key}`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 + ); +} diff --git a/mcp-server/src/tools/workspaces.ts b/mcp-server/src/tools/workspaces.ts new file mode 100644 index 00000000..dafb6521 --- /dev/null +++ b/mcp-server/src/tools/workspaces.ts @@ -0,0 +1,131 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall } from "../api.js"; + +export async function handleListWorkspaces() { + const data = await apiCall("GET", "/workspaces"); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 }, + }); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleGetWorkspace(params: { workspace_id: string }) { + const data = await apiCall("GET", `/workspaces/${params.workspace_id}`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleDeleteWorkspace(params: { workspace_id: string }) { + const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}?confirm=true`); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleRestartWorkspace(params: { workspace_id: string }) { + const data = await apiCall("POST", `/workspaces/${params.workspace_id}/restart`, {}); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handlePauseWorkspace(params: { workspace_id: string }) { + const data = await apiCall("POST", `/workspaces/${params.workspace_id}/pause`, {}); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +export async function handleResumeWorkspace(params: { workspace_id: string }) { + const data = await apiCall("POST", `/workspaces/${params.workspace_id}/resume`, {}); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +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"), + }, + 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 + ); +} From fa9342aa811112d119b4d920d5f389d1f0fe9f5d Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 13 Apr 2026 14:06:52 -0700 Subject: [PATCH 002/722] =?UTF-8?q?chore:=20structural=20cleanup=20?= =?UTF-8?q?=E2=80=94=20dead=20dirs,=20moves,=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete empty platform/plugins/ (dead remnant; plugins/ at repo root is the real registry; router.go comment updated) - Gitignore local dev cruft: platform/workspace-configs-templates/, .agents/ (codex/gemini skill cache), backups/ - Untrack .agents/skills/ (keep local, stop tracking) - Move examples/remote-agent/ → sdk/python/examples/remote-agent/ (co-locate with the SDK it exercises); update refs in molecule_agent README + __init__ + PLAN.md + the demo's own README - Move docs/superpowers/plans/ → plugins/superpowers/plans/ (plans were written by the superpowers plugin's writing-plans subskill; belong with the plugin, not under docs) - Add tests/README.md explaining the unit-tests-per-package + root-E2E split so new contributors don't ask - Add docs/README.md explaining why site tooling lives under docs/ rather than a separate docs-site/ (VitePress ergonomics) Co-Authored-By: Claude Opus 4.6 (1M context) --- .agents/skills/code-review/SKILL.md | 172 ------------------ .agents/skills/update-docs/SKILL.md | 60 ------ .gitignore | 6 + PLAN.md | 4 +- docs/README.md | 25 +++ platform/internal/router/router.go | 2 +- .../2026-04-08-hermes-borrowing-roadmap.md | 0 .../2026-04-08-hermes-inspired-dx-rollout.md | 0 ...6-04-08-workspace-awareness-integration.md | 0 .../python/examples}/remote-agent/README.md | 2 +- .../python/examples}/remote-agent/run.py | 0 sdk/python/molecule_agent/README.md | 4 +- sdk/python/molecule_agent/__init__.py | 2 +- tests/README.md | 24 +++ 14 files changed, 62 insertions(+), 239 deletions(-) delete mode 100644 .agents/skills/code-review/SKILL.md delete mode 100644 .agents/skills/update-docs/SKILL.md create mode 100644 docs/README.md rename {docs => plugins}/superpowers/plans/2026-04-08-hermes-borrowing-roadmap.md (100%) rename {docs => plugins}/superpowers/plans/2026-04-08-hermes-inspired-dx-rollout.md (100%) rename {docs => plugins}/superpowers/plans/2026-04-08-workspace-awareness-integration.md (100%) rename {examples => sdk/python/examples}/remote-agent/README.md (97%) rename {examples => sdk/python/examples}/remote-agent/run.py (100%) create mode 100644 tests/README.md diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md deleted file mode 100644 index a6954b04..00000000 --- a/.agents/skills/code-review/SKILL.md +++ /dev/null @@ -1,172 +0,0 @@ ---- -name: code-review -description: "Review code for best practices, modularity, scalability, abstraction, test coverage, redundancy, hardcoded values, type safety, performance, naming, API design, async patterns, config/env sync, template consistency, and documentation alignment. Generates detailed report with issues and recommendations." ---- - -# Code Review - -Perform a comprehensive code review of recent changes or specified files to ensure quality standards. - -## Review Criteria - -### 1. Best Practices -- Follows TypeScript strict mode conventions -- Proper error handling (try/catch, error types, no silent failures) -- No hardcoded values (use environment variables or constants) -- Proper logging with appropriate log levels -- Security best practices (input validation, no SQL injection, XSS prevention) -- No console.log in production code (use logger) - -### 2. Modularity -- Single responsibility principle (each function/class does one thing) -- Functions are small and focused (< 50 lines ideally) -- No code duplication (DRY principle) -- Clear separation of concerns (routes, services, utilities) - -### 3. Scalability -- Efficient database queries (proper indexing, no N+1 queries) -- Connection pooling used correctly -- Async operations handled properly -- No blocking operations in hot paths - -### 4. Abstraction -- Interfaces/types defined for all public APIs -- Implementation details hidden behind abstractions -- Adapter pattern used for external services (LLM, database) -- Configuration externalized (not hardcoded) - -### 5. Test Coverage -- Unit tests exist for all utility functions and service functions -- Service layer has integration tests -- Edge cases are covered -- Test files go in `tests/unit/` or `tests/integration/`, named `*.test.ts` -- All exported functions have at least one test - -### 6. No Redundancy -- No duplicate code blocks (extract to shared functions/utilities) -- No repeated logic across files (consolidate into services) -- No redundant imports or unused variables -- No copy-pasted code with minor variations (use parameters/generics) -- No redundant API calls (cache or batch where appropriate) -- No repeated validation logic (create reusable validators) -- No duplicate helper logic in test files (extract shared test utilities) - -### 7. No Hardcoded Values -- No hardcoded URLs, API endpoints, or hostnames (use env vars) -- No hardcoded credentials, keys, or secrets (use env vars) -- No magic numbers without named constants -- No hardcoded file paths (use configuration or path utilities) -- No hardcoded timeouts/limits (externalize to config) -- No hardcoded error messages (use constants or i18n) -- No hardcoded feature flags (use configuration system) -- No hardcoded tenant/user IDs in business logic - -### 8. Type Safety -- No usage of `any` type (use `unknown` or proper types) -- Proper null/undefined handling (optional chaining, nullish coalescing) -- Generic types used appropriately -- Return types explicitly declared for public functions -- No type assertions (`as`) without validation - -### 9. Performance -- No memory leaks (cleanup subscriptions, timers, event listeners) -- Proper memoization for expensive computations -- Lazy loading for heavy components/modules -- Efficient data structures for the use case -- No synchronous operations blocking the event loop -- Batch API calls where possible (e.g., single `messages.modify` with multiple label IDs) - -### 10. Naming & Readability -- Descriptive variable/function names (no `x`, `temp`, `data`) -- Consistent naming conventions (camelCase, PascalCase) -- No misleading names (function does what name suggests) -- Boolean variables prefixed appropriately (`is`, `has`, `should`) -- No excessive abbreviations -- Code is self-documenting where possible - -### 11. API Design -- Consistent response formats across endpoints -- Proper HTTP status codes used -- Input validation at API boundaries -- Proper error response structure -- RESTful conventions followed -- API versioning considered for breaking changes - -### 12. Async & Concurrency -- No unhandled promise rejections -- Proper race condition handling -- Concurrent operations use Promise.all where appropriate -- No floating promises (missing await) -- Proper cleanup on component unmount/request abort -- AbortController used for cancellable operations - -### 13. Dependency Management -- No unused dependencies in package.json -- No deprecated packages -- Security vulnerabilities addressed (npm audit) -- Peer dependency conflicts resolved -- Dependencies pinned to specific versions where needed - -### 14. Environment & Configuration Sync -- Every env var used in `src/config/env.ts` is documented in `.env.example` -- Every env var in `.env.example` is defined in the Zod schema (`src/config/env.ts`) -- Default values match between `.env.example` comments and Zod `.default()` calls -- Conditional requirements are documented (e.g., "only required when LLM_PROVIDER=openai") -- No env vars referenced directly via `process.env` outside of `src/config/env.ts` and `src/lib/logger.ts` -- `docker-compose.yml` service ports/URLs align with `.env.example` defaults -- `Dockerfile` exposes the correct `PORT` matching `.env.example` -- `docs/railway-deployment.md` env var list matches the Zod schema - -### 15. Template & Documentation Consistency -- Email templates in `docs/templates/` have all `{{variable}}` placeholders documented in their "Available Variables" table -- Template variable sources match actual database columns and service outputs -- Classification categories in `docs/classification-design.md` match the `EmailCategory` type in `src/types/email.ts` -- Confidence thresholds in docs match the actual thresholds implemented in code -- Sub-types in docs match the template trigger conditions -- Gmail label names in code (`GmailLabel` const) match labels documented in architecture docs -- API endpoint schemas in `docs/api-spec.md` match actual route handler request/response types -- Error handling strategies in `docs/error-handling.md` match actual retry/error class behavior (e.g., `isRetryable` flags) - -### 16. Error Messages & UX -- User-friendly error messages (no technical jargon) -- Loading states for async operations -- Empty states handled gracefully -- Graceful degradation when features fail -- Confirmation for destructive actions -- Success feedback for completed actions -- Error boundaries to prevent full app crashes -- Proper form validation with clear feedback - -## Output Format - -```markdown -## Code Review Report - -### Files Reviewed -- List of files - -### Issues Found - -#### 🔴 Critical -- [file:line] Description - Recommendation - -#### 🟡 Warning -- [file:line] Description - Recommendation - -#### 🔵 Suggestions -- [file:line] Description - Recommendation - -### Config & Template Sync -- .env.example ↔ env.ts schema: [in sync / N mismatches] -- docs/classification-design.md ↔ src/types/email.ts: [in sync / N mismatches] -- docs/templates/ ↔ template variables: [in sync / N mismatches] -- docs/error-handling.md ↔ src/lib/errors.ts: [in sync / N mismatches] - -### Test Coverage -- Files missing tests -- Coverage gaps - -### Summary -- Total issues count -- Action items -``` diff --git a/.agents/skills/update-docs/SKILL.md b/.agents/skills/update-docs/SKILL.md deleted file mode 100644 index 3870989b..00000000 --- a/.agents/skills/update-docs/SKILL.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: update-docs -description: "Review recent edits and update all documentation including architecture docs, API specs, and edit history. Creates missing docs for new implementations." ---- - -# Update Documentation - -Review recent code changes and update ALL relevant documentation in the `/docs` folder. - -## Steps - -1. **Read today's edit history** - - - Check `docs/edit-history/` for the current date's session file - - Identify all files that were modified - -2. **Analyze changes** - - - Read the modified files to understand what changed - - Categorize changes: new features, bug fixes, architecture changes, API changes, config changes - -3. **Update edit-history session file** - - - Add a summary section at the top describing what was accomplished - - Group related changes under descriptive headings - - Add any missing context about why changes were made - -4. **Update AGENTS.md if needed** - - - New commands or scripts added - - Architecture or key modules changed - - New environment variables required - - New routes or endpoints added - -5. **Update docs/README.md if needed** - - - New features or capabilities - - Changed setup instructions - - Updated project overview - -6. **Update docs/ files** - Review and update all architecture documentation to match current implementation - - **For each doc:** - - - Check if documented features match actual code implementation - - Update outdated sections to reflect current code - - Add NEW sections for features that are implemented but not documented - - Remove or mark deprecated features that no longer exist - - Ensure code examples match actual implementation - -7. **Create new docs if needed** - - - If a significant new feature or module was added but has no documentation, create appropriate documentation - - Follow existing documentation style and structure - -8. **Report summary** - - List all documentation files updated - - Note any new documentation files created - - Summarize key changes documented diff --git a/.gitignore b/.gitignore index f1ec7587..c738933a 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,12 @@ redis_data/ # Workspace instance configs (auto-generated by provisioner, not templates) workspace-configs-templates/ws-* +# Local dev cruft — provisioner writes here at runtime; templates live at repo root +platform/workspace-configs-templates/ + +# Codex/Gemini agent skill cache (local only, not authoritative) +.agents/ + # Workspace runtime markers (written by agent containers, not committed) .initial_prompt_done diff --git a/PLAN.md b/PLAN.md index ff49f4ba..1f2a268b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -139,7 +139,7 @@ for the full code audit. - [x] **30.8 Remote-agent SDK + docs** — `sdk/python/molecule_agent/` thin client: register → pull secrets → run A2A loop → poll state → - heartbeat. Working `examples/remote-agent/` a new user can run on a + heartbeat. Working `sdk/python/examples/remote-agent/` a new user can run on a laptop. Remove the three feature flags. Remote workspaces become GA. ### Out of scope for Phase 30 @@ -155,7 +155,7 @@ for the full code audit. ### Success criteria -- `examples/remote-agent/` boots on a laptop disconnected from the +- `sdk/python/examples/remote-agent/` boots on a laptop disconnected from the platform's LAN, registers, receives a task from parent PM via A2A, returns a result, appears on the canvas. - `tests/e2e/test_federation.sh` spawns a second platform instance + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..fc9b779c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,25 @@ +# docs/ + +This directory serves two purposes: + +1. **Markdown content** — everything under `architecture/`, `agent-runtime/`, `api-protocol/`, `development/`, `frontend/`, `plugins/`, `product/`, etc. This is what agents and humans read. +2. **VitePress site** — `.vitepress/config.ts`, `package.json`, `package-lock.json`. These drive the rendered documentation site. + +## Local preview + +```bash +cd docs +npm install +npm run dev # preview on http://localhost:5173 +npm run build # static build to docs/.vitepress/dist/ +``` + +## Conventions + +- New top-level docs must be linked from `PLAN.md`, `README.md`, and `CLAUDE.md` — otherwise agents can't find them (see `.claude/` memory `feedback_cross_reference_docs.md`). +- `edit-history/YYYY-MM-DD.md` is append-only log of significant changes; don't rewrite history. +- `archive/` holds one-shot analyses and retired docs — kept for context but not maintained. + +## Why site tooling lives here (not in `docs-site/`) + +VitePress expects its config at `/.vitepress/config.ts` where `` is also the content directory. Splitting tooling into a sibling `docs-site/` would require a non-trivial `srcDir` shim and break relative links in `.vitepress/config.ts`. Keeping both together is the pragmatic choice; this README is the tradeoff ledger. diff --git a/platform/internal/router/router.go b/platform/internal/router/router.go index e394232f..4c46e057 100644 --- a/platform/internal/router/router.go +++ b/platform/internal/router/router.go @@ -270,7 +270,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi } func findPluginsDir(configsDir string) string { - // configsDir-relative is most reliable (avoids empty platform/plugins/) + // configsDir-relative is most reliable; plugins live at repo-root plugins/ candidates := []string{ filepath.Join(configsDir, "..", "plugins"), "../plugins", diff --git a/docs/superpowers/plans/2026-04-08-hermes-borrowing-roadmap.md b/plugins/superpowers/plans/2026-04-08-hermes-borrowing-roadmap.md similarity index 100% rename from docs/superpowers/plans/2026-04-08-hermes-borrowing-roadmap.md rename to plugins/superpowers/plans/2026-04-08-hermes-borrowing-roadmap.md diff --git a/docs/superpowers/plans/2026-04-08-hermes-inspired-dx-rollout.md b/plugins/superpowers/plans/2026-04-08-hermes-inspired-dx-rollout.md similarity index 100% rename from docs/superpowers/plans/2026-04-08-hermes-inspired-dx-rollout.md rename to plugins/superpowers/plans/2026-04-08-hermes-inspired-dx-rollout.md diff --git a/docs/superpowers/plans/2026-04-08-workspace-awareness-integration.md b/plugins/superpowers/plans/2026-04-08-workspace-awareness-integration.md similarity index 100% rename from docs/superpowers/plans/2026-04-08-workspace-awareness-integration.md rename to plugins/superpowers/plans/2026-04-08-workspace-awareness-integration.md diff --git a/examples/remote-agent/README.md b/sdk/python/examples/remote-agent/README.md similarity index 97% rename from examples/remote-agent/README.md rename to sdk/python/examples/remote-agent/README.md index 13969a03..fb3f6714 100644 --- a/examples/remote-agent/README.md +++ b/sdk/python/examples/remote-agent/README.md @@ -27,7 +27,7 @@ curl -s -X POST http://localhost:8080/workspaces//secrets \ # 3. Run the demo from any machine that can reach the platform: WORKSPACE_ID= PLATFORM_URL=http://localhost:8080 \ - python3 examples/remote-agent/run.py + python3 sdk/python/examples/remote-agent/run.py ``` You should see log lines for each of the three phases, and then diff --git a/examples/remote-agent/run.py b/sdk/python/examples/remote-agent/run.py similarity index 100% rename from examples/remote-agent/run.py rename to sdk/python/examples/remote-agent/run.py diff --git a/sdk/python/molecule_agent/README.md b/sdk/python/molecule_agent/README.md index 2d3ae27b..f1368bef 100644 --- a/sdk/python/molecule_agent/README.md +++ b/sdk/python/molecule_agent/README.md @@ -44,7 +44,7 @@ print(f"loop exited: {terminal}") ``` A runnable demo with full setup walkthrough lives at -[`examples/remote-agent/`](../../../examples/remote-agent). +[`sdk/python/examples/remote-agent/`](../../examples/remote-agent). ## What the SDK gives you @@ -93,5 +93,5 @@ the security benefits of bearer auth until both sides upgrade. - [`molecule_plugin`](../molecule_plugin) — the *other* SDK in this package, for plugin authors. Different audience. -- [`examples/remote-agent/run.py`](../../../examples/remote-agent/run.py) +- [`sdk/python/examples/remote-agent/run.py`](../../examples/remote-agent/run.py) — the runnable demo that proves all of the above end-to-end. diff --git a/sdk/python/molecule_agent/__init__.py b/sdk/python/molecule_agent/__init__.py index 9767317a..029aa298 100644 --- a/sdk/python/molecule_agent/__init__.py +++ b/sdk/python/molecule_agent/__init__.py @@ -20,7 +20,7 @@ Intended usage:: env = client.pull_secrets() # decrypted secrets dict client.run_heartbeat_loop() # background heartbeat + state-poll -See ``examples/remote-agent/`` for a runnable demo. +See ``sdk/python/examples/remote-agent/`` for a runnable demo. Design notes: * **No async.** The SDK uses blocking ``requests`` so a remote agent author diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..bbff0ea9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,24 @@ +# Tests + +This repo uses the standard monorepo testing convention: **unit tests live with their package, cross-component E2E tests live here.** + +## Where to find tests + +| Scope | Location | +|---|---| +| Go unit + integration (platform, CLI, handlers) | `platform/**/*_test.go` — run with `cd platform && go test -race ./...` | +| TypeScript unit (canvas components, hooks, store) | `canvas/src/**/__tests__/` — run with `cd canvas && npm test -- --run` | +| TypeScript unit (MCP server handlers) | `mcp-server/src/__tests__/` — run with `cd mcp-server && npx jest` | +| Python unit (workspace runtime, adapters) | `workspace-template/tests/` — run with `cd workspace-template && python3 -m pytest` | +| Python unit (SDK: plugin + remote agent) | `sdk/python/tests/` — run with `cd sdk/python && python3 -m pytest` | +| **Cross-component E2E** (spans platform + runtime + HTTP) | `tests/e2e/` ← **you are here** | + +## Why split this way + +- **Go** requires co-located `_test.go` files to access unexported symbols. +- **Per-package test commands** keep the inner loop fast — changing canvas doesn't re-run Go tests. +- **`tests/e2e/`** covers scenarios that no single package owns: a full workspace lifecycle, A2A across two provisioned agents, delegation chains, bundle round-trips. + +## Running E2E + +Every E2E script here assumes the platform is running at `localhost:8080` and (where noted) provisioned agents are online. See the header comment of each `.sh` for specifics. From 6875537e2c83b0e963e571ed89d2745e7d85067e Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 13 Apr 2026 14:09:21 -0700 Subject: [PATCH 003/722] fix(mcp-server): setup_command references real module, not broken path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The get_remote_agent_setup_command handler emitted \`python3 -m examples.remote-agent.run\` — an invalid Python module path (dashes not allowed in module names), so the command never actually worked. Replace with a direct \`python3 -c "..."\` snippet that imports from \`molecule_agent\` (the real SDK module) and points to the demo script for reference. Fixes the pre-existing jest failure in \`handleGetRemoteAgentSetupCommand emits bash for external workspace\` that was flagged against PR #2. Updates test expectation to \`molecule_agent\` (the actual importable module name) from the never-valid \`molecule-agent\`. Co-Authored-By: Claude Opus 4.6 (1M context) --- mcp-server/src/__tests__/index.test.ts | 2 +- mcp-server/src/tools/remote_agents.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/mcp-server/src/__tests__/index.test.ts b/mcp-server/src/__tests__/index.test.ts index 25c0cb1f..1bbb1167 100644 --- a/mcp-server/src/__tests__/index.test.ts +++ b/mcp-server/src/__tests__/index.test.ts @@ -1083,7 +1083,7 @@ describe("Phase 30 remote-agent tools", () => { expect(body.workspace_name).toBe("remote-1"); expect(body.setup_command).toContain("WORKSPACE_ID=ws-ext"); expect(body.setup_command).toContain("PLATFORM_URL="); - expect(body.setup_command).toContain("molecule-agent"); + expect(body.setup_command).toContain("molecule_agent"); }); test("handleCheckRemoteAgentFreshness fresh when heartbeat is recent", async () => { diff --git a/mcp-server/src/tools/remote_agents.ts b/mcp-server/src/tools/remote_agents.ts index 48171a57..0af3c04f 100644 --- a/mcp-server/src/tools/remote_agents.ts +++ b/mcp-server/src/tools/remote_agents.ts @@ -94,8 +94,13 @@ export async function handleGetRemoteAgentSetupCommand(params: { ``, `WORKSPACE_ID=${w.id} \\`, `PLATFORM_URL=${targetUrl} \\`, - `python3 -m examples.remote-agent.run`, + `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"); From af931aa8da748e3b5f9a861ba90220f7a2d7d0df Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 13 Apr 2026 14:26:17 -0700 Subject: [PATCH 004/722] refactor(mcp-server): DRY envelopes, typed apiCall, explicit re-exports Second-pass cleanup after the monolith split. Addresses every issue from the code-review pass. Core additions in src/api.ts: - toMcpResult(data) + toMcpText(text): single source of truth for the MCP text-content envelope (was ~87 duplicated literals) - ApiError type + isApiError(v) guard: typed discriminated-union for the error-by-value pattern; replaces open-coded shape checks - apiCall: generic so callers can document expected response shape without unchecked "as" casts Bulk cleanups across all 12 tools/*.ts: - Every handler now returns toMcpResult(data) or toMcpText(text) - Open-coded "typeof obj === 'object' && 'error' in obj" in remote_agents.ts replaced with isApiError(v) - Extracted initialCanvasPosition() helper out of handleCreateWorkspace; explains why random seeding exists - Added runtime/workspace_dir/workspace_access to create_workspace zod schema (previously accepted by handler but hidden from clients) src/index.ts: - Replaced "export * from" with explicit named re-exports so the public surface is auditable and future name collisions fail loudly Tests: - createServer() smoke test that records every srv.tool(...) call and asserts 87 registered tools unique by name. Catches future PRs that forget to wire a registerXxxTools(srv). Docs: - Fix broken relative links in sdk/python/molecule_agent/README.md (was ../../examples/ from inside sdk/python/, should be ../examples/) - Update stale "61 tools" -> "87 tools" in CLAUDE.md + main() log Verification: - npm run build clean - npx jest -> 97/97 passed (was 96; +1 smoke test) - grep "content: [{ type: \"text\" as const" src/tools/ -> 0 matches - No file over 216 lines Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- mcp-server/src/__tests__/index.test.ts | 21 +++- mcp-server/src/api.ts | 39 ++++++- mcp-server/src/index.ts | 155 ++++++++++++++++++++++--- mcp-server/src/tools/agents.ts | 34 +++--- mcp-server/src/tools/approvals.ts | 10 +- mcp-server/src/tools/channels.ts | 22 ++-- mcp-server/src/tools/delegation.ts | 18 +-- mcp-server/src/tools/discovery.ts | 30 ++--- mcp-server/src/tools/files.ts | 19 +-- mcp-server/src/tools/memory.ts | 20 ++-- mcp-server/src/tools/plugins.ts | 16 +-- mcp-server/src/tools/remote_agents.ts | 77 +++++------- mcp-server/src/tools/schedules.ts | 14 +-- mcp-server/src/tools/secrets.ts | 14 +-- mcp-server/src/tools/workspaces.ts | 29 +++-- sdk/python/molecule_agent/README.md | 4 +- 17 files changed, 351 insertions(+), 173 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2532e48d..c6d163fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,7 @@ cd mcp-server npm install && npm run build # Build MCP server node dist/index.js # Run (stdio transport) ``` -Exposes 61 tools for managing Molecule AI from Claude Code, Cursor, Codex, or any MCP client. Includes workspace CRUD, async delegation, plugins (install/uninstall/list), global secrets, pause/resume, org import, A2A chat, approvals, memory, files, config, discovery, bundles, templates, traces, activity logs, and social channels (add/update/remove/send/test). Configured in `.mcp.json`. Env: `MOLECULE_URL` (default http://localhost:8080). +Exposes 87 tools for managing Molecule AI from Claude Code, Cursor, Codex, or any MCP client. Includes workspace CRUD, async delegation, plugins (install/uninstall/list), global secrets, pause/resume, org import, A2A chat, approvals, memory, files, config, discovery, bundles, templates, traces, activity logs, and social channels (add/update/remove/send/test). Configured in `.mcp.json`. Env: `MOLECULE_URL` (default http://localhost:8080). ### CI Pipeline GitHub Actions (`.github/workflows/ci.yml`) runs on push to main and PRs: diff --git a/mcp-server/src/__tests__/index.test.ts b/mcp-server/src/__tests__/index.test.ts index 1bbb1167..b1913179 100644 --- a/mcp-server/src/__tests__/index.test.ts +++ b/mcp-server/src/__tests__/index.test.ts @@ -7,9 +7,13 @@ // Jest hoists these mock calls before imports, so the MCP SDK is // mocked before index.ts is loaded (preventing stdio/server side-effects). +// The mock McpServer records every tool(name, ...) call on an instance +// property so the createServer() smoke test can assert the registered count +// without reaching into the real SDK's private `_registeredTools` field. jest.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({ McpServer: class { - tool() {} + registeredToolNames: string[] = []; + tool(name: string) { this.registeredToolNames.push(name); } connect() { return Promise.resolve(); } }, })); @@ -850,6 +854,21 @@ describe("createServer()", () => { expect(server).toBeDefined(); expect(typeof server.connect).toBe("function"); }); + + // Smoke test: every registerXxxTools(srv) wiring in createServer() runs, + // and each tool() call is recorded by the mocked McpServer above. If a + // future PR adds a tool file but forgets to call its registerXxxTools + // from createServer(), this count drops and the test fails. We assert + // the concrete current tool count (87) rather than a lower bound so a + // silently-dropped handler is also caught. + test("registers all tools (count is stable across registerXxxTools wiring)", () => { + const server = createServer() as unknown as { registeredToolNames: string[] }; + const names = server.registeredToolNames; + expect(names.length).toBe(87); + // Names must be unique — a duplicate registration would indicate a + // copy-paste mistake in one of the registerXxxTools() calls. + expect(new Set(names).size).toBe(names.length); + }); }); // ============================================================ diff --git a/mcp-server/src/api.ts b/mcp-server/src/api.ts index 20428a97..e9e9a11c 100644 --- a/mcp-server/src/api.ts +++ b/mcp-server/src/api.ts @@ -8,7 +8,39 @@ export const PLATFORM_URL = process.env.PLATFORM_URL || "http://localhost:8080"; -export async function apiCall(method: string, path: string, body?: unknown) { +/** + * 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( + method: string, + path: string, + body?: unknown, +): Promise { try { const res = await fetch(`${PLATFORM_URL}${path}`, { method, @@ -21,12 +53,13 @@ export async function apiCall(method: string, path: string, body?: unknown) { } const text = await res.text(); try { - return JSON.parse(text); + return JSON.parse(text) as T; } catch { - return { raw: text, status: res.status }; + 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 }; } diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index a6d1c735..61587546 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -27,19 +27,146 @@ import { registerDiscoveryTools } from "./tools/discovery.js"; import { registerRemoteAgentTools } from "./tools/remote_agents.js"; // Re-exports so existing importers (tests, SDK consumers) keep working. -export { PLATFORM_URL, apiCall }; -export * from "./tools/workspaces.js"; -export * from "./tools/agents.js"; -export * from "./tools/secrets.js"; -export * from "./tools/files.js"; -export * from "./tools/memory.js"; -export * from "./tools/plugins.js"; -export * from "./tools/channels.js"; -export * from "./tools/delegation.js"; -export * from "./tools/schedules.js"; -export * from "./tools/approvals.js"; -export * from "./tools/discovery.js"; -export * from "./tools/remote_agents.js"; +// 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({ @@ -79,7 +206,7 @@ async function main() { const server = createServer(); const transport = new StdioServerTransport(); await server.connect(transport); - console.error("Molecule AI MCP server running on stdio (61 tools available)"); + console.error("Molecule AI MCP server running on stdio (87 tools available)"); } // Only auto-start when run directly (not when imported for testing). diff --git a/mcp-server/src/tools/agents.ts b/mcp-server/src/tools/agents.ts index f0cd5dd0..8438f617 100644 --- a/mcp-server/src/tools/agents.ts +++ b/mcp-server/src/tools/agents.ts @@ -1,49 +1,53 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +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("POST", `/workspaces/${workspace_id}/a2a`, { - method: "message/send", - params: { - message: { role: "user", parts: [{ type: "text", text: message }] }, + 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?.result?.parts || []; + ); + const parts = (data as { result?: { parts?: Array<{ kind?: string; text?: string }> } } | null)?.result?.parts || []; const text = parts - .filter((p: { kind?: string }) => p.kind === "text") - .map((p: { text?: string }) => p.text || "") + .filter((p) => p.kind === "text") + .map((p) => p.text || "") .join("\n"); - return { content: [{ type: "text" as const, text: text || JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleRemoveAgent(params: { workspace_id: string }) { const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}/agent`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleGetModel(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/model`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerAgentTools(srv: McpServer) { diff --git a/mcp-server/src/tools/approvals.ts b/mcp-server/src/tools/approvals.ts index 3d4178b1..038bb7fb 100644 --- a/mcp-server/src/tools/approvals.ts +++ b/mcp-server/src/tools/approvals.ts @@ -1,10 +1,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +import { apiCall, toMcpResult } from "../api.js"; export async function handleListPendingApprovals() { const data = await apiCall("GET", "/approvals/pending"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleDecideApproval(params: { @@ -18,7 +18,7 @@ export async function handleDecideApproval(params: { `/workspaces/${workspace_id}/approvals/${approval_id}/decide`, { decision, decided_by: "mcp-client" } ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleCreateApproval(params: { @@ -28,12 +28,12 @@ export async function handleCreateApproval(params: { }) { const { workspace_id, action, reason } = params; const data = await apiCall("POST", `/workspaces/${workspace_id}/approvals`, { action, reason }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleGetWorkspaceApprovals(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/approvals`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerApprovalTools(srv: McpServer) { diff --git a/mcp-server/src/tools/channels.ts b/mcp-server/src/tools/channels.ts index c8070e58..71d227a7 100644 --- a/mcp-server/src/tools/channels.ts +++ b/mcp-server/src/tools/channels.ts @@ -1,15 +1,15 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +import { apiCall, toMcpResult, toMcpText } from "../api.js"; export async function handleListChannelAdapters() { const data = await apiCall("GET", `/channels/adapters`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListChannels(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/channels`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleAddChannel(params: { @@ -19,14 +19,14 @@ export async function handleAddChannel(params: { allowed_users?: string; }) { let config: unknown; - try { config = JSON.parse(params.config); } catch { return { content: [{ type: "text" as const, text: "Error: config is not valid JSON" }] }; } + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleUpdateChannel(params: { @@ -38,19 +38,19 @@ export async function handleUpdateChannel(params: { }) { const body: Record = {}; if (params.config) { - try { body.config = JSON.parse(params.config); } catch { return { content: [{ type: "text" as const, text: "Error: config is not valid JSON" }] }; } + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleSendChannelMessage(params: { @@ -61,12 +61,12 @@ export async function handleSendChannelMessage(params: { const data = await apiCall("POST", `/workspaces/${params.workspace_id}/channels/${params.channel_id}/send`, { text: params.text, }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleDiscoverChannelChats(params: { @@ -74,7 +74,7 @@ export async function handleDiscoverChannelChats(params: { config: Record; }) { const data = await apiCall("POST", "/channels/discover", params); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerChannelTools(srv: McpServer) { diff --git a/mcp-server/src/tools/delegation.ts b/mcp-server/src/tools/delegation.ts index 6e892f1f..120c4fe1 100644 --- a/mcp-server/src/tools/delegation.ts +++ b/mcp-server/src/tools/delegation.ts @@ -1,6 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +import { apiCall, toMcpResult } from "../api.js"; export async function handleAsyncDelegate(params: { workspace_id: string; @@ -9,12 +9,12 @@ export async function handleAsyncDelegate(params: { }) { const { workspace_id, target_id, task } = params; const data = await apiCall("POST", `/workspaces/${workspace_id}/delegate`, { target_id, task }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleCheckDelegations(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/delegations`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleRecordDelegation(params: { @@ -25,7 +25,7 @@ export async function handleRecordDelegation(params: { }) { const { workspace_id, ...body } = params; const data = await apiCall("POST", `/workspaces/${workspace_id}/delegations/record`, body); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleUpdateDelegationStatus(params: { @@ -41,7 +41,7 @@ export async function handleUpdateDelegationStatus(params: { `/workspaces/${workspace_id}/delegations/${delegation_id}/update`, body, ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleReportActivity(params: { @@ -57,7 +57,7 @@ export async function handleReportActivity(params: { }) { const { workspace_id, ...body } = params; const data = await apiCall("POST", `/workspaces/${workspace_id}/activity`, body); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListActivity(params: { @@ -71,7 +71,7 @@ export async function handleListActivity(params: { if (limit) urlParams.set("limit", String(limit)); const qs = urlParams.toString() ? `?${urlParams.toString()}` : ""; const data = await apiCall("GET", `/workspaces/${workspace_id}/activity${qs}`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleNotifyUser(params: { @@ -81,12 +81,12 @@ export async function handleNotifyUser(params: { }) { const { workspace_id, ...body } = params; const data = await apiCall("POST", `/workspaces/${workspace_id}/notify`, body); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListTraces(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/traces`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerDelegationTools(srv: McpServer) { diff --git a/mcp-server/src/tools/discovery.ts b/mcp-server/src/tools/discovery.ts index 5c3936f9..3707df81 100644 --- a/mcp-server/src/tools/discovery.ts +++ b/mcp-server/src/tools/discovery.ts @@ -1,78 +1,78 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleDiscoverWorkspace(params: { workspace_id: string }) { const data = await apiCall("GET", `/registry/discover/${params.workspace_id}`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListTemplates() { const data = await apiCall("GET", "/templates"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListOrgTemplates() { const data = await apiCall("GET", "/org/templates"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleImportOrg(params: { dir: string }) { const data = await apiCall("POST", "/org/import", { dir: params.dir }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleImportTemplate(params: { name: string; files: Record }) { const { name, files } = params; const data = await apiCall("POST", `/templates/import`, { name, files }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleExportBundle(params: { workspace_id: string }) { const data = await apiCall("GET", `/bundles/export/${params.workspace_id}`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleImportBundle(params: { bundle: Record }) { const data = await apiCall("POST", `/bundles/import`, params.bundle); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleGetViewport() { const data = await apiCall("GET", "/canvas/viewport"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleSetViewport(params: { x: number; y: number; zoom: number }) { const data = await apiCall("PUT", "/canvas/viewport", params); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleExpandTeam(params: { workspace_id: string }) { const data = await apiCall("POST", `/workspaces/${params.workspace_id}/expand`, {}); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleCollapseTeam(params: { workspace_id: string }) { const data = await apiCall("POST", `/workspaces/${params.workspace_id}/collapse`, {}); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerDiscoveryTools(srv: McpServer) { diff --git a/mcp-server/src/tools/files.ts b/mcp-server/src/tools/files.ts index 132ed63c..1597c7be 100644 --- a/mcp-server/src/tools/files.ts +++ b/mcp-server/src/tools/files.ts @@ -1,28 +1,29 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleReadFile(params: { workspace_id: string; path: string }) { const { workspace_id, path } = params; - const data = await apiCall("GET", `/workspaces/${workspace_id}/files/${path}`); - return { content: [{ type: "text" as const, text: data?.content || JSON.stringify(data) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleReplaceAllFiles(params: { @@ -31,18 +32,18 @@ export async function handleReplaceAllFiles(params: { }) { const { workspace_id, files } = params; const data = await apiCall("PUT", `/workspaces/${workspace_id}/files`, { files }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleGetConfig(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/config`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleUpdateConfig(params: { workspace_id: string; config: Record }) { const { workspace_id, config } = params; const data = await apiCall("PATCH", `/workspaces/${workspace_id}/config`, config); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerFileTools(srv: McpServer) { diff --git a/mcp-server/src/tools/memory.ts b/mcp-server/src/tools/memory.ts index 19c4d419..a2dd9ed6 100644 --- a/mcp-server/src/tools/memory.ts +++ b/mcp-server/src/tools/memory.ts @@ -1,6 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +import { apiCall, toMcpResult } from "../api.js"; export async function handleCommitMemory(params: { workspace_id: string; @@ -9,7 +9,7 @@ export async function handleCommitMemory(params: { }) { const { workspace_id, content, scope } = params; const data = await apiCall("POST", `/workspaces/${workspace_id}/memories`, { content, scope }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleSearchMemory(params: { @@ -22,13 +22,13 @@ export async function handleSearchMemory(params: { if (query) urlParams.set("q", query); if (scope) urlParams.set("scope", scope); const data = await apiCall("GET", `/workspaces/${workspace_id}/memories?${urlParams}`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleSessionSearch(params: { @@ -42,12 +42,12 @@ export async function handleSessionSearch(params: { 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleGetSharedContext(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/shared-context`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleSetKV(params: { @@ -58,7 +58,7 @@ export async function handleSetKV(params: { }) { const { workspace_id, ...body } = params; const data = await apiCall("POST", `/workspaces/${workspace_id}/memory`, body); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleGetKV(params: { workspace_id: string; key: string }) { @@ -66,12 +66,12 @@ export async function handleGetKV(params: { workspace_id: string; key: string }) "GET", `/workspaces/${params.workspace_id}/memory/${encodeURIComponent(params.key)}`, ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListKV(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/memory`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleDeleteKV(params: { workspace_id: string; key: string }) { @@ -79,7 +79,7 @@ export async function handleDeleteKV(params: { workspace_id: string; key: string "DELETE", `/workspaces/${params.workspace_id}/memory/${encodeURIComponent(params.key)}`, ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerMemoryTools(srv: McpServer) { diff --git a/mcp-server/src/tools/plugins.ts b/mcp-server/src/tools/plugins.ts index 963211b0..f3210c70 100644 --- a/mcp-server/src/tools/plugins.ts +++ b/mcp-server/src/tools/plugins.ts @@ -1,37 +1,37 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +import { apiCall, toMcpResult } from "../api.js"; export async function handleListPluginRegistry() { const data = await apiCall("GET", "/plugins"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListInstalledPlugins(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/plugins`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListPluginSources() { const data = await apiCall("GET", "/plugins/sources"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListAvailablePlugins(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/plugins/available`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleCheckPluginCompatibility(params: { @@ -43,7 +43,7 @@ export async function handleCheckPluginCompatibility(params: { "GET", `/workspaces/${workspace_id}/plugins/compatibility?runtime=${encodeURIComponent(runtime)}`, ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerPluginTools(srv: McpServer) { diff --git a/mcp-server/src/tools/remote_agents.ts b/mcp-server/src/tools/remote_agents.ts index 0af3c04f..555281c9 100644 --- a/mcp-server/src/tools/remote_agents.ts +++ b/mcp-server/src/tools/remote_agents.ts @@ -1,6 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall, PLATFORM_URL } from "../api.js"; +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 @@ -9,7 +9,7 @@ import { apiCall, PLATFORM_URL } from "../api.js"; export async function handleListRemoteAgents() { const data = await apiCall("GET", "/workspaces"); if (!Array.isArray(data)) { - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } const remote = data .filter((w: { runtime?: string }) => w.runtime === "external") @@ -22,7 +22,7 @@ export async function handleListRemoteAgents() { uptime_seconds: w.uptime_seconds, tier: w.tier, })); - return { content: [{ type: "text" as const, text: JSON.stringify({ count: remote.length, agents: remote }, null, 2) }] }; + return toMcpResult({ count: remote.length, agents: remote }); } // Phase 30.4 — token-gated; from MCP we don't have a workspace bearer @@ -31,8 +31,8 @@ export async function handleListRemoteAgents() { // 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 (data && typeof data === "object" && "error" in data) { - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + if (isApiError(data)) { + return toMcpResult(data); } const w = data as Record; const projected = { @@ -43,7 +43,7 @@ export async function handleGetRemoteAgentState(params: { workspace_id: string } runtime: w.runtime, last_heartbeat_at: w.last_heartbeat_at, }; - return { content: [{ type: "text" as const, text: JSON.stringify(projected, null, 2) }] }; + return toMcpResult(projected); } export async function handleGetRemoteAgentSetupCommand(params: { @@ -54,21 +54,16 @@ export async function handleGetRemoteAgentSetupCommand(params: { // 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 (ws && typeof ws === "object" && "error" in ws) { - return { content: [{ type: "text" as const, text: JSON.stringify(ws, null, 2) }] }; + if (isApiError(ws)) { + return toMcpResult(ws); } const w = ws as { id: string; name: string; runtime?: string }; if (w.runtime !== "external") { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: "workspace is not external; setup command only applies to runtime='external'", - workspace_id: w.id, - actual_runtime: w.runtime, - }, null, 2), - }], - }; + 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 @@ -104,18 +99,13 @@ export async function handleGetRemoteAgentSetupCommand(params: { `# The agent will register, mint its bearer token (cached at`, `# ~/.molecule/${w.id}/.auth_token), pull secrets, then heartbeat.`, ].join("\n"); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - workspace_id: w.id, - workspace_name: w.name, - platform_url: targetUrl, - setup_command: setupCmd, - ...(warnings.length > 0 ? { warnings } : {}), - }, null, 2), - }], - }; + 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: { @@ -123,8 +113,8 @@ export async function handleCheckRemoteAgentFreshness(params: { threshold_seconds?: number; }) { const ws = await apiCall("GET", `/workspaces/${params.workspace_id}`); - if (ws && typeof ws === "object" && "error" in ws) { - return { content: [{ type: "text" as const, text: JSON.stringify(ws, null, 2) }] }; + if (isApiError(ws)) { + return toMcpResult(ws); } const w = ws as { last_heartbeat_at?: string; status?: string; runtime?: string }; const threshold = params.threshold_seconds ?? 90; @@ -137,20 +127,15 @@ export async function handleCheckRemoteAgentFreshness(params: { } } const fresh = secondsSince !== null && secondsSince <= threshold; - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - workspace_id: params.workspace_id, - status: w.status, - runtime: w.runtime, - last_heartbeat_at: heartbeatStr, - seconds_since_heartbeat: secondsSince, - threshold_seconds: threshold, - fresh, - }, null, 2), - }], - }; + 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) { diff --git a/mcp-server/src/tools/schedules.ts b/mcp-server/src/tools/schedules.ts index 3892a7e6..370f2359 100644 --- a/mcp-server/src/tools/schedules.ts +++ b/mcp-server/src/tools/schedules.ts @@ -1,10 +1,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleCreateSchedule(params: { @@ -17,7 +17,7 @@ export async function handleCreateSchedule(params: { }) { const { workspace_id, ...body } = params; const data = await apiCall("POST", `/workspaces/${workspace_id}/schedules`, body); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleUpdateSchedule(params: { @@ -35,7 +35,7 @@ export async function handleUpdateSchedule(params: { `/workspaces/${workspace_id}/schedules/${schedule_id}`, body, ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleDeleteSchedule(params: { @@ -46,7 +46,7 @@ export async function handleDeleteSchedule(params: { "DELETE", `/workspaces/${params.workspace_id}/schedules/${params.schedule_id}`, ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleRunSchedule(params: { @@ -57,7 +57,7 @@ export async function handleRunSchedule(params: { "POST", `/workspaces/${params.workspace_id}/schedules/${params.schedule_id}/run`, ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleGetScheduleHistory(params: { @@ -68,7 +68,7 @@ export async function handleGetScheduleHistory(params: { "GET", `/workspaces/${params.workspace_id}/schedules/${params.schedule_id}/history`, ); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerScheduleTools(srv: McpServer) { diff --git a/mcp-server/src/tools/secrets.ts b/mcp-server/src/tools/secrets.ts index 97e14824..061bc64a 100644 --- a/mcp-server/src/tools/secrets.ts +++ b/mcp-server/src/tools/secrets.ts @@ -1,38 +1,38 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListSecrets(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}/secrets`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleListGlobalSecrets() { const data = await apiCall("GET", "/settings/secrets"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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 { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleDeleteGlobalSecret(params: { key: string }) { const data = await apiCall("DELETE", `/settings/secrets/${params.key}`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerSecretTools(srv: McpServer) { diff --git a/mcp-server/src/tools/workspaces.ts b/mcp-server/src/tools/workspaces.ts index dafb6521..b82b6767 100644 --- a/mcp-server/src/tools/workspaces.ts +++ b/mcp-server/src/tools/workspaces.ts @@ -1,10 +1,16 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { apiCall } from "../api.js"; +import { apiCall, toMcpResult } from "../api.js"; export async function handleListWorkspaces() { const data = await apiCall("GET", "/workspaces"); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + 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: { @@ -21,24 +27,24 @@ export async function handleCreateWorkspace(params: { const data = await apiCall("POST", "/workspaces", { name, role, template, tier, parent_id, runtime, workspace_dir, workspace_access, - canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 }, + canvas: initialCanvasPosition(), }); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleGetWorkspace(params: { workspace_id: string }) { const data = await apiCall("GET", `/workspaces/${params.workspace_id}`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleDeleteWorkspace(params: { workspace_id: string }) { const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}?confirm=true`); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleRestartWorkspace(params: { workspace_id: string }) { const data = await apiCall("POST", `/workspaces/${params.workspace_id}/restart`, {}); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleUpdateWorkspace(params: { @@ -52,17 +58,17 @@ export async function handleUpdateWorkspace(params: { }) { const { workspace_id, ...fields } = params; const data = await apiCall("PATCH", `/workspaces/${workspace_id}`, fields); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handlePauseWorkspace(params: { workspace_id: string }) { const data = await apiCall("POST", `/workspaces/${params.workspace_id}/pause`, {}); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export async function handleResumeWorkspace(params: { workspace_id: string }) { const data = await apiCall("POST", `/workspaces/${params.workspace_id}/resume`, {}); - return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + return toMcpResult(data); } export function registerWorkspaceTools(srv: McpServer) { @@ -77,6 +83,9 @@ export function registerWorkspaceTools(srv: McpServer) { 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 ); diff --git a/sdk/python/molecule_agent/README.md b/sdk/python/molecule_agent/README.md index f1368bef..34c89f00 100644 --- a/sdk/python/molecule_agent/README.md +++ b/sdk/python/molecule_agent/README.md @@ -44,7 +44,7 @@ print(f"loop exited: {terminal}") ``` A runnable demo with full setup walkthrough lives at -[`sdk/python/examples/remote-agent/`](../../examples/remote-agent). +[`sdk/python/examples/remote-agent/`](../examples/remote-agent). ## What the SDK gives you @@ -93,5 +93,5 @@ the security benefits of bearer auth until both sides upgrade. - [`molecule_plugin`](../molecule_plugin) — the *other* SDK in this package, for plugin authors. Different audience. -- [`sdk/python/examples/remote-agent/run.py`](../../examples/remote-agent/run.py) +- [`sdk/python/examples/remote-agent/run.py`](../examples/remote-agent/run.py) — the runnable demo that proves all of the above end-to-end. From 74e2da8b92d2df4d97ed92f31a9a9788a66d32b9 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 13 Apr 2026 14:36:30 -0700 Subject: [PATCH 005/722] =?UTF-8?q?chore:=20quality=20pass=20=E2=80=94=20n?= =?UTF-8?q?ative=20dialogs,=20env=20sync,=20Go=20handler=20splits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three parallel cleanups driven by the second code-review pass. ## Native dialogs → ConfirmDialog (7 sites) Violated the standing feedback_no_native_dialogs rule. - ChannelsTab: confirm() → ConfirmDialog danger variant with pendingDelete state - ScheduleTab: window.confirm() → ConfirmDialog danger - ChatTab: confirm("Restart...") → ConfirmDialog warning (restart is recoverable) - TemplatePalette: two alert() sites collapsed into a single notice state + ConfirmDialog as OK-only info toast - ErrorBoundary: dropped both window.alert calls entirely. Clipboard-copy click is self-evident; console.error already captures the fallback. ## .env.example ↔ Go env var sync Added 11 previously-undocumented env vars grouped into 6 new sections: - Platform: PLATFORM_URL, MOLECULE_URL, WORKSPACE_DIR, MOLECULE_ENV - CORS / rate limiting: CORS_ORIGINS, RATE_LIMIT - Activity retention: ACTIVITY_RETENTION_DAYS, ACTIVITY_CLEANUP_INTERVAL_HOURS - Container detection: MOLECULE_IN_DOCKER (moved to dedup) - Observability: AWARENESS_URL - Webhooks: GITHUB_WEBHOOK_SECRET - CLI: MOLECLI_URL All 21 distinct os.Getenv / envx.* keys (excluding HOME) now documented. Zero orphans in the other direction. ## Go handler function splits (4 funcs, pure refactor) No behavior change; same tests pass. | Function | Before | After | Helpers | |---------------------------|-------:|------:|---------------------------------------------------------------| | proxyA2ARequest | 257 | 56 | resolveAgentURL, normalizeA2APayload, dispatchA2A, | | | | | handleA2ADispatchError, maybeMarkContainerDead, | | | | | logA2AFailure, logA2ASuccess | | Delegate | 127 | 60 | bindDelegateRequest, lookupIdempotentDelegation, | | | | | insertDelegationRow | | Discover | 125 | 40 | discoverWorkspacePeer, writeExternalWorkspaceURL, | | | | | discoverHostPeer | | SessionSearch | 109 | 24 | parseSessionSearchParams, buildSessionSearchQuery, | | | | | scanSessionSearchRows | Preserved exact error semantics, log.Printf calls, status codes, and response shapes. Introduced a proxyDispatchBuildError sentinel in a2a_proxy so the orchestrator can distinguish "couldn't build the request" from "Do() failed" without changing existing branches. ## Verification - go build ./... clean - go vet ./... clean - go test -race ./internal/... — all pass - canvas npm run build — clean - canvas npm test -- --run — 352/352 pass - grep window.confirm|window.alert|window.prompt in canvas/src — 0 matches - every platform os.Getenv key present in .env.example Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 25 +- canvas/src/components/ErrorBoundary.tsx | 9 +- canvas/src/components/TemplatePalette.tsx | 15 +- canvas/src/components/tabs/ChannelsTab.tsx | 20 +- canvas/src/components/tabs/ChatTab.tsx | 21 +- canvas/src/components/tabs/ScheduleTab.tsx | 20 +- platform/internal/handlers/a2a_proxy.go | 360 ++++++++++++--------- platform/internal/handlers/activity.go | 56 +++- platform/internal/handlers/delegation.go | 198 +++++++----- platform/internal/handlers/discovery.go | 140 ++++---- 10 files changed, 543 insertions(+), 321 deletions(-) diff --git a/.env.example b/.env.example index b6e3a863..fc6e1edc 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,30 @@ PORT=8080 SECRETS_ENCRYPTION_KEY= # 32-byte key (raw or base64). Leave empty for plaintext (dev only). CONFIGS_DIR= # Path to workspace-configs-templates/ (auto-discovered if empty) PLUGINS_DIR= # Path to plugins/ directory (default: /plugins in container) +# PLATFORM_URL=http://host.docker.internal:8080 # URL agent containers use to reach the platform; injected into workspace env. Default derives from PORT. +# MOLECULE_URL=http://localhost:8080 # Canonical MCP-client URL (mirrors PLATFORM_URL inside containers). Read by the MCP server (mcp-server/) and Molecule MCP tooling. +# WORKSPACE_DIR= # Optional global host path bind-mounted to /workspace in every container. Per-workspace workspace_dir column overrides this; if neither is set each workspace gets an isolated Docker named volume. +# MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and conditional behaviour. + +# CORS / rate limiting +# CORS_ORIGINS=http://localhost:3000,http://localhost:3001 # Comma-separated allowed origins for the HTTP API. +# RATE_LIMIT=600 # Requests/minute per client (default 600). + +# Activity retention +# ACTIVITY_RETENTION_DAYS=7 # Days to keep rows in activity_logs before pruning. +# ACTIVITY_CLEANUP_INTERVAL_HOURS=6 # How often the background pruner runs. + +# Container/runtime detection +# MOLECULE_IN_DOCKER= # Set when running the platform inside Docker (accepts 1/0, true/false). Triggers A2A proxy to rewrite 127.0.0.1: agent URLs to Docker bridge hostnames. Auto-detected via /.dockerenv; only set if detection fails or to force off. + +# Observability (Awareness) +# AWARENESS_URL= # If set, injected into workspace containers along with a deterministic AWARENESS_NAMESPACE derived from workspace ID. Enables the cross-session memory MCP server. + +# Webhooks +# GITHUB_WEBHOOK_SECRET= # HMAC secret used to verify incoming GitHub webhook payloads at /webhooks/github. + +# CLI clients +# MOLECLI_URL=http://localhost:8080 # URL the molecli TUI uses to reach the platform. # Plugin install safeguards (POST /workspaces/:id/plugins) # All three bound the cost of a single install so a slow/malicious @@ -39,7 +63,6 @@ CEREBRAS_API_KEY= # Cerebras API key (cloud.cerebras.ai). Use with GOOGLE_API_KEY= # Google AI API key (aistudio.google.com). Use with model: google_genai:gemini-2.5-flash MAX_TOKENS=2048 # Max output tokens for OpenRouter requests (default: 2048) LANGGRAPH_RECURSION_LIMIT=500 # LangGraph/DeepAgents max ReAct steps per turn (lib default: 25; raised to 500 — PM fan-out to 6+ reports + synthesis routinely exceeds 100) -MOLECULE_IN_DOCKER= # Set when running the platform inside Docker (accepts 1/0, true/false — anything strconv.ParseBool recognises). Triggers A2A proxy to rewrite 127.0.0.1: agent URLs to Docker bridge hostnames. Auto-detected via /.dockerenv; only set if detection fails (e.g. Podman, custom runtimes) or to force off. MODEL_PROVIDER=anthropic:claude-sonnet-4-6 # Format: provider:model. Providers: anthropic, openai, openrouter, groq, cerebras, google_genai, ollama # Social Channels (optional — configure per-workspace via API or Canvas) diff --git a/canvas/src/components/ErrorBoundary.tsx b/canvas/src/components/ErrorBoundary.tsx index 495da276..60135f73 100644 --- a/canvas/src/components/ErrorBoundary.tsx +++ b/canvas/src/components/ErrorBoundary.tsx @@ -41,11 +41,10 @@ export class ErrorBoundary extends React.Component< }; // Log the full report to console for collection by monitoring tools console.error("Error Report:", JSON.stringify(errorDetails, null, 2)); - // Copy error info to clipboard for manual reporting - navigator.clipboard?.writeText(JSON.stringify(errorDetails, null, 2)).then( - () => window.alert("Error details copied to clipboard."), - () => window.alert("Error details logged to console.") - ); + // Copy error info to clipboard for manual reporting (button click is its + // own affordance — no native alert needed). On clipboard failure the + // console.error above still surfaces the report. + navigator.clipboard?.writeText(JSON.stringify(errorDetails, null, 2)); }; render() { diff --git a/canvas/src/components/TemplatePalette.tsx b/canvas/src/components/TemplatePalette.tsx index ba821834..ed6c7ec7 100644 --- a/canvas/src/components/TemplatePalette.tsx +++ b/canvas/src/components/TemplatePalette.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { api } from "@/lib/api"; import { checkDeploySecrets, type PreflightResult } from "@/lib/deploy-preflight"; import { MissingKeysModal } from "./MissingKeysModal"; +import { ConfirmDialog } from "./ConfirmDialog"; interface Template { id: string; @@ -144,6 +145,7 @@ const TIER_LABELS: Record = { function ImportAgentButton({ onImported }: { onImported: () => void }) { const [importing, setImporting] = useState(false); + const [notice, setNotice] = useState(null); const fileInputRef = useRef(null); const handleFiles = async (fileList: FileList) => { @@ -173,7 +175,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) { } if (Object.keys(files).length === 0) { - alert("No files found in the selected folder"); + setNotice("No files found in the selected folder"); return; } @@ -181,7 +183,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) { await api.post("/templates/import", { name, files }); onImported(); } catch (e) { - alert(e instanceof Error ? e.message : "Import failed"); + setNotice(e instanceof Error ? e.message : "Import failed"); } finally { setImporting(false); } @@ -205,6 +207,15 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) { > {importing ? "Importing..." : "Import Agent Folder"} + setNotice(null)} + onCancel={() => setNotice(null)} + /> ); } diff --git a/canvas/src/components/tabs/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx index 68ddda4e..de22810b 100644 --- a/canvas/src/components/tabs/ChannelsTab.tsx +++ b/canvas/src/components/tabs/ChannelsTab.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { api } from "@/lib/api"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; interface ChannelAdapter { type: string; @@ -39,6 +40,7 @@ export function ChannelsTab({ workspaceId }: Props) { const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [testing, setTesting] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); // Form state const [formType, setFormType] = useState("telegram"); @@ -146,8 +148,10 @@ export function ChannelsTab({ workspaceId }: Props) { load(); }; - const handleDelete = async (ch: Channel) => { - if (!confirm(`Delete ${ch.channel_type} channel?`)) return; + const confirmDelete = async () => { + if (!pendingDelete) return; + const ch = pendingDelete; + setPendingDelete(null); await api.del(`/workspaces/${workspaceId}/channels/${ch.id}`); load(); }; @@ -338,7 +342,7 @@ export function ChannelsTab({ workspaceId }: Props) { {ch.enabled ? "On" : "Off"} + + { + useCanvasStore.getState().restartWorkspace(workspaceId); + setConfirmRestart(false); + }} + onCancel={() => setConfirmRestart(false)} + /> ); } diff --git a/canvas/src/components/tabs/ScheduleTab.tsx b/canvas/src/components/tabs/ScheduleTab.tsx index 61155267..0dc4e9bd 100644 --- a/canvas/src/components/tabs/ScheduleTab.tsx +++ b/canvas/src/components/tabs/ScheduleTab.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { api } from "@/lib/api"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; interface Schedule { id: string; @@ -64,6 +65,7 @@ export function ScheduleTab({ workspaceId }: Props) { const [formPrompt, setFormPrompt] = useState(""); const [formEnabled, setFormEnabled] = useState(true); const [error, setError] = useState(""); + const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null); const fetchSchedules = useCallback(async () => { try { @@ -120,8 +122,10 @@ export function ScheduleTab({ workspaceId }: Props) { } }; - const handleDelete = async (id: string, name: string) => { - if (!window.confirm(`Delete schedule "${name || "Unnamed"}"? This cannot be undone.`)) return; + const confirmDelete = async () => { + if (!pendingDelete) return; + const { id } = pendingDelete; + setPendingDelete(null); await api.del(`/workspaces/${workspaceId}/schedules/${id}`); fetchSchedules(); }; @@ -343,7 +347,7 @@ export function ScheduleTab({ workspaceId }: Props) { ✎ + {!singleButton && ( + + )} - e.target.files && handleUploadFiles(e.target.files)} - /> - - - )} - - {root === "/configs" && ( - - )} - - - + !f.dir).length} + onNewFile={() => setShowNewFile(true)} + onUpload={uploadFiles} + onDownloadAll={downloadAllFiles} + onClearAll={() => setShowDeleteAll(true)} + onRefresh={() => loadFiles()} + /> - {/* Delete all confirmation */} {showDeleteAll && (

Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.

- - + +
)} {error && ( -
- {error} -
+
{error}
)} {confirmDelete && (

Delete {confirmDelete}{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?

- - + +
)} @@ -381,11 +207,11 @@ export function FilesTab({ workspaceId }: Props) { No config files yet ) : ( - {}} + onDelete={root === "/configs" ? setConfirmDelete : () => {}} expandedDirs={expandedDirs} onToggleDir={toggleDir} loadingDir={loadingDir} @@ -395,256 +221,20 @@ export function FilesTab({ workspaceId }: Props) { {/* Editor */}
- {selectedFile ? ( - <> - {/* File header */} -
-
- {getIcon(selectedFile, false)} - {selectedFile} - {isDirty && modified} -
-
- {success && {success}} - - {root === "/configs" && ( - - )} -
-
- - {/* Editor area */} - {loadingFile ? ( -
Loading...
- ) : ( -