diff --git a/README.md b/README.md index e5e5034..d3a9807 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ After saving the server, restart the app before testing `who_am_i` or `list_apps - `count_devices` - `list_issues` - `get_issue` +- `update_issue_status` - `get_issue_stats` - `get_issue_device_stats` - `get_issue_devices` diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index c2cdb6f..68af6b1 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -55,6 +55,16 @@ describe("BugfenderClient", () => { expect(init.method).toBe("POST"); expect(init.body).toBe(JSON.stringify({ key: "value" })); }); + + it("makes PUT requests with JSON body", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ ok: true })); + const client = makeClient(); + await client.put("/app/app-1/issues-aggregation/hash-1", { status: 3 }); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://api.test/app/app-1/issues-aggregation/hash-1"); + expect(init.method).toBe("PUT"); + expect(init.body).toBe(JSON.stringify({ status: 3 })); + }); }); describe("buildHeaders", () => { diff --git a/src/client.ts b/src/client.ts index 59437e1..698a452 100644 --- a/src/client.ts +++ b/src/client.ts @@ -42,6 +42,17 @@ export class BugfenderClient { ); } + async put(path: string, body?: unknown, requireAuth = true): Promise { + return this.request( + path, + { + method: "PUT", + body: body ? JSON.stringify(body) : undefined, + }, + requireAuth, + ); + } + private async request(path: string, init: RequestInit, requireAuth: boolean): Promise { if (requireAuth && !this.apiToken && !this.refreshToken) { throw new BugfenderApiError("Missing Bugfender token", 401, null); diff --git a/src/tools/issues.ts b/src/tools/issues.ts index b5edbe9..bcd9b26 100644 --- a/src/tools/issues.ts +++ b/src/tools/issues.ts @@ -12,6 +12,30 @@ const issueTypeByName: Record = { "user feedback": "2", }; +const issueStatusByName: Record = { + new: 0, + open: 1, + in_progress: 2, + resolved: 3, + closed: 4, + muted: 5, + invalid: 6, +}; + +function normalizeIssueStatus(value: string): number { + const trimmed = value.trim().toLowerCase(); + if (/^\d+$/.test(trimmed)) { + return Number(trimmed); + } + + const status = issueStatusByName[trimmed]; + if (status === undefined) { + throw new Error(`Invalid issue status: ${value}. Use one of: ${Object.keys(issueStatusByName).join(", ")}`); + } + + return status; +} + function normalizeIssueType(value?: string): string | undefined { if (!value) { return undefined; @@ -111,6 +135,25 @@ export function registerIssueTools(server: McpServer, context: ServerContext): v ), ); + server.tool( + "update_issue_status", + "Updates the status of an issue group (issues aggregation). Use this to mark issues as resolved, closed, in progress, etc.", + { + app_id: z.string().describe("The public app ID (e.g. 5X3c4veRGV) from list_apps"), + issue_id: z.string().describe("The issue group hash from list_issues or get_issue"), + status: z + .string() + .describe("New status: new, open, in_progress, resolved, closed, muted, or invalid"), + }, + ({ app_id, issue_id, status }) => + handleTool(context, async () => { + await context.client.put(`/app/${app_id}/issues-aggregation/${issue_id}`, { + status: normalizeIssueStatus(status), + }); + return ok({ app_id, issue_id, status: status.trim().toLowerCase() }); + }), + ); + server.tool( "get_feedback", {