feat(mcp): require confirm_name for delete_workspace and send X-Confirm-Name header #7
+2
-1
@@ -157,7 +157,8 @@ Full list of tools exposed by this server (88 total). Each is implemented in `sr
|
||||
| `create_workspace` | Create a new workspace node on the canvas |
|
||||
| `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) |
|
||||
| `delete_workspace` | Delete a workspace (cascades to children). Requires `confirm_name` matching the workspace's exact name, sent as the `X-Confirm-Name` header. |
|
||||
| `deprovision_workspace` | Alias for `delete_workspace`. Same confirmation requirement. |
|
||||
| `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. |
|
||||
|
||||
@@ -513,12 +513,38 @@ 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: "Test-PM" });
|
||||
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": "Test-PM" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses without confirm_name", async () => {
|
||||
global.fetch = jest.fn();
|
||||
const result = await handleDeleteWorkspace({ workspace_id: "ws-del", confirm_name: "" });
|
||||
expectJsonContent(result, {
|
||||
error: "CONFIRMATION_REQUIRED",
|
||||
detail: expect.stringContaining("confirm_name"),
|
||||
workspace_id: "ws-del",
|
||||
});
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("deprovision_workspace alias sends the same DELETE + header", async () => {
|
||||
global.fetch = mockFetch({ deleted: true });
|
||||
await handleDeleteWorkspace({ workspace_id: "ws-del", confirm_name: "Test-PM" });
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${PLATFORM_URL}/workspaces/ws-del?confirm=true`,
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", "X-Confirm-Name": "Test-PM" },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1121,7 +1147,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 +1166,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" })],
|
||||
|
||||
+2
-1
@@ -49,11 +49,12 @@ 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) {
|
||||
|
||||
@@ -336,8 +336,24 @@ 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;
|
||||
}) {
|
||||
const { workspace_id, confirm_name } = params;
|
||||
if (!confirm_name || confirm_name.trim().length === 0) {
|
||||
return toMcpResult({
|
||||
error: "CONFIRMATION_REQUIRED",
|
||||
detail:
|
||||
"Deleting a workspace is destructive and cascades to children. " +
|
||||
"Pass confirm_name with the workspace's exact name to proceed.",
|
||||
workspace_id,
|
||||
});
|
||||
}
|
||||
|
||||
const data = await apiCall("DELETE", `/workspaces/${workspace_id}?confirm=true`, undefined, {
|
||||
"X-Confirm-Name": confirm_name.trim(),
|
||||
});
|
||||
return toMcpResult(data);
|
||||
}
|
||||
|
||||
@@ -441,8 +457,21 @@ export function registerWorkspaceTools(srv: McpServer) {
|
||||
|
||||
srv.tool(
|
||||
"delete_workspace",
|
||||
"Delete a workspace (cascades to children)",
|
||||
{ workspace_id: z.string().describe("Workspace ID") },
|
||||
"Delete a workspace (cascades to children). confirm_name must be the workspace's exact name.",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace ID"),
|
||||
confirm_name: z.string().describe("Exact workspace name to confirm deletion"),
|
||||
},
|
||||
handleDeleteWorkspace
|
||||
);
|
||||
|
||||
srv.tool(
|
||||
"deprovision_workspace",
|
||||
"Alias for delete_workspace. Delete a workspace (cascades to children). confirm_name must be the workspace's exact name.",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace ID"),
|
||||
confirm_name: z.string().describe("Exact workspace name to confirm deletion"),
|
||||
},
|
||||
handleDeleteWorkspace
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user