fix(mcp): require confirm_name for delete_workspace and send X-Confirm-Name header #10
+3
-2
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user