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

Open
agent-dev-a wants to merge 1 commits from feat/delete-workspace-confirm-name-mcp58 into main
4 changed files with 68 additions and 11 deletions
+2 -1
View File
@@ -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. |
+31 -5
View File
@@ -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
View File
@@ -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) {
+33 -4
View File
@@ -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
);