feat(requests): P2 — agent MCP tools for unified requests/inbox (RFC) #56

Merged
claude-ceo-assistant merged 1 commits from feat/unified-requests-inbox-p2-mcp into main 2026-06-10 14:55:35 +00:00
4 changed files with 484 additions and 2 deletions
+9 -1
View File
@@ -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);
+188
View File
@@ -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 <code>)", 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");
});
});
+16 -1
View File
@@ -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 });
}
}
+271
View File
@@ -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<typeof CreateRequestSchema>;
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<typeof ListInboxSchema>;
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<typeof CheckRequestsSchema>;
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<typeof GetRequestSchema>;
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<typeof RespondRequestSchema>;
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<typeof AddRequestMessageSchema>;
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<typeof CancelRequestSchema>;
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
export async function handleCreateRequest(args: unknown): Promise<ReturnType<typeof toMcpResult>> {
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<ReturnType<typeof toMcpResult>> {
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<ReturnType<typeof toMcpResult>> {
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<ReturnType<typeof toMcpResult>> {
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<ReturnType<typeof toMcpResult>> {
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<ReturnType<typeof toMcpResult>> {
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<ReturnType<typeof toMcpResult>> {
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
);
}