fix(mcp): require confirm_name for delete_workspace and send X-Confirm-Name header #10

Closed
agent-dev-a wants to merge 1 commits from fix/mcp7-delete-workspace-confirm-name into main
5 changed files with 89 additions and 12 deletions
+3 -2
View File
@@ -148,9 +148,9 @@ src/
## MCP Tool Registry
Full list of tools exposed by this server (88 total). Each is implemented in `src/tools/<name>.ts`.
Full list of tools exposed by this server (89 total). Each is implemented in `src/tools/<name>.ts`.
### Workspace Tools (9)
### Workspace Tools (10)
| Tool | Description |
|------|-------------|
| `list_workspaces` | List all workspaces with their status, skills, and hierarchy |
@@ -158,6 +158,7 @@ Full list of tools exposed by this server (88 total). Each is implemented in `sr
| `get_workspace` | Get detailed information about a specific workspace |
| `update_workspace` | Update workspace fields (name, role, tier, parent_id, position) |
| `delete_workspace` | Delete a workspace (cascades to children) |
| `deprovision_workspace` | Alias for `delete_workspace` — delete a workspace (cascades to children) |
| `restart_workspace` | Restart an offline or failed workspace |
| `pause_workspace` | Pause a workspace (stops container, preserves config) |
| `provision_workspace` | Provision a workspace with a specific runtime (claude-code, codex, hermes, openclaw, langgraph, autogen, crewai, deepagents). Fail-closed: validates the runtime, reads the created workspace back, and returns an error if the platform silently fell back to a different runtime. Use this — not `create_workspace` — when the runtime must be guaranteed. |
+50 -5
View File
@@ -404,6 +404,21 @@ describe("apiCall()", () => {
);
});
test("merges custom headers with Content-Type", async () => {
global.fetch = mockFetch({ ok: true });
await apiCall("DELETE", "/workspaces/ws-1?confirm=true", undefined, { "X-Confirm-Name": "ws-1" });
expect(global.fetch).toHaveBeenCalledWith(
`${PLATFORM_URL}/workspaces/ws-1?confirm=true`,
expect.objectContaining({
method: "DELETE",
headers: {
"Content-Type": "application/json",
"X-Confirm-Name": "ws-1",
},
})
);
});
test("omits body when none provided (GET requests)", async () => {
global.fetch = mockFetch([]);
await apiCall("GET", "/workspaces");
@@ -513,14 +528,44 @@ describe("handleGetWorkspace()", () => {
});
describe("handleDeleteWorkspace()", () => {
test("calls DELETE /workspaces/:id?confirm=true", async () => {
test("calls DELETE /workspaces/:id?confirm=true with X-Confirm-Name header", async () => {
global.fetch = mockFetch({ deleted: true });
await handleDeleteWorkspace({ workspace_id: "ws-del" });
await handleDeleteWorkspace({ workspace_id: "ws-del", confirm_name: "ws-del" });
expect(global.fetch).toHaveBeenCalledWith(
`${PLATFORM_URL}/workspaces/ws-del?confirm=true`,
expect.objectContaining({ method: "DELETE" })
expect.objectContaining({
method: "DELETE",
headers: {
"Content-Type": "application/json",
"X-Confirm-Name": "ws-del",
},
})
);
});
test("refuses deletion when confirm_name is missing or blank", async () => {
global.fetch = mockFetch({ deleted: true });
const missing = await handleDeleteWorkspace({ workspace_id: "ws-del" } as any);
expect(global.fetch).not.toHaveBeenCalled();
expectJsonContent(missing, {
error: "INVALID_ARGUMENTS",
detail: "confirm_name is required and must be the exact workspace name",
});
jest.clearAllMocks();
global.fetch = mockFetch({ deleted: true });
const blank = await handleDeleteWorkspace({ workspace_id: "ws-del", confirm_name: " " });
expect(global.fetch).not.toHaveBeenCalled();
expectJsonContent(blank, {
error: "INVALID_ARGUMENTS",
detail: "confirm_name is required and must be the exact workspace name",
});
});
test("deprovision_workspace is registered as an alias", async () => {
const server = createServer() as unknown as { registeredToolNames: string[] };
expect(server.registeredToolNames).toContain("deprovision_workspace");
});
});
describe("handleRestartWorkspace()", () => {
@@ -1121,7 +1166,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(89);
// 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);
@@ -1140,7 +1185,7 @@ describe("Response format invariants", () => {
const cases: Array<[string, () => Promise<{ content: Array<{ type: string; text: string }> }>]> = [
["handleListWorkspaces", () => handleListWorkspaces()],
["handleGetWorkspace", () => handleGetWorkspace({ workspace_id: "x" })],
["handleDeleteWorkspace", () => handleDeleteWorkspace({ workspace_id: "x" })],
["handleDeleteWorkspace", () => handleDeleteWorkspace({ workspace_id: "x", confirm_name: "x" })],
["handleListSecrets", () => handleListSecrets({ workspace_id: "x" })],
["handleListPendingApprovals", () => handleListPendingApprovals()],
["handleGetConfig", () => handleGetConfig({ workspace_id: "x" })],
+5 -1
View File
@@ -49,11 +49,15 @@ export async function apiCall<T = unknown>(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>,
): Promise<T | ApiError> {
try {
const res = await fetch(`${PLATFORM_URL}${path}`, {
method,
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
...(headers || {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
+1 -1
View File
@@ -213,7 +213,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 (89 tools available)", { transport: "stdio", toolCount: 89 });
}
// Only auto-start when run directly (not when imported for testing).
+30 -3
View File
@@ -336,8 +336,22 @@ export async function handleGetWorkspace(params: { workspace_id: string }) {
return toMcpResult(data);
}
export async function handleDeleteWorkspace(params: { workspace_id: string }) {
const data = await apiCall("DELETE", `/workspaces/${params.workspace_id}?confirm=true`);
export async function handleDeleteWorkspace(params: {
workspace_id: string;
confirm_name?: string;
}) {
if (!params.confirm_name || params.confirm_name.trim() === "") {
return toMcpResult({
error: "INVALID_ARGUMENTS",
detail: "confirm_name is required and must be the exact workspace name",
});
}
const data = await apiCall(
"DELETE",
`/workspaces/${params.workspace_id}?confirm=true`,
undefined,
{ "X-Confirm-Name": params.confirm_name.trim() }
);
return toMcpResult(data);
}
@@ -442,7 +456,20 @@ export function registerWorkspaceTools(srv: McpServer) {
srv.tool(
"delete_workspace",
"Delete a workspace (cascades to children)",
{ workspace_id: z.string().describe("Workspace ID") },
{
workspace_id: z.string().describe("Workspace ID"),
confirm_name: z.string().describe("Exact workspace name to confirm destructive deletion"),
},
handleDeleteWorkspace
);
srv.tool(
"deprovision_workspace",
"Alias for delete_workspace — delete a workspace (cascades to children)",
{
workspace_id: z.string().describe("Workspace ID"),
confirm_name: z.string().describe("Exact workspace name to confirm destructive deletion"),
},
handleDeleteWorkspace
);