feat(management): create_approval tool (mcp-server#61) — stop the concierge improvising with gated ops #62

Merged
agent-reviewer-cr2 merged 2 commits from feat/management-create-approval into main 2026-06-11 19:38:53 +00:00
3 changed files with 107 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@molecule-ai/mcp-server",
"version": "1.5.0",
"version": "1.6.0",
"description": "MCP server for Molecule AI Agent Team \u2014 manage workspaces, agents, and skills from any AI coding tool",
"type": "module",
"exports": {
+34 -1
View File
@@ -52,6 +52,8 @@ import {
handleResumeWorkspace,
handleExportBundle,
handleListOrgEvents,
handleCreateApproval as mgmtCreateApproval,
handleCreateRequest as mgmtCreateRequest,
} from "../tools/management/index.js";
import { handleRecreateWorkspace } from "../tools/management/cp_admin.js";
@@ -158,6 +160,37 @@ describe("workspace secret tools", () => {
expect(JSON.parse(init.body as string)).toEqual({ key: "ANTHROPIC_API_KEY", value: "sk-x" });
});
it("create_approval POSTs an approval-kind request addressed to the user (mcp-server#61)", async () => {
const f = mockFetch({ ok: true, id: "req-1" });
global.fetch = f as unknown as typeof fetch;
await mgmtCreateApproval({ workspace_id: "w1", action: "Test approval", reason: "demo" });
const { url, init } = lastCall(f);
expect(url).toBe(`${HOST}/workspaces/w1/requests`);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
kind: "approval",
recipient_type: "user",
recipient_id: "",
title: "Test approval",
detail: "demo",
});
});
it("create_request kind=task POSTs a task-kind request to the user", async () => {
const f = mockFetch({ ok: true, id: "req-2" });
global.fetch = f as unknown as typeof fetch;
await mgmtCreateRequest({ workspace_id: "w1", kind: "task", title: "Review the report", detail: "by EOD" });
const { url, init } = lastCall(f);
expect(url).toBe(`${HOST}/workspaces/w1/requests`);
expect(JSON.parse(init.body as string)).toEqual({
kind: "task",
recipient_type: "user",
recipient_id: "",
title: "Review the report",
detail: "by EOD",
});
});
it("list_workspace_secrets GETs /workspaces/:id/secrets", async () => {
const f = mockFetch([{ key: "FOO" }]);
global.fetch = f as unknown as typeof fetch;
@@ -589,7 +622,7 @@ describe("registration + mode", () => {
"mint_org_token", "list_org_tokens", "revoke_org_token", "mint_workspace_token",
"get_org_plugin_allowlist", "set_org_plugin_allowlist",
"export_bundle", "import_bundle",
"list_org_events", "list_pending_approvals",
"list_org_events", "list_pending_approvals", "create_approval", "create_request",
]) {
expect(names).toContain(expected);
}
+72
View File
@@ -380,6 +380,57 @@ export async function handleListPendingApprovals() {
return toMcpResult(await mgmtGet("/approvals/pending"));
}
// create_approval (mcp-server#61) — raise an approval-kind request addressed
// to the USER via the unified requests system (same shape the workspace-mode
// tool uses; see ../approvals.ts handleCreateApproval). Without this tool the
// org concierge IMPROVISED approval demos by running gated/destructive ops
// (set_workspace_secret on itself → secret-change auto-restart → its own box
// terminated mid-turn, twice on 2026-06-11 — core#2573). Deliberately NO
// decide_approval here: deciding is the HUMAN side of the gate and an agent
// must never hold it.
const CreateApprovalMgmtSchema = z.object({
workspace_id: z.string().describe("Workspace the approval is raised for/anchored to"),
action: z.string().describe("What needs approval (becomes the request title)"),
reason: z.string().optional().describe("Why it's needed (becomes the detail)"),
});
export async function handleCreateApproval(args: unknown) {
const p = validate(args, CreateApprovalMgmtSchema);
return toMcpResult(
await mgmtCall("POST", `/workspaces/${encodeURIComponent(p.workspace_id)}/requests`, {
kind: "approval",
recipient_type: "user",
recipient_id: "",
title: p.action,
detail: p.reason,
}),
);
}
// create_request — the unified form (mirrors the workspace-mode tool in
// ../requests.ts): kind='task' asks the user to DO something; kind='approval'
// asks the user to APPROVE something. create_approval above is the
// approval-kind convenience alias (issue #61 names it explicitly).
const CreateRequestMgmtSchema = z.object({
workspace_id: z.string().describe("Workspace the request is raised for/anchored to"),
kind: z.enum(["task", "approval"]).describe("task = please do X; approval = please approve X"),
title: z.string().describe("Short title shown in the user's inbox"),
detail: z.string().optional().describe("Longer context / why"),
});
export async function handleCreateRequest(args: unknown) {
const p = validate(args, CreateRequestMgmtSchema);
return toMcpResult(
await mgmtCall("POST", `/workspaces/${encodeURIComponent(p.workspace_id)}/requests`, {
kind: p.kind,
recipient_type: "user",
recipient_id: "",
title: p.title,
detail: p.detail,
}),
);
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
@@ -619,6 +670,27 @@ export function registerManagementTools(srv: McpServer) {
{},
handleListPendingApprovals,
);
srv.tool(
"create_approval",
"Management: raise an approval request to the user for a workspace action. Use this (NEVER a destructive/gated operation) when you need a human decision or want to demonstrate the approval flow.",
{
workspace_id: z.string().describe("Workspace the approval is raised for/anchored to"),
action: z.string().describe("What needs approval (becomes the request title)"),
reason: z.string().optional().describe("Why it's needed (becomes the detail)"),
},
handleCreateApproval,
);
srv.tool(
"create_request",
"Management: raise a request to the user — kind='task' asks them to DO something; kind='approval' asks them to APPROVE something. The safe way to put work or decisions in the user's inbox.",
{
workspace_id: z.string().describe("Workspace the request is raised for/anchored to"),
kind: z.enum(["task", "approval"]).describe("task = please do X; approval = please approve X"),
title: z.string().describe("Short title shown in the user's inbox"),
detail: z.string().optional().describe("Longer context / why"),
},
handleCreateRequest,
);
// --- CP-tier tools (separate module — Org API Key cannot reach CP) ---
registerCpAdminTools(srv);