Skip to content

Commit cb01b29

Browse files
committed
feat(mcp): add update_issue and update_issue_comment tools
Add editing capabilities to MCP server: - update_issue: edit title, description, status (close/reopen), labels - update_issue_comment: edit comment content MCP server now provides 6 tools for complete issue management: list_issues, view_issue, create_issue, update_issue, post_issue_comment, update_issue_comment
1 parent 7ae3863 commit cb01b29

2 files changed

Lines changed: 228 additions & 1 deletion

File tree

cli/lib/issues.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,166 @@ export async function createIssueComment(params: CreateIssueCommentParams): Prom
379379
throw new Error(formatHttpError("Failed to create issue comment", response.status, data));
380380
}
381381
}
382+
383+
export interface UpdateIssueParams {
384+
apiKey: string;
385+
apiBaseUrl: string;
386+
issueId: string;
387+
title?: string;
388+
description?: string;
389+
status?: number;
390+
labels?: string[];
391+
debug?: boolean;
392+
}
393+
394+
export interface UpdatedIssue {
395+
id: string;
396+
title: string;
397+
description: string | null;
398+
status: number;
399+
updated_at: string;
400+
labels: string[] | null;
401+
}
402+
403+
export async function updateIssue(params: UpdateIssueParams): Promise<UpdatedIssue> {
404+
const { apiKey, apiBaseUrl, issueId, title, description, status, labels, debug } = params;
405+
if (!apiKey) {
406+
throw new Error("API key is required");
407+
}
408+
if (!issueId) {
409+
throw new Error("issueId is required");
410+
}
411+
412+
const base = normalizeBaseUrl(apiBaseUrl);
413+
const url = new URL(`${base}/rpc/issue_update`);
414+
415+
const bodyObj: Record<string, unknown> = {
416+
issue_id: issueId,
417+
};
418+
if (title !== undefined) {
419+
bodyObj.title = title;
420+
}
421+
if (description !== undefined) {
422+
bodyObj.description = description;
423+
}
424+
if (status !== undefined) {
425+
bodyObj.status = status;
426+
}
427+
if (labels !== undefined) {
428+
bodyObj.labels = labels;
429+
}
430+
const body = JSON.stringify(bodyObj);
431+
432+
const headers: Record<string, string> = {
433+
"access-token": apiKey,
434+
"Prefer": "return=representation",
435+
"Content-Type": "application/json",
436+
};
437+
438+
if (debug) {
439+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
440+
console.log(`Debug: Resolved API base URL: ${base}`);
441+
console.log(`Debug: POST URL: ${url.toString()}`);
442+
console.log(`Debug: Auth scheme: access-token`);
443+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
444+
console.log(`Debug: Request body: ${body}`);
445+
}
446+
447+
const response = await fetch(url.toString(), {
448+
method: "POST",
449+
headers,
450+
body,
451+
});
452+
453+
if (debug) {
454+
console.log(`Debug: Response status: ${response.status}`);
455+
console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
456+
}
457+
458+
const data = await response.text();
459+
460+
if (response.ok) {
461+
try {
462+
return JSON.parse(data) as UpdatedIssue;
463+
} catch {
464+
throw new Error(`Failed to parse update issue response: ${data}`);
465+
}
466+
} else {
467+
throw new Error(formatHttpError("Failed to update issue", response.status, data));
468+
}
469+
}
470+
471+
export interface UpdateIssueCommentParams {
472+
apiKey: string;
473+
apiBaseUrl: string;
474+
commentId: string;
475+
content: string;
476+
debug?: boolean;
477+
}
478+
479+
export interface UpdatedIssueComment {
480+
id: string;
481+
issue_id: string;
482+
content: string;
483+
updated_at: string;
484+
}
485+
486+
export async function updateIssueComment(params: UpdateIssueCommentParams): Promise<UpdatedIssueComment> {
487+
const { apiKey, apiBaseUrl, commentId, content, debug } = params;
488+
if (!apiKey) {
489+
throw new Error("API key is required");
490+
}
491+
if (!commentId) {
492+
throw new Error("commentId is required");
493+
}
494+
if (!content) {
495+
throw new Error("content is required");
496+
}
497+
498+
const base = normalizeBaseUrl(apiBaseUrl);
499+
const url = new URL(`${base}/rpc/issue_comment_update`);
500+
501+
const bodyObj: Record<string, unknown> = {
502+
comment_id: commentId,
503+
content: content,
504+
};
505+
const body = JSON.stringify(bodyObj);
506+
507+
const headers: Record<string, string> = {
508+
"access-token": apiKey,
509+
"Prefer": "return=representation",
510+
"Content-Type": "application/json",
511+
};
512+
513+
if (debug) {
514+
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
515+
console.log(`Debug: Resolved API base URL: ${base}`);
516+
console.log(`Debug: POST URL: ${url.toString()}`);
517+
console.log(`Debug: Auth scheme: access-token`);
518+
console.log(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
519+
console.log(`Debug: Request body: ${body}`);
520+
}
521+
522+
const response = await fetch(url.toString(), {
523+
method: "POST",
524+
headers,
525+
body,
526+
});
527+
528+
if (debug) {
529+
console.log(`Debug: Response status: ${response.status}`);
530+
console.log(`Debug: Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
531+
}
532+
533+
const data = await response.text();
534+
535+
if (response.ok) {
536+
try {
537+
return JSON.parse(data) as UpdatedIssueComment;
538+
} catch {
539+
throw new Error(`Failed to parse update comment response: ${data}`);
540+
}
541+
} else {
542+
throw new Error(formatHttpError("Failed to update issue comment", response.status, data));
543+
}
544+
}

cli/lib/mcp-server.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pkg from "../package.json";
22
import * as config from "./config";
3-
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue } from "./issues";
3+
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "./issues";
44
import { resolveBaseUrls } from "./util";
55

66
// MCP SDK imports - Bun handles these directly
@@ -91,6 +91,41 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
9191
additionalProperties: false,
9292
},
9393
},
94+
{
95+
name: "update_issue",
96+
description: "Update an existing issue (title, description, status, labels). Use status=1 to close, status=0 to reopen.",
97+
inputSchema: {
98+
type: "object",
99+
properties: {
100+
issue_id: { type: "string", description: "Issue ID (UUID)" },
101+
title: { type: "string", description: "New title (supports \\n as newline)" },
102+
description: { type: "string", description: "New description (supports \\n as newline)" },
103+
status: { type: "number", description: "Status: 0=open, 1=closed" },
104+
labels: {
105+
type: "array",
106+
items: { type: "string" },
107+
description: "Labels to set on the issue",
108+
},
109+
debug: { type: "boolean", description: "Enable verbose debug logs" },
110+
},
111+
required: ["issue_id"],
112+
additionalProperties: false,
113+
},
114+
},
115+
{
116+
name: "update_issue_comment",
117+
description: "Update an existing issue comment",
118+
inputSchema: {
119+
type: "object",
120+
properties: {
121+
comment_id: { type: "string", description: "Comment ID (UUID)" },
122+
content: { type: "string", description: "New comment text (supports \\n as newline)" },
123+
debug: { type: "boolean", description: "Enable verbose debug logs" },
124+
},
125+
required: ["comment_id", "content"],
126+
additionalProperties: false,
127+
},
128+
},
94129
],
95130
};
96131
});
@@ -167,6 +202,35 @@ export async function startMcpServer(rootOpts?: RootOptsLike, extra?: { debug?:
167202
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
168203
}
169204

205+
if (toolName === "update_issue") {
206+
const issueId = String(args.issue_id || "").trim();
207+
if (!issueId) {
208+
return { content: [{ type: "text", text: "issue_id is required" }], isError: true };
209+
}
210+
const rawTitle = args.title !== undefined ? String(args.title) : undefined;
211+
const title = rawTitle !== undefined ? interpretEscapes(rawTitle) : undefined;
212+
const rawDescription = args.description !== undefined ? String(args.description) : undefined;
213+
const description = rawDescription !== undefined ? interpretEscapes(rawDescription) : undefined;
214+
const status = args.status !== undefined ? Number(args.status) : undefined;
215+
const labels = Array.isArray(args.labels) ? args.labels.map(String) : undefined;
216+
const result = await updateIssue({ apiKey, apiBaseUrl, issueId, title, description, status, labels, debug });
217+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
218+
}
219+
220+
if (toolName === "update_issue_comment") {
221+
const commentId = String(args.comment_id || "").trim();
222+
const rawContent = String(args.content || "");
223+
if (!commentId) {
224+
return { content: [{ type: "text", text: "comment_id is required" }], isError: true };
225+
}
226+
if (!rawContent) {
227+
return { content: [{ type: "text", text: "content is required" }], isError: true };
228+
}
229+
const content = interpretEscapes(rawContent);
230+
const result = await updateIssueComment({ apiKey, apiBaseUrl, commentId, content, debug });
231+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
232+
}
233+
170234
throw new Error(`Unknown tool: ${toolName}`);
171235
} catch (err) {
172236
const message = err instanceof Error ? err.message : String(err);

0 commit comments

Comments
 (0)