Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.

Commit a6e1025

Browse files
pasha-twclaude
andcommitted
feat: add epics-create-comment tool
Add support for creating comments on epics, mirroring the existing stories-create-comment tool. The Shortcut API and SDK already support epic comments, but the MCP server did not expose this capability. Changes: - Add createEpicComment method to ShortcutClientWrapper - Register epics-create-comment tool with write access - Add unit tests for the new tool Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5b63ed2 commit a6e1025

3 files changed

Lines changed: 114 additions & 2 deletions

File tree

src/client/shortcut.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
ShortcutClient as BaseClient,
66
CreateDoc,
77
CreateEpic,
8+
CreateEpicComment,
89
CreateIteration,
910
CreateLabelParams,
1011
CreateStoryComment,
@@ -26,6 +27,7 @@ import type {
2627
StoryLink,
2728
StorySlim,
2829
Task,
30+
ThreadedComment,
2931
UpdateDoc,
3032
UpdateEpic,
3133
UpdateIteration,
@@ -446,6 +448,18 @@ export class ShortcutClientWrapper {
446448
await this.client.deleteIteration(iterationPublicId);
447449
}
448450

451+
async createEpicComment(
452+
epicPublicId: number,
453+
params: CreateEpicComment,
454+
): Promise<ThreadedComment> {
455+
const response = await this.client.createEpicComment(epicPublicId, params);
456+
const epicComment = response?.data ?? null;
457+
458+
if (!epicComment) throw new Error(`Failed to create the comment: ${response.status}`);
459+
460+
return epicComment;
461+
}
462+
449463
async createEpic(params: CreateEpic): Promise<Epic> {
450464
const response = await this.client.createEpic(params);
451465
const epic = response?.data ?? null;

src/tools/epics.test.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,11 @@ describe("EpicTools", () => {
113113
expect(mockToolRead.mock.calls?.[0]?.[0]).toBe("epics-get-by-id");
114114
expect(mockToolRead.mock.calls?.[1]?.[0]).toBe("epics-search");
115115

116-
expect(mockToolWrite).toHaveBeenCalledTimes(3);
116+
expect(mockToolWrite).toHaveBeenCalledTimes(4);
117117
expect(mockToolWrite.mock.calls?.[0]?.[0]).toBe("epics-create");
118118
expect(mockToolWrite.mock.calls?.[1]?.[0]).toBe("epics-update");
119-
expect(mockToolWrite.mock.calls?.[2]?.[0]).toBe("epics-delete");
119+
expect(mockToolWrite.mock.calls?.[2]?.[0]).toBe("epics-create-comment");
120+
expect(mockToolWrite.mock.calls?.[3]?.[0]).toBe("epics-delete");
120121
});
121122
});
122123

@@ -425,6 +426,70 @@ describe("EpicTools", () => {
425426
});
426427
});
427428

429+
describe("createEpicComment method", () => {
430+
const getEpicMock = mock(async (id: number) => mockEpics.find((epic) => epic.id === id));
431+
const createEpicCommentMock = mock(async (_id: number, _params: { text: string }) => ({
432+
id: 100,
433+
text: "Test comment",
434+
app_url: "https://app.shortcut.com/test/epic/1/comments/100",
435+
}));
436+
437+
beforeEach(() => {
438+
createEpicCommentMock.mockClear();
439+
});
440+
441+
test("should create a comment on an epic", async () => {
442+
const mockClient = createMockClient({
443+
getEpic: getEpicMock,
444+
createEpicComment: createEpicCommentMock,
445+
});
446+
const epicTools = new EpicTools(mockClient);
447+
const result = await epicTools.createEpicComment({
448+
epicPublicId: 1,
449+
text: "Test comment",
450+
});
451+
452+
expect(getTextContent(result)).toBe(
453+
"Created comment on epic 1. Comment URL: https://app.shortcut.com/test/epic/1/comments/100.",
454+
);
455+
expect(createEpicCommentMock).toHaveBeenCalledTimes(1);
456+
expect(createEpicCommentMock.mock.calls?.[0]?.[0]).toBe(1);
457+
expect(createEpicCommentMock.mock.calls?.[0]?.[1]).toMatchObject({
458+
text: "Test comment",
459+
});
460+
});
461+
462+
test("should throw error when epic is not found", async () => {
463+
const mockClient = createMockClient({
464+
getEpic: mock(async () => null),
465+
createEpicComment: createEpicCommentMock,
466+
});
467+
const epicTools = new EpicTools(mockClient);
468+
469+
await expect(() =>
470+
epicTools.createEpicComment({
471+
epicPublicId: 999,
472+
text: "Test comment",
473+
}),
474+
).toThrow("Failed to retrieve Shortcut epic with public ID: 999");
475+
});
476+
477+
test("should throw error when text is empty", async () => {
478+
const mockClient = createMockClient({
479+
getEpic: getEpicMock,
480+
createEpicComment: createEpicCommentMock,
481+
});
482+
const epicTools = new EpicTools(mockClient);
483+
484+
await expect(() =>
485+
epicTools.createEpicComment({
486+
epicPublicId: 1,
487+
text: "",
488+
}),
489+
).toThrow("Epic comment text is required");
490+
});
491+
});
492+
428493
describe("deleteEpic method", () => {
429494
const getEpicMock = mock(async (id: number) => mockEpics.find((epic) => epic.id === id));
430495
const deleteEpicMock = mock(async (_id: number) => undefined);

src/tools/epics.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@ export class EpicTools extends BaseTools {
9393
async (params) => await tools.updateEpic(params),
9494
);
9595

96+
server.addToolWithWriteAccess(
97+
"epics-create-comment",
98+
"Add a comment to an epic.",
99+
{
100+
epicPublicId: z.number().positive().describe("Epic ID"),
101+
text: z.string().min(1).describe("Comment text"),
102+
},
103+
async (params) => await tools.createEpicComment(params),
104+
);
105+
96106
server.addToolWithWriteAccess(
97107
"epics-delete",
98108
"Delete an epic (cannot be undone).",
@@ -196,6 +206,29 @@ export class EpicTools extends BaseTools {
196206
return this.toResult(`Updated epic ${epicPublicId}. Epic URL: ${updatedEpic.app_url}`);
197207
}
198208

209+
async createEpicComment({
210+
epicPublicId,
211+
text,
212+
}: {
213+
epicPublicId: number;
214+
text: string;
215+
}): Promise<CallToolResult> {
216+
if (!epicPublicId) throw new Error("Epic public ID is required");
217+
if (!text) throw new Error("Epic comment text is required");
218+
219+
const epic = await this.client.getEpic(epicPublicId);
220+
if (!epic)
221+
throw new Error(`Failed to retrieve Shortcut epic with public ID: ${epicPublicId}`);
222+
223+
const epicComment = await this.client.createEpicComment(epicPublicId, {
224+
text,
225+
});
226+
227+
return this.toResult(
228+
`Created comment on epic ${epicPublicId}. Comment URL: ${epicComment.app_url}.`,
229+
);
230+
}
231+
199232
async deleteEpic(epicPublicId: number): Promise<CallToolResult> {
200233
const epic = await this.client.getEpic(epicPublicId);
201234
if (!epic) throw new Error(`Failed to retrieve Shortcut epic with public ID: ${epicPublicId}`);

0 commit comments

Comments
 (0)