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