From af931aa8da748e3b5f9a861ba90220f7a2d7d0df Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 13 Apr 2026 14:26:17 -0700 Subject: [PATCH] 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.