feat(mcp): sprints, epics, backlog, trash, checklists and release notes
Thirty new tools (54 in all) for what the API gained since the MCP: create a project and read its public showcase; sprints (list, progress and burndown, plan, change, start, close with carry-over, delete, move a ticket in or out); epics and the backlog (list epics with progress, set a ticket's parent, rank); bulk edit of up to 100 tickets by key, saying which refused; the trash; checklists; following a ticket, removing a relation, editing and deleting comments; editing and archiving releases, drafting and saving their notes; notifications. Searching filters by epic, sprint and dates and sorts by backlog order; get_issue shows parent, checklist, commits and followers; create and update take parent and start date. API: `unreadOnly` and `dryRun` were documented as objects in the OpenAPI; they are booleans now. Chapter 9 lists every tool; Word and PDF regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,9 @@ import { AddressInfo } from 'node:net';
|
||||
import { TASKSTREAM_API_URL } from './api/taskstream-api';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
const ISSUE_1 = '11111111-1111-4111-8111-111111111111';
|
||||
const ISSUE_2 = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
/** What the fake API received. */
|
||||
interface Received {
|
||||
method: string;
|
||||
@@ -75,6 +78,38 @@ function fakeApi(received: Received[]): Server {
|
||||
code: 'BUSINESS_RULE',
|
||||
message: 'Cannot move',
|
||||
});
|
||||
if (req.method === 'POST' && req.url === '/api/projects')
|
||||
return reply(201, { id: 'p2', key: 'DOCS', version: 1 });
|
||||
if (req.url === '/api/issues/WEB-1') return reply(200, { id: ISSUE_1 });
|
||||
if (req.url === '/api/issues/WEB-2') return reply(200, { id: ISSUE_2 });
|
||||
if (req.url === '/api/issues/bulk')
|
||||
return reply(200, {
|
||||
updated: 1,
|
||||
results: [
|
||||
{ issueId: ISSUE_1, ok: true, error: null },
|
||||
{ issueId: ISSUE_2, ok: false, error: 'Cannot move WEB-2' },
|
||||
],
|
||||
});
|
||||
if (req.url === '/api/sprints/s1/close')
|
||||
return reply(201, { id: 's1', version: 3 });
|
||||
if (req.url?.startsWith('/api/me/notifications?'))
|
||||
return reply(200, {
|
||||
unread: 1,
|
||||
items: [
|
||||
{
|
||||
id: 'n1',
|
||||
kind: 'MENTIONED',
|
||||
projectId: 'p1',
|
||||
projectKey: 'WEB',
|
||||
issueId: ISSUE_1,
|
||||
issueKey: 'WEB-1',
|
||||
actor: { id: '101', name: 'Ana' },
|
||||
summary: 'Ana te ha mencionado en WEB-1',
|
||||
readAt: null,
|
||||
createdAt: '2026-10-09T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
return reply(404, { statusCode: 404, message: 'Not found' });
|
||||
});
|
||||
}
|
||||
@@ -152,11 +187,24 @@ describe('TaskStream MCP', () => {
|
||||
'change_issue_status',
|
||||
'get_release',
|
||||
'whoami',
|
||||
'create_project',
|
||||
'list_epics',
|
||||
'bulk_update_issues',
|
||||
'delete_issue',
|
||||
'list_sprints',
|
||||
'get_sprint',
|
||||
'close_sprint',
|
||||
'draft_release_notes',
|
||||
'add_checklist_item',
|
||||
'my_notifications',
|
||||
]),
|
||||
);
|
||||
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||
expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true);
|
||||
expect(byName.get('create_issue')?.annotations?.readOnlyHint).toBe(false);
|
||||
expect(byName.get('delete_issue')?.annotations).toMatchObject({
|
||||
destructiveHint: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls the API with the caller's own token and compacts the answer", async () => {
|
||||
@@ -217,6 +265,68 @@ describe('TaskStream MCP', () => {
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('creates projects with the key in capitals', async () => {
|
||||
const result = await call(
|
||||
'create_project',
|
||||
{ key: 'docs', name: 'Documentación', isPublic: true },
|
||||
'pireon-token',
|
||||
);
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(received[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
body: { key: 'DOCS', name: 'Documentación', isPublic: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('changes many tickets by key and says which refused', async () => {
|
||||
const result = await call(
|
||||
'bulk_update_issues',
|
||||
{ issues: ['WEB-1', 'WEB-2'], status: 'DONE' },
|
||||
'pireon-token',
|
||||
);
|
||||
expect(received.at(-1)).toMatchObject({
|
||||
method: 'POST',
|
||||
url: '/api/issues/bulk',
|
||||
body: { issueIds: [ISSUE_1, ISSUE_2], status: 'DONE' },
|
||||
});
|
||||
expect(JSON.parse(result.content[0].text)).toEqual({
|
||||
updated: 1,
|
||||
refused: [{ issue: 'WEB-2', error: 'Cannot move WEB-2' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('closes a sprint carrying the unfinished tickets over', async () => {
|
||||
await call(
|
||||
'close_sprint',
|
||||
{ sprintId: 's1', carryOverTo: 's2' },
|
||||
'pireon-token',
|
||||
);
|
||||
expect(received[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
url: '/api/sprints/s1/close',
|
||||
body: { carryOverTo: 's2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the unread notifications', async () => {
|
||||
const result = await call('my_notifications', {}, 'pireon-token');
|
||||
expect(received[0].url).toBe(
|
||||
'/api/me/notifications?unreadOnly=true&limit=30',
|
||||
);
|
||||
expect(JSON.parse(result.content[0].text)).toEqual({
|
||||
unread: 1,
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
kind: 'MENTIONED',
|
||||
issue: 'WEB-1',
|
||||
who: 'Ana',
|
||||
read: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
/** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */
|
||||
function rawPost(headers: Record<string, string>): Promise<number> {
|
||||
const { port } = new URL(url);
|
||||
|
||||
Reference in New Issue
Block a user