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<T = unknown>: 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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-13 14:26:17 -07:00
parent 7a2df32dd0
commit 50b0a1859a
17 changed files with 351 additions and 173 deletions

View File

@ -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:

View File

@ -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);
});
});
// ============================================================

View File

@ -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<T = unknown>(
method: string,
path: string,
body?: unknown,
): Promise<T | ApiError> {
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 };
}

View File

@ -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).

View File

@ -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) {

View File

@ -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) {

View File

@ -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<string, unknown> = {};
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<string, unknown>;
}) {
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) {

View File

@ -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) {

View File

@ -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<string, string> }) {
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<string, unknown> }) {
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) {

View File

@ -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<string, unknown> }) {
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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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<string, unknown>;
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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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
);

View File

@ -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.