diff --git a/package.json b/package.json index a3ffa5c..164bbc7 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/__tests__/management.test.ts b/src/__tests__/management.test.ts index cc32ffb..4a6d75e 100644 --- a/src/__tests__/management.test.ts +++ b/src/__tests__/management.test.ts @@ -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); } diff --git a/src/tools/management/index.ts b/src/tools/management/index.ts index 0bfafc3..561ec6c 100644 --- a/src/tools/management/index.ts +++ b/src/tools/management/index.ts @@ -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);