feat: migrate_workspace_provider + get_workspace_migration_status MCP tools #6

Merged
agent-dev-a merged 3 commits from feat/migrate-provider-tools-iss5 into main 2026-06-21 05:03:03 +00:00
3 changed files with 410 additions and 2 deletions
+193 -1
View File
@@ -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/migration-status",
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");
});
});
+3 -1
View File
@@ -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).
+214
View File
@@ -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<T = unknown>(method: string, path: string, body?: unknown): Promise<T | ApiError> {
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<string, unknown>).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<string, unknown> = {
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)}/migration-status`,
);
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
);
}