diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index f3d81b7..474b8cd 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1162,9 +1162,17 @@ 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(89); + expect(names.length).toBe(96); // create_issue (Gitea bug-filing) must be wired into the default surface. expect(names).toContain("create_issue"); + // Unified requests/inbox tools (RFC P2) — all 7 wired into the surface. + expect(names).toContain("create_request"); + expect(names).toContain("list_inbox"); + expect(names).toContain("check_requests"); + expect(names).toContain("get_request"); + expect(names).toContain("respond_request"); + expect(names).toContain("add_request_message"); + expect(names).toContain("cancel_request"); // 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); diff --git a/src/__tests__/requests.test.ts b/src/__tests__/requests.test.ts new file mode 100644 index 0000000..dcb63b7 --- /dev/null +++ b/src/__tests__/requests.test.ts @@ -0,0 +1,188 @@ +/** + * Unit tests for the unified requests / inbox tools (src/tools/requests.ts). + * + * fetch is mocked globally (no real HTTP). Each test asserts the handler hits + * the right path + method + body and returns the standard MCP envelope. Mirrors + * the fetch-mock convention in index.test.ts / issues.test.ts. + */ + +import { PLATFORM_URL } from "../api.js"; +import { + handleCreateRequest, + handleListInbox, + handleCheckRequests, + handleGetRequest, + handleRespondRequest, + handleAddRequestMessage, + handleCancelRequest, +} from "../tools/requests.js"; + +function mockFetch(payload: unknown, ok = true, status = 200) { + const body = typeof payload === "string" ? payload : JSON.stringify(payload); + return jest.fn().mockResolvedValue({ + ok, + status, + headers: { get: () => null }, + text: jest.fn().mockResolvedValue(body), + }); +} + +function mockFetchSequence(responses: Array<{ payload: unknown; ok?: boolean; status?: number }>) { + const fn = jest.fn(); + for (const r of responses) { + fn.mockResolvedValueOnce({ + ok: r.ok ?? true, + status: r.status ?? 200, + headers: { get: () => null }, + text: jest.fn().mockResolvedValue(JSON.stringify(r.payload)), + }); + } + return fn; +} + +function bodyOf(result: { content: { type: string; text: string }[] }) { + return JSON.parse(result.content[0].text); +} + +afterEach(() => jest.restoreAllMocks()); + +describe("create_request", () => { + it("POSTs a task to the requester workspace's /requests with the full body", async () => { + global.fetch = mockFetch({ request_id: "req-1", status: "pending" }) as unknown as typeof fetch; + const res = await handleCreateRequest({ + workspace_id: "ws-1", + kind: "task", + recipient_type: "agent", + recipient_id: "ws-2", + title: "do the thing", + detail: "with care", + priority: 5, + }); + + const call = (global.fetch as jest.Mock).mock.calls[0]; + expect(call[0]).toBe(`${PLATFORM_URL}/workspaces/ws-1/requests`); + expect(call[1].method).toBe("POST"); + const sent = JSON.parse(call[1].body); + expect(sent).toEqual({ + kind: "task", + recipient_type: "agent", + recipient_id: "ws-2", + title: "do the thing", + detail: "with care", + priority: 5, + }); + expect(bodyOf(res).request_id).toBe("req-1"); + }); + + it("POSTs an approval addressed to a user", async () => { + global.fetch = mockFetch({ request_id: "req-2", status: "pending" }) as unknown as typeof fetch; + await handleCreateRequest({ + workspace_id: "ws-9", + kind: "approval", + recipient_type: "user", + recipient_id: "user-7", + title: "approve deploy", + }); + const sent = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); + expect(sent.kind).toBe("approval"); + expect(sent.recipient_type).toBe("user"); + expect(sent.recipient_id).toBe("user-7"); + }); +}); + +describe("list_inbox vs check_requests", () => { + it("list_inbox GETs the recipient inbox path with a status filter", async () => { + global.fetch = mockFetch([{ request_id: "req-1" }]) as unknown as typeof fetch; + await handleListInbox({ workspace_id: "ws-1", status: "pending" }); + const url = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(url).toBe(`${PLATFORM_URL}/workspaces/ws-1/requests/inbox?status=pending`); + expect((global.fetch as jest.Mock).mock.calls[0][1].method).toBe("GET"); + }); + + it("check_requests GETs the OUTGOING /requests path (not the inbox)", async () => { + global.fetch = mockFetch([{ request_id: "req-2" }]) as unknown as typeof fetch; + await handleCheckRequests({ workspace_id: "ws-1" }); + const url = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(url).toBe(`${PLATFORM_URL}/workspaces/ws-1/requests`); + }); +}); + +describe("get_request", () => { + it("GETs the per-workspace request path (agent auth scope)", async () => { + global.fetch = mockFetch({ request: { request_id: "req-1" }, messages: [] }) as unknown as typeof fetch; + const res = await handleGetRequest({ workspace_id: "ws-1", request_id: "req-1" }); + const url = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(url).toBe(`${PLATFORM_URL}/workspaces/ws-1/requests/req-1`); + expect(bodyOf(res).request.request_id).toBe("req-1"); + }); +}); + +describe("respond_request", () => { + it("POSTs the terminal action with responder_type=agent, responder_id=workspace_id", async () => { + global.fetch = mockFetch({ status: "done", request_id: "req-1" }) as unknown as typeof fetch; + await handleRespondRequest({ workspace_id: "ws-1", request_id: "req-1", action: "done" }); + const call = (global.fetch as jest.Mock).mock.calls[0]; + expect(call[0]).toBe(`${PLATFORM_URL}/workspaces/ws-1/requests/req-1/respond`); + expect(call[1].method).toBe("POST"); + const sent = JSON.parse(call[1].body); + expect(sent).toEqual({ action: "done", responder_type: "agent", responder_id: "ws-1" }); + }); + + it("also posts a thread message when `message` is supplied, returning both results", async () => { + global.fetch = mockFetchSequence([ + { payload: { status: "approved", request_id: "req-1" } }, + { payload: { status: "created", request_id: "req-1", message_id: "m-1" } }, + ]) as unknown as typeof fetch; + const res = await handleRespondRequest({ + workspace_id: "ws-1", + request_id: "req-1", + action: "approved", + message: "looks good", + }); + const calls = (global.fetch as jest.Mock).mock.calls; + expect(calls).toHaveLength(2); + expect(calls[1][0]).toBe(`${PLATFORM_URL}/workspaces/ws-1/requests/req-1/messages`); + const msgBody = JSON.parse(calls[1][1].body); + expect(msgBody).toEqual({ body: "looks good", author_type: "agent", author_id: "ws-1" }); + const out = bodyOf(res); + expect(out.respond.status).toBe("approved"); + expect(out.message.message_id).toBe("m-1"); + }); +}); + +describe("add_request_message", () => { + it("POSTs the thread message with author_type=agent, author_id=workspace_id", async () => { + global.fetch = mockFetch({ status: "created", request_id: "req-1", message_id: "m-9" }) as unknown as typeof fetch; + await handleAddRequestMessage({ workspace_id: "ws-1", request_id: "req-1", body: "need more info" }); + const call = (global.fetch as jest.Mock).mock.calls[0]; + expect(call[0]).toBe(`${PLATFORM_URL}/workspaces/ws-1/requests/req-1/messages`); + expect(call[1].method).toBe("POST"); + const sent = JSON.parse(call[1].body); + expect(sent).toEqual({ body: "need more info", author_type: "agent", author_id: "ws-1" }); + }); +}); + +describe("cancel_request", () => { + it("POSTs the cancel path for the requester workspace", async () => { + global.fetch = mockFetch({ status: "cancelled", request_id: "req-1" }) as unknown as typeof fetch; + const res = await handleCancelRequest({ workspace_id: "ws-1", request_id: "req-1" }); + const call = (global.fetch as jest.Mock).mock.calls[0]; + expect(call[0]).toBe(`${PLATFORM_URL}/workspaces/ws-1/requests/req-1/cancel`); + expect(call[1].method).toBe("POST"); + expect(bodyOf(res).status).toBe("cancelled"); + }); +}); + +describe("error passthrough", () => { + it("surfaces a non-2xx platform error in the envelope (HTTP )", async () => { + global.fetch = mockFetch("boom", false, 500) as unknown as typeof fetch; + const res = await handleCreateRequest({ + workspace_id: "ws-1", + kind: "task", + recipient_type: "agent", + recipient_id: "ws-2", + title: "x", + }); + expect(bodyOf(res).error).toContain("HTTP 500"); + }); +}); diff --git a/src/index.ts b/src/index.ts index 83cc645..ac3410d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { registerApprovalTools } from "./tools/approvals.js"; import { registerDiscoveryTools } from "./tools/discovery.js"; import { registerRemoteAgentTools } from "./tools/remote_agents.js"; import { registerIssueTools } from "./tools/issues.js"; +import { registerRequestTools } from "./tools/requests.js"; import { registerManagementTools } from "./tools/management/index.js"; // Re-exports so existing importers (tests, SDK consumers) keep working. @@ -231,6 +232,16 @@ export { giteaApiUrl, defaultIssueRepo, } from "./tools/issues.js"; +export { + registerRequestTools, + handleCreateRequest, + handleListInbox, + handleCheckRequests, + handleGetRequest, + handleRespondRequest, + handleAddRequestMessage, + handleCancelRequest, +} from "./tools/requests.js"; export { mgmtCall, mgmtGet, managementUrl } from "./tools/management/client.js"; export { registerCpAdminTools, handleListOrgs, handleGetOrg, cpUrl, cpConfigured } from "./tools/management/cp_admin.js"; @@ -264,6 +275,9 @@ export function createServer() { // host and an agent on the workspace surface both observe bugs worth // tracking). The tool name is unique, so it is safe in both registries. registerIssueTools(srv); + // Unified requests/inbox tools (RFC P2) — registered in BOTH modes, same + // as create_issue: an agent on either surface can raise/answer requests. + registerRequestTools(srv); return srv; } @@ -280,6 +294,7 @@ export function createServer() { registerDiscoveryTools(srv); registerRemoteAgentTools(srv); registerIssueTools(srv); + registerRequestTools(srv); return srv; } @@ -347,7 +362,7 @@ async function main() { mode: "management", }); } else { - logInfo("Molecule AI MCP server running on stdio (89 tools available)", { transport: "stdio", toolCount: 89 }); + logInfo("Molecule AI MCP server running on stdio (96 tools available)", { transport: "stdio", toolCount: 96 }); } } diff --git a/src/tools/requests.ts b/src/tools/requests.ts new file mode 100644 index 0000000..1d41400 --- /dev/null +++ b/src/tools/requests.ts @@ -0,0 +1,271 @@ +/** + * Unified requests / inbox tools — RFC "unified-requests-inbox", Phase 2. + * + * These are the AGENT-FACING MCP tools for the requests subsystem: the one + * primitive that generalizes "tasks" (agent → user/agent asks) and "approvals" + * (the gate) into a single inbox keyed by `kind` ∈ {task, approval}, where both + * the requester and the recipient may be a user OR another agent. + * + * Responding is ASYNCHRONOUS: a requester is never blocked. It raises a request + * (`create_request`), keeps working, and later picks up the answer with + * `check_requests`. A recipient sees incoming work via `list_inbox` and acts on + * it with `respond_request` / `add_request_message`. + * + * Every tool acts AS a workspace (the agent), mirroring the approvals tools + * which all take `workspace_id`. The Phase-1 workspace-server registers the + * agent-side action verbs under the per-workspace, workspace-token-auth prefix + * `/workspaces/:id/requests/...` (the bare `/requests/:requestId/...` paths are + * AdminAuth-gated for the canvas user — NOT reachable with a workspace token), + * so EVERY tool below — including get/respond/messages/cancel — routes through + * `/workspaces/{workspace_id}/requests/...`. See + * workspace-server/internal/router/router.go (the `wsAuth` group) and + * handlers/requests.go for the contract. + * + * The pre-existing approval tools (create_approval, decide_approval, …) are + * left untouched — they keep working against the old /approvals endpoints; the + * formal shim/deprecation is a later phase (P5). + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { apiCall, platformGet, toMcpResult } from "../api.js"; +import { validate } from "../utils/validation.js"; + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +const CreateRequestSchema = z.object({ + workspace_id: z.string().describe("Acting workspace (the requesting agent)"), + kind: z.enum(["task", "approval"]).describe("task = please do X; approval = please approve X"), + recipient_type: z.enum(["user", "agent"]).describe("Whether the recipient is a user or another agent"), + recipient_id: z + .string() + .describe("Recipient id — a workspace id for an agent recipient, or a user id for a user recipient"), + title: z.string().describe("Short one-line summary of what is being asked"), + detail: z.string().optional().describe("Full detail / context for the request"), + priority: z.number().int().optional().describe("Optional integer priority (higher = more urgent)"), +}); +export type CreateRequestParams = z.infer; + +const ListInboxSchema = z.object({ + workspace_id: z.string().describe("Acting workspace (the recipient agent)"), + status: z + .string() + .optional() + .describe("Optional status filter, e.g. pending | info_requested | done | rejected | approved | cancelled"), +}); +export type ListInboxParams = z.infer; + +const CheckRequestsSchema = z.object({ + workspace_id: z.string().describe("Acting workspace (the requesting agent)"), + status: z.string().optional().describe("Optional status filter (see list_inbox)"), +}); +export type CheckRequestsParams = z.infer; + +const GetRequestSchema = z.object({ + workspace_id: z.string().describe("Acting workspace (the agent making the call; used for auth scope)"), + request_id: z.string().describe("Request id to fetch"), +}); +export type GetRequestParams = z.infer; + +const RespondRequestSchema = z.object({ + workspace_id: z.string().describe("Acting workspace (the responding agent)"), + request_id: z.string().describe("Request id to respond to"), + action: z + .enum(["done", "rejected", "approved"]) + .describe("Terminal action — must be valid for the request's kind (task → done/rejected; approval → approved/rejected)"), + message: z + .string() + .optional() + .describe("Optional note posted to the request's More-Info thread alongside the response"), +}); +export type RespondRequestParams = z.infer; + +const AddRequestMessageSchema = z.object({ + workspace_id: z.string().describe("Acting workspace (the agent authoring the message)"), + request_id: z.string().describe("Request id whose thread to append to"), + body: z.string().describe("Message text. If the author is the recipient, this flips the request to info_requested"), +}); +export type AddRequestMessageParams = z.infer; + +const CancelRequestSchema = z.object({ + workspace_id: z.string().describe("Acting workspace (the requester withdrawing the request)"), + request_id: z.string().describe("Request id to cancel/withdraw"), +}); +export type CancelRequestParams = z.infer; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +export async function handleCreateRequest(args: unknown): Promise> { + const p = validate(args, CreateRequestSchema); + const data = await apiCall("POST", `/workspaces/${p.workspace_id}/requests`, { + kind: p.kind, + recipient_type: p.recipient_type, + recipient_id: p.recipient_id, + title: p.title, + detail: p.detail, + priority: p.priority, + }); + return toMcpResult(data); +} + +export async function handleListInbox(args: unknown): Promise> { + const p = validate(args, ListInboxSchema); + const qs = p.status ? `?status=${encodeURIComponent(p.status)}` : ""; + const data = await platformGet(`/workspaces/${p.workspace_id}/requests/inbox${qs}`); + return toMcpResult(data); +} + +export async function handleCheckRequests(args: unknown): Promise> { + const p = validate(args, CheckRequestsSchema); + const qs = p.status ? `?status=${encodeURIComponent(p.status)}` : ""; + const data = await platformGet(`/workspaces/${p.workspace_id}/requests${qs}`); + return toMcpResult(data); +} + +export async function handleGetRequest(args: unknown): Promise> { + const p = validate(args, GetRequestSchema); + const data = await platformGet(`/workspaces/${p.workspace_id}/requests/${p.request_id}`); + return toMcpResult(data); +} + +export async function handleRespondRequest(args: unknown): Promise> { + const p = validate(args, RespondRequestSchema); + const data = await apiCall( + "POST", + `/workspaces/${p.workspace_id}/requests/${p.request_id}/respond`, + { action: p.action, responder_type: "agent", responder_id: p.workspace_id } + ); + // If a note was supplied, post it to the More-Info thread too. The response + // envelope returns both results so the caller sees each outcome (no silent + // drop if the thread post fails). + if (p.message && p.message.trim().length > 0) { + const msg = await apiCall( + "POST", + `/workspaces/${p.workspace_id}/requests/${p.request_id}/messages`, + { body: p.message, author_type: "agent", author_id: p.workspace_id } + ); + return toMcpResult({ respond: data, message: msg }); + } + return toMcpResult(data); +} + +export async function handleAddRequestMessage(args: unknown): Promise> { + const p = validate(args, AddRequestMessageSchema); + const data = await apiCall( + "POST", + `/workspaces/${p.workspace_id}/requests/${p.request_id}/messages`, + { body: p.body, author_type: "agent", author_id: p.workspace_id } + ); + return toMcpResult(data); +} + +export async function handleCancelRequest(args: unknown): Promise> { + const p = validate(args, CancelRequestSchema); + const data = await apiCall("POST", `/workspaces/${p.workspace_id}/requests/${p.request_id}/cancel`); + return toMcpResult(data); +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +export function registerRequestTools(srv: McpServer) { + srv.tool( + "create_request", + "Raise a request (a task or an approval) addressed to a user or another agent. " + + "kind='task' asks someone to DO something; kind='approval' asks someone to APPROVE something. " + + "Asynchronous: you are not blocked — poll for the answer later with check_requests.", + { + workspace_id: z.string().describe("Acting workspace (the requesting agent)"), + kind: z.enum(["task", "approval"]).describe("task = please do X; approval = please approve X"), + recipient_type: z.enum(["user", "agent"]).describe("Whether the recipient is a user or another agent"), + recipient_id: z + .string() + .describe("Recipient id — a workspace id for an agent recipient, or a user id for a user recipient"), + title: z.string().describe("Short one-line summary of what is being asked"), + detail: z.string().optional().describe("Full detail / context for the request"), + priority: z.number().int().optional().describe("Optional integer priority (higher = more urgent)"), + }, + handleCreateRequest + ); + + srv.tool( + "list_inbox", + "List requests addressed TO this agent (its inbox) — the incoming tasks/approvals it should act on. " + + "Optionally filter by status (e.g. pending).", + { + workspace_id: z.string().describe("Acting workspace (the recipient agent)"), + status: z + .string() + .optional() + .describe("Optional status filter, e.g. pending | info_requested | done | rejected | approved | cancelled"), + }, + handleListInbox + ); + + srv.tool( + "check_requests", + "Check the status of requests this agent RAISED (the async pickup of responses). " + + "Use after create_request to see whether a recipient has responded.", + { + workspace_id: z.string().describe("Acting workspace (the requesting agent)"), + status: z.string().optional().describe("Optional status filter (see list_inbox)"), + }, + handleCheckRequests + ); + + srv.tool( + "get_request", + "Get a single request plus its full More-Info message thread.", + { + workspace_id: z.string().describe("Acting workspace (the agent making the call; used for auth scope)"), + request_id: z.string().describe("Request id to fetch"), + }, + handleGetRequest + ); + + srv.tool( + "respond_request", + "Respond to a request addressed to this agent with a terminal action " + + "(done | rejected | approved — must be valid for the request's kind). " + + "Optionally include a message, which is also posted to the request's thread.", + { + workspace_id: z.string().describe("Acting workspace (the responding agent)"), + request_id: z.string().describe("Request id to respond to"), + action: z + .enum(["done", "rejected", "approved"]) + .describe("Terminal action — task → done/rejected; approval → approved/rejected"), + message: z + .string() + .optional() + .describe("Optional note posted to the request's More-Info thread alongside the response"), + }, + handleRespondRequest + ); + + srv.tool( + "add_request_message", + "Add a message to a request's More-Info thread (e.g. to ask the requester for clarification). " + + "When the author is the recipient, this flips the request to info_requested.", + { + workspace_id: z.string().describe("Acting workspace (the agent authoring the message)"), + request_id: z.string().describe("Request id whose thread to append to"), + body: z.string().describe("Message text"), + }, + handleAddRequestMessage + ); + + srv.tool( + "cancel_request", + "Withdraw (cancel) a request this agent previously raised.", + { + workspace_id: z.string().describe("Acting workspace (the requester withdrawing the request)"), + request_id: z.string().describe("Request id to cancel/withdraw"), + }, + handleCancelRequest + ); +}