From 8eb8ba9b89fdf6010de965279128726953ee8f88 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 15 Jun 2026 04:52:29 +0000 Subject: [PATCH 1/3] feat(mcp): migrate_workspace_provider + get_workspace_migration_status tools (closes #5) - Add CP-tier provider migration tools wrapping POST/GET /api/v1/admin/workspaces/:id/migrate-provider. - Gate on CP_ADMIN_API_TOKEN; require explicit confirm:true; auto-resolve source provider from workspace; enforce from_instance_id for non-AWS sources. - Register tools and update server tool count (90). - Add unit tests for gating, validation, auto-resolution, error mapping, and status retrieval. Co-Authored-By: Claude --- server/src/__tests__/index.test.ts | 194 +++++++++++++++++++++++++- server/src/index.ts | 4 +- server/src/tools/workspaces.ts | 214 +++++++++++++++++++++++++++++ 3 files changed, 410 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/index.test.ts b/server/src/__tests__/index.test.ts index 1819284..b15428b 100644 --- a/server/src/__tests__/index.test.ts +++ b/server/src/__tests__/index.test.ts @@ -75,6 +75,8 @@ import { handleDeleteGlobalSecret, handlePauseWorkspace, handleResumeWorkspace, + handleMigrateWorkspaceProvider, + handleGetWorkspaceMigrationStatus, handleListOrgTemplates, handleImportOrg, handleListRemoteAgents, @@ -1121,7 +1123,7 @@ describe("createServer()", () => { 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(88); + expect(names.length).toBe(90); // 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); @@ -1402,3 +1404,193 @@ describe("Phase 30 remote-agent tools", () => { expect(body.seconds_since_heartbeat).toBeNull(); }); }); + +// ============================================================ +// CP-tier provider migration (issue #5) +// ============================================================ + +describe("migrate_workspace_provider", () => { + const ORIGINAL_CP_TOKEN = process.env.CP_ADMIN_API_TOKEN; + + beforeEach(() => { + jest.resetAllMocks(); + process.env.CP_ADMIN_API_TOKEN = "cp-test-token"; + }); + + afterEach(() => { + process.env.CP_ADMIN_API_TOKEN = ORIGINAL_CP_TOKEN; + }); + + test("returns CP_TIER_NOT_CONFIGURED when token is absent", async () => { + delete process.env.CP_ADMIN_API_TOKEN; + global.fetch = jest.fn(); + const res = await handleMigrateWorkspaceProvider({ + workspace_id: "ws-1", + to: "hetzner", + from: "aws", + confirm: true, + }); + const body = JSON.parse(res.content[0].text); + expect(body.error).toBe("CP_TIER_NOT_CONFIGURED"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test("refuses without explicit confirm:true", async () => { + global.fetch = jest.fn(); + const res = await handleMigrateWorkspaceProvider({ + workspace_id: "ws-1", + to: "hetzner", + from: "aws", + }); + const body = JSON.parse(res.content[0].text); + expect(body.error).toBe("CONFIRMATION_REQUIRED"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test("rejects unsupported to provider", async () => { + global.fetch = jest.fn(); + const res = await handleMigrateWorkspaceProvider({ + workspace_id: "ws-1", + to: "azure", + from: "aws", + confirm: true, + }); + const body = JSON.parse(res.content[0].text); + expect(body.error).toBe("INVALID_ARGUMENTS"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test("auto-resolves from provider from workspace and posts correct body", async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ id: "ws-1", provider: "aws" })), + }) + .mockResolvedValueOnce({ + ok: true, + status: 202, + text: () => Promise.resolve(JSON.stringify({ status: "migration_started" })), + }); + global.fetch = fetchMock; + + const res = await handleMigrateWorkspaceProvider({ + workspace_id: "ws-1", + to: "hetzner", + confirm: true, + }); + + const body = JSON.parse(res.content[0].text); + expect(body.ok).toBe(true); + expect(body.from).toBe("aws"); + expect(body.to).toBe("hetzner"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const workspaceCall = fetchMock.mock.calls[0]; + expect(workspaceCall[0]).toBe(`${PLATFORM_URL}/workspaces/ws-1`); + + const migrateCall = fetchMock.mock.calls[1]; + expect(migrateCall[0]).toBe("https://api.moleculesai.app/api/v1/admin/workspaces/ws-1/migrate-provider"); + expect(migrateCall[1].method).toBe("POST"); + expect(migrateCall[1].headers.Authorization).toBe("Bearer cp-test-token"); + const sentBody = JSON.parse(migrateCall[1].body); + expect(sentBody).toEqual({ from: "aws", to: "hetzner", confirm: true }); + }); + + test("requires from_instance_id for non-AWS source", async () => { + global.fetch = jest.fn(); + const res = await handleMigrateWorkspaceProvider({ + workspace_id: "ws-1", + to: "aws", + from: "hetzner", + confirm: true, + }); + const body = JSON.parse(res.content[0].text); + expect(body.error).toBe("INVALID_ARGUMENTS"); + expect(body.detail).toContain("from_instance_id"); + }); + + test("rejects from === to", async () => { + global.fetch = jest.fn(); + const res = await handleMigrateWorkspaceProvider({ + workspace_id: "ws-1", + to: "aws", + from: "aws", + confirm: true, + }); + const body = JSON.parse(res.content[0].text); + expect(body.error).toBe("INVALID_ARGUMENTS"); + }); + + test("maps CP error to MIGRATION_FAILED", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 409, + text: () => Promise.resolve("migration already in progress"), + }); + const res = await handleMigrateWorkspaceProvider({ + workspace_id: "ws-1", + to: "hetzner", + from: "aws", + confirm: true, + }); + const body = JSON.parse(res.content[0].text); + expect(body.error).toBe("MIGRATION_FAILED"); + expect(body.detail.status).toBe(409); + }); +}); + +describe("get_workspace_migration_status", () => { + const ORIGINAL_CP_TOKEN = process.env.CP_ADMIN_API_TOKEN; + + beforeEach(() => { + jest.resetAllMocks(); + process.env.CP_ADMIN_API_TOKEN = "cp-test-token"; + }); + + afterEach(() => { + process.env.CP_ADMIN_API_TOKEN = ORIGINAL_CP_TOKEN; + }); + + test("returns CP_TIER_NOT_CONFIGURED when token is absent", async () => { + delete process.env.CP_ADMIN_API_TOKEN; + global.fetch = jest.fn(); + const res = await handleGetWorkspaceMigrationStatus({ workspace_id: "ws-1" }); + const body = JSON.parse(res.content[0].text); + expect(body.error).toBe("CP_TIER_NOT_CONFIGURED"); + }); + + test("returns migration status on success", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => + Promise.resolve( + JSON.stringify({ + migration: { state: "snapshotting", from_provider: "aws", to_provider: "hetzner" }, + terminal: false, + }) + ), + }); + const res = await handleGetWorkspaceMigrationStatus({ workspace_id: "ws-1" }); + const body = JSON.parse(res.content[0].text); + expect(body.migration.state).toBe("snapshotting"); + expect(body.terminal).toBe(false); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.moleculesai.app/api/v1/admin/workspaces/ws-1/migrate-provider", + expect.objectContaining({ method: "GET" }) + ); + }); + + test("maps 404 to NOT_FOUND", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + text: () => Promise.resolve("not found"), + }); + const res = await handleGetWorkspaceMigrationStatus({ workspace_id: "ws-1" }); + const body = JSON.parse(res.content[0].text); + expect(body.error).toBe("NOT_FOUND"); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 0c943a9..983e57e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -45,6 +45,8 @@ export { handleUpdateWorkspace, handlePauseWorkspace, handleResumeWorkspace, + handleMigrateWorkspaceProvider, + handleGetWorkspaceMigrationStatus, } from "./tools/workspaces.js"; export { @@ -213,7 +215,7 @@ async function main() { const server = createServer(); const transport = new StdioServerTransport(); await server.connect(transport); - logInfo("Molecule AI MCP server running on stdio (88 tools available)", { transport: "stdio", toolCount: 88 }); + logInfo("Molecule AI MCP server running on stdio (90 tools available)", { transport: "stdio", toolCount: 90 }); } // Only auto-start when run directly (not when imported for testing). diff --git a/server/src/tools/workspaces.ts b/server/src/tools/workspaces.ts index 15bba17..4a4efb8 100644 --- a/server/src/tools/workspaces.ts +++ b/server/src/tools/workspaces.ts @@ -1,6 +1,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { apiCall, platformGet, toMcpResult, isApiError } from "../api.js"; +import type { ApiError } from "../api.js"; // Supported runtimes the platform provisioner will honor. Mirrors the // workspace-server allowlist (`internal/handlers/runtime_registry.go` @@ -370,6 +371,199 @@ export async function handleResumeWorkspace(params: { workspace_id: string }) { return toMcpResult(data); } +// ============================================================ +// CP-tier provider migration (issue #5) +// Canvas can migrate a workspace across clouds, but the MCP/CLI could not. +// These tools wrap the CP-admin endpoint; they require CP_ADMIN_API_TOKEN. +// ============================================================ + +const SUPPORTED_PROVIDERS = ["aws", "hetzner", "gcp"] as const; +type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]; + +function cpUrl(): string { + return process.env.MOLECULE_CP_URL || "https://api.moleculesai.app"; +} + +function cpConfigured(): boolean { + return !!process.env.CP_ADMIN_API_TOKEN; +} + +function cpNotConfigured(tool: string): { error: string; detail: string } { + return { + error: "CP_TIER_NOT_CONFIGURED", + detail: + `'${tool}' is a control-plane tier tool. The Org API Key cannot reach the control plane. ` + + "Set CP_ADMIN_API_TOKEN (CP admin bearer) to enable it. This is gated, not broken.", + }; +} + +async function cpCall(method: string, path: string, body?: unknown): Promise { + const tok = process.env.CP_ADMIN_API_TOKEN; + if (!tok) return cpNotConfigured(path) as ApiError; + try { + const res = await fetch(`${cpUrl()}${path}`, { + method, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${tok}` }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text(); + if (res.status === 401 || res.status === 403) { + return { error: "AUTH_ERROR", detail: text, status: res.status }; + } + return { error: `HTTP ${res.status}`, detail: text, status: res.status }; + } + const text = await res.text(); + try { + return JSON.parse(text) as T; + } catch { + return { raw: text, status: res.status } as ApiError; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { error: `Control plane unreachable at ${cpUrl()}`, detail: msg }; + } +} + +function isSupportedProvider(p: string): p is SupportedProvider { + return (SUPPORTED_PROVIDERS as readonly string[]).includes(p); +} + +export async function handleMigrateWorkspaceProvider(params: { + workspace_id: string; + to: string; + from?: string; + confirm?: boolean; + from_instance_id?: string; +}) { + if (!cpConfigured()) return toMcpResult(cpNotConfigured("migrate_workspace_provider")); + + const { workspace_id, to, from_instance_id } = params; + const confirm = params.confirm ?? false; + + if (!confirm) { + return toMcpResult({ + error: "CONFIRMATION_REQUIRED", + detail: + "A real migration mutates two clouds and is async (~15-20 min). " + + "Pass confirm:true explicitly to proceed.", + workspace_id, + }); + } + + if (!isSupportedProvider(to)) { + return toMcpResult({ + error: "INVALID_ARGUMENTS", + detail: `to must be one of ${SUPPORTED_PROVIDERS.join("|")}; got '${to}'`, + workspace_id, + }); + } + + // `from` is required by CP. Auto-resolve from the workspace's current + // provider when omitted; caller may still override. + let fromProvider: SupportedProvider | undefined = isSupportedProvider(params.from ?? "") + ? (params.from as SupportedProvider) + : undefined; + + if (!fromProvider) { + const ws = await platformGet(`/workspaces/${encodeURIComponent(workspace_id)}`); + if (isApiError(ws)) { + return toMcpResult({ + error: "FROM_UNRESOLVED", + detail: "'from' provider is required and could not be resolved from the workspace", + workspace_id, + upstream: ws, + }); + } + const provider = ws && typeof ws === "object" ? (ws as Record).provider : undefined; + if (typeof provider === "string" && isSupportedProvider(provider)) { + fromProvider = provider; + } + } + + if (!fromProvider) { + return toMcpResult({ + error: "FROM_UNRESOLVED", + detail: + "'from' provider is required. Pass it explicitly, or ensure the workspace has a supported provider field.", + workspace_id, + }); + } + + if (fromProvider === to) { + return toMcpResult({ + error: "INVALID_ARGUMENTS", + detail: "'from' and 'to' providers must differ", + workspace_id, + from: fromProvider, + to, + }); + } + + // from_instance_id is required for non-AWS sources because CP cannot tag-resolve them. + if (fromProvider !== "aws" && !from_instance_id) { + return toMcpResult({ + error: "INVALID_ARGUMENTS", + detail: `from_instance_id is required when source provider is '${fromProvider}'`, + workspace_id, + from: fromProvider, + to, + }); + } + + const body: Record = { + from: fromProvider, + to, + confirm: true, + }; + if (from_instance_id) body.from_instance_id = from_instance_id; + + const res = await cpCall( + "POST", + `/api/v1/admin/workspaces/${encodeURIComponent(workspace_id)}/migrate-provider`, + body, + ); + + if (isApiError(res)) { + return toMcpResult({ + error: "MIGRATION_FAILED", + detail: res, + workspace_id, + from: fromProvider, + to, + }); + } + + return toMcpResult({ ok: true, workspace_id, from: fromProvider, to, result: res }); +} + +export async function handleGetWorkspaceMigrationStatus(params: { workspace_id: string }) { + if (!cpConfigured()) return toMcpResult(cpNotConfigured("get_workspace_migration_status")); + + const { workspace_id } = params; + const res = await cpCall( + "GET", + `/api/v1/admin/workspaces/${encodeURIComponent(workspace_id)}/migrate-provider`, + ); + + if (isApiError(res)) { + if (res.status === 404) { + return toMcpResult({ + error: "NOT_FOUND", + detail: "No migration record found for this workspace.", + workspace_id, + }); + } + return toMcpResult({ + error: "STATUS_FAILED", + detail: res, + workspace_id, + }); + } + + return toMcpResult(res); +} + export function registerWorkspaceTools(srv: McpServer) { srv.tool("list_workspaces", "List all workspaces with their status, skills, and hierarchy", {}, handleListWorkspaces); @@ -479,4 +673,24 @@ export function registerWorkspaceTools(srv: McpServer) { { workspace_id: z.string().describe("Workspace ID") }, handleResumeWorkspace ); + + srv.tool( + "migrate_workspace_provider", + "Management (CP-TIER): migrate a workspace's compute provider across clouds (AWS ↔ Hetzner ↔ GCP). Data-safe and async (~15-20 min). Requires CP_ADMIN_API_TOKEN — the Org API Key cannot reach the control plane. confirm:true is mandatory.", + { + workspace_id: z.string().describe("Workspace UUID to migrate"), + to: z.enum(SUPPORTED_PROVIDERS).describe("Target cloud provider"), + from: z.enum(SUPPORTED_PROVIDERS).optional().describe("Source cloud provider; auto-resolved from workspace when omitted"), + confirm: z.boolean().optional().describe("Must be true. A real migration mutates two clouds."), + from_instance_id: z.string().optional().describe("Required when source is hetzner or gcp (CP cannot tag-resolve these); optional for AWS."), + }, + handleMigrateWorkspaceProvider + ); + + srv.tool( + "get_workspace_migration_status", + "Management (CP-TIER): read the state of an in-flight or completed cross-cloud provider migration. Requires CP_ADMIN_API_TOKEN.", + { workspace_id: z.string().describe("Workspace UUID") }, + handleGetWorkspaceMigrationStatus + ); } -- 2.52.0 From cfc2d29f316bcf99c3da67908619d4bfc04a3d8e Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Fri, 19 Jun 2026 13:24:32 +0000 Subject: [PATCH 2/3] fix(mcp): point get_workspace_migration_status at /migration-status endpoint CR2 review found the status tool was calling GET /migrate-provider instead of GET /migration-status. Switch the handler and its regression test to the correct status endpoint. Co-Authored-By: Claude --- server/src/__tests__/index.test.ts | 2 +- server/src/tools/workspaces.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/index.test.ts b/server/src/__tests__/index.test.ts index b15428b..740301d 100644 --- a/server/src/__tests__/index.test.ts +++ b/server/src/__tests__/index.test.ts @@ -1578,7 +1578,7 @@ describe("get_workspace_migration_status", () => { expect(body.migration.state).toBe("snapshotting"); expect(body.terminal).toBe(false); expect(global.fetch).toHaveBeenCalledWith( - "https://api.moleculesai.app/api/v1/admin/workspaces/ws-1/migrate-provider", + "https://api.moleculesai.app/api/v1/admin/workspaces/ws-1/migration-status", expect.objectContaining({ method: "GET" }) ); }); diff --git a/server/src/tools/workspaces.ts b/server/src/tools/workspaces.ts index 4a4efb8..a34a4cb 100644 --- a/server/src/tools/workspaces.ts +++ b/server/src/tools/workspaces.ts @@ -543,7 +543,7 @@ export async function handleGetWorkspaceMigrationStatus(params: { workspace_id: const { workspace_id } = params; const res = await cpCall( "GET", - `/api/v1/admin/workspaces/${encodeURIComponent(workspace_id)}/migrate-provider`, + `/api/v1/admin/workspaces/${encodeURIComponent(workspace_id)}/migration-status`, ); if (isApiError(res)) { -- 2.52.0 From d0f94107e495b05651b6c3f927df4d159347c5e0 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sun, 21 Jun 2026 04:59:30 +0000 Subject: [PATCH 3/3] chore: re-trigger SOP-checklist gate after base fix Empty commit to re-run the gate against main now that the 403-tolerance fix is merged. Co-Authored-By: Claude -- 2.52.0