186 lines
6.4 KiB
TypeScript
186 lines
6.4 KiB
TypeScript
import {
|
||
cancelChangeRequest,
|
||
consumeApproval,
|
||
createChangeRequest,
|
||
getChangeRequestForAgent,
|
||
listChangeRequestsForAgent,
|
||
updateChangeRequest,
|
||
CreateRequestInput,
|
||
} from "../../ChangeRequestService";
|
||
import { Change } from "../../ChangeNormalization";
|
||
import { CHANGES_SCHEMA, McpToolDef, errResult, fromService, json } from "../shared";
|
||
|
||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
|
||
function coerceChanges(raw: unknown): Change[] | { error: string } {
|
||
if (!Array.isArray(raw) || raw.length === 0) {
|
||
return { error: "changes must be a non-empty array" };
|
||
}
|
||
const out: Change[] = [];
|
||
for (const [i, item] of raw.entries()) {
|
||
if (!item || typeof item !== "object") return { error: `changes[${i}] must be an object` };
|
||
const c = item as any;
|
||
if (c.type === "unified_diff") {
|
||
if (typeof c.path !== "string" || typeof c.content !== "string") {
|
||
return { error: `changes[${i}] (unified_diff) needs string 'path' and 'content'` };
|
||
}
|
||
out.push({ type: "unified_diff", path: c.path, content: c.content });
|
||
} else if (c.type === "config") {
|
||
if (typeof c.path !== "string") return { error: `changes[${i}] (config) needs a string 'path'` };
|
||
out.push({
|
||
type: "config",
|
||
path: c.path,
|
||
before: c.before,
|
||
after: c.after,
|
||
content_type: typeof c.content_type === "string" ? c.content_type : undefined,
|
||
});
|
||
} else if (c.type === "custom") {
|
||
if (typeof c.label !== "string") return { error: `changes[${i}] (custom) needs a string 'label'` };
|
||
out.push({ type: "custom", label: c.label, before: c.before, after: c.after });
|
||
} else {
|
||
return { error: `changes[${i}] has invalid type '${c.type}'` };
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function buildInput(args: Record<string, unknown>): CreateRequestInput | { error: string } {
|
||
const title = typeof args.title === "string" ? args.title : undefined;
|
||
if (!title) return { error: "title is required" };
|
||
const changes = coerceChanges(args.changes);
|
||
if ("error" in changes) return { error: changes.error };
|
||
return {
|
||
title,
|
||
description: typeof args.description === "string" ? args.description : null,
|
||
changes,
|
||
expires_in: typeof args.expires_in === "number" ? args.expires_in : undefined,
|
||
metadata:
|
||
args.metadata && typeof args.metadata === "object"
|
||
? (args.metadata as Record<string, unknown>)
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
const commonProps = {
|
||
title: { type: "string", description: "Short, human-readable title (1–200 chars)" },
|
||
description: { type: "string", description: "Optional context for the reviewer" },
|
||
changes: CHANGES_SCHEMA,
|
||
expires_in: {
|
||
type: "integer",
|
||
description: "Seconds until the request expires (60–43200; default 1800 = 30 min)",
|
||
},
|
||
metadata: {
|
||
type: "object",
|
||
description: "Optional free-form context, e.g. { repository, environment }",
|
||
},
|
||
};
|
||
|
||
export const createRequest: McpToolDef = {
|
||
name: "create_request",
|
||
description:
|
||
"Submit a change request for human approval. Returns request_id, approval_url, and state (PENDING). Poll get_request until decided.",
|
||
inputSchema: { type: "object", required: ["title", "changes"], properties: commonProps },
|
||
async handler(args, { agent }) {
|
||
const input = buildInput(args);
|
||
if ("error" in input) return errResult(input.error);
|
||
return fromService(await createChangeRequest(agent, input));
|
||
},
|
||
};
|
||
|
||
export const updateRequest: McpToolDef = {
|
||
name: "update_request",
|
||
description:
|
||
"Update a PENDING or CHANGES_REQUESTED request with revised content (e.g. after the human requested changes). Resets the request to PENDING for re-review.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["request_id", "title", "changes"],
|
||
properties: { request_id: { type: "string" }, ...commonProps },
|
||
},
|
||
async handler(args, { agent }) {
|
||
const requestId = typeof args.request_id === "string" ? args.request_id : undefined;
|
||
if (!requestId) return errResult("request_id is required");
|
||
const input = buildInput(args);
|
||
if ("error" in input) return errResult(input.error);
|
||
return fromService(await updateChangeRequest(agent, requestId, input));
|
||
},
|
||
};
|
||
|
||
export const getRequest: McpToolDef = {
|
||
name: "get_request",
|
||
description:
|
||
"Fetch a request by id: its current state, comment, and (once decided) the platform-signed receipt.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["request_id"],
|
||
properties: { request_id: { type: "string" } },
|
||
},
|
||
async handler(args, { agent }) {
|
||
const requestId = typeof args.request_id === "string" ? args.request_id : undefined;
|
||
if (!requestId) return errResult("request_id is required");
|
||
return fromService(await getChangeRequestForAgent(agent, requestId));
|
||
},
|
||
};
|
||
|
||
export const cancelRequest: McpToolDef = {
|
||
name: "cancel_request",
|
||
description: "Cancel your own request before a decision is made (PENDING or CHANGES_REQUESTED).",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["request_id"],
|
||
properties: { request_id: { type: "string" } },
|
||
},
|
||
async handler(args, { agent }) {
|
||
const requestId = typeof args.request_id === "string" ? args.request_id : undefined;
|
||
if (!requestId) return errResult("request_id is required");
|
||
return fromService(await cancelChangeRequest(agent, requestId));
|
||
},
|
||
};
|
||
|
||
export const consumeApprovalTool: McpToolDef = {
|
||
name: "consume_approval",
|
||
description:
|
||
"Consume an APPROVED request (single-use) before applying changes. Returns the signed receipt. Fails if not APPROVED or already consumed.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["request_id"],
|
||
properties: { request_id: { type: "string" } },
|
||
},
|
||
async handler(args, { agent }) {
|
||
const requestId = typeof args.request_id === "string" ? args.request_id : undefined;
|
||
if (!requestId) return errResult("request_id is required");
|
||
return fromService(await consumeApproval(agent, requestId));
|
||
},
|
||
};
|
||
|
||
export const listRequests: McpToolDef = {
|
||
name: "list_requests",
|
||
description: "List your recent change requests, optionally filtered by state.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
state: {
|
||
type: "string",
|
||
enum: [
|
||
"PENDING",
|
||
"CHANGES_REQUESTED",
|
||
"APPROVED",
|
||
"REJECTED",
|
||
"EXPIRED",
|
||
"CONSUMED",
|
||
"CANCELLED",
|
||
],
|
||
},
|
||
page: { type: "integer" },
|
||
page_size: { type: "integer" },
|
||
},
|
||
},
|
||
async handler(args, { agent }) {
|
||
const data = await listChangeRequestsForAgent(agent, {
|
||
state: typeof args.state === "string" ? args.state : undefined,
|
||
page: typeof args.page === "number" ? args.page : 1,
|
||
pageSize: typeof args.page_size === "number" ? args.page_size : 20,
|
||
});
|
||
return json(data);
|
||
},
|
||
};
|