@taiga-ai/mcp-server 0.25.0-rc.474 → 0.26.0-dev.481

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +111 -0
  2. package/dist/index.js +292 -115
  3. package/package.json +15 -2
  4. package/scripts/build.mjs +0 -37
  5. package/scripts/prepare-publish.cjs +0 -49
  6. package/src/client.test.ts +0 -424
  7. package/src/client.ts +0 -977
  8. package/src/config.test.ts +0 -24
  9. package/src/config.ts +0 -28
  10. package/src/index.ts +0 -45
  11. package/src/modern.integration.test.ts +0 -153
  12. package/src/server.integration.test.ts +0 -9214
  13. package/src/server.ts +0 -18
  14. package/src/tools/annotations.ts +0 -38
  15. package/src/tools/autonomy.ts +0 -86
  16. package/src/tools/backlogs.ts +0 -802
  17. package/src/tools/builds.ts +0 -668
  18. package/src/tools/catalogue.test.ts +0 -170
  19. package/src/tools/catalogue.ts +0 -223
  20. package/src/tools/context.ts +0 -513
  21. package/src/tools/documents.ts +0 -375
  22. package/src/tools/errors.ts +0 -172
  23. package/src/tools/hints.ts +0 -99
  24. package/src/tools/inbox.ts +0 -546
  25. package/src/tools/index.ts +0 -239
  26. package/src/tools/inputs.ts +0 -47
  27. package/src/tools/instructions.ts +0 -41
  28. package/src/tools/jobs.ts +0 -90
  29. package/src/tools/knowledge.ts +0 -249
  30. package/src/tools/maintenance.ts +0 -547
  31. package/src/tools/markdown.test.ts +0 -249
  32. package/src/tools/markdown.ts +0 -151
  33. package/src/tools/observe.ts +0 -311
  34. package/src/tools/product-documents.ts +0 -184
  35. package/src/tools/products.ts +0 -529
  36. package/src/tools/prompts.ts +0 -265
  37. package/src/tools/queue.ts +0 -250
  38. package/src/tools/readiness.ts +0 -177
  39. package/src/tools/remote.test.ts +0 -44
  40. package/src/tools/remote.ts +0 -107
  41. package/src/tools/repositories.ts +0 -360
  42. package/src/tools/request-prompt.test.ts +0 -32
  43. package/src/tools/request-prompt.ts +0 -28
  44. package/src/tools/resources.ts +0 -217
  45. package/src/tools/sections.test.ts +0 -143
  46. package/src/tools/sections.ts +0 -287
  47. package/src/tools/setup.ts +0 -455
  48. package/src/tools/summarize.test.ts +0 -20
  49. package/src/tools/summarize.ts +0 -96
  50. package/src/tools/wait.ts +0 -490
  51. package/src/version.test.ts +0 -10
  52. package/src/version.ts +0 -14
  53. package/taiga-ai-mcp-server-0.25.0-rc.474.tgz +0 -0
  54. package/tsconfig.json +0 -21
  55. package/vitest.config.ts +0 -9
@@ -1,802 +0,0 @@
1
- import type { McpServer } from '@modelcontextprotocol/server';
2
- import { z } from 'zod';
3
- import {
4
- type ActivityEvent,
5
- type ActivityActor,
6
- type BacklogItem,
7
- BUILD_RUN_STATUSES,
8
- INITIATIVE_STATES,
9
- PLAN_REJECTION_COMMENT_MAX_CHARS,
10
- PR_CLOSING_STATES,
11
- USER_DROP_STATES,
12
- renderHalt,
13
- resolveAutoBuild,
14
- type BuildRunStatus,
15
- type Halt,
16
- } from '@taiga/types';
17
- import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
18
- import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
19
- import { idInput, pagination, productId } from './inputs.js';
20
- import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
21
- import { jobReport } from './jobs.js';
22
- import { requestPrompt } from './request-prompt.js';
23
- import { queueView } from './queue.js';
24
- import { handbackNextStep, handbackSaid, landedDisposition } from './builds.js';
25
- import { SEARCH_PAGE_SIZE, criteriaCounts, itemRows, page, pickFields, searchPages } from './summarize.js';
26
-
27
- /** What a list row needs to say for the model to pick an item to read in full. */
28
- export const BACKLOG_SUMMARY_FIELDS = [
29
- 'id',
30
- 'title',
31
- 'category',
32
- 'status',
33
- 'state',
34
- 'planningStatus',
35
- 'priority',
36
- 'estimate',
37
- 'origin',
38
- 'needsHumanCause',
39
- 'archivedAt',
40
- 'createdAt',
41
- 'updatedAt',
42
- ] as const;
43
-
44
- /**
45
- * What an initiative says about itself after a write: where it is on the
46
- * line, whose turn it is and what the line is waiting for. Not the brief,
47
- * the criteria or the technical context the caller already has.
48
- */
49
- const ITEM_OUTCOME_FIELDS = [
50
- 'id',
51
- 'productId',
52
- 'title',
53
- 'status',
54
- 'state',
55
- 'planningStatus',
56
- 'needsHumanCause',
57
- 'needsHumanDetail',
58
- 'autoBuild',
59
- 'archivedAt',
60
- 'lifecycle',
61
- ] as const;
62
-
63
- /**
64
- * What the lifecycle on an initiative says, and the call each next step is.
65
- * needsHumanCause is only the line's flag; a plan that failed outside the line
66
- * leaves it null, and the lifecycle is what says why and what moves it on.
67
- */
68
- const LIFECYCLE_MEANING =
69
- 'situation says in a sentence where it stands and what moves it on. lifecycle.cta names the next call: ' +
70
- 'start_plan taiga_queue_initiative, retry_plan taiga_plan_again (planFailureCause says why), start_build ' +
71
- 'taiga_approve_plan, resume_build taiga_resume_initiative, retry_build a fresh run (web only), review_pr and ' +
72
- 'view_pr the pull request. needsHumanCause is set only when the line stopped on it.';
73
-
74
- /** The move each call to action is, in the tools' words. */
75
- const CTA_MOVE: Record<string, string> = {
76
- start_plan: 'taiga_queue_initiative puts it on the line to be planned and built (paid work: ask first)',
77
- retry_plan: 'taiga_plan_again asks for a new plan (paid work: ask first)',
78
- start_build:
79
- 'read the plan with taiga_get_plan, then taiga_approve_plan builds it or taiga_reject_plan sends it back',
80
- resume_build: 'taiga_resume_initiative carries the run on (paid work: ask first)',
81
- retry_build: 'a new run starts over from the web; taiga_move_initiative takes it off the line',
82
- review_pr: 'a person reviews and merges the pull request',
83
- view_pr: 'the line merges the pull request once its checks pass',
84
- };
85
-
86
- /** How a run stands, as the end of "its latest run ...", by disposition. */
87
- const RUN_STANDS: Record<string, string> = {
88
- in_flight: 'is running',
89
- gated: 'is in review',
90
- succeeded: 'merged',
91
- unmerged: 'settled unmerged',
92
- failed: 'failed',
93
- stopped: 'was stopped',
94
- rejected: 'had its pull request closed without merging',
95
- };
96
-
97
- /** Where an initiative is, in a phrase, by its lifecycle stage. */
98
- const STAGE: Record<string, string> = {
99
- backlog: 'In the backlog, not on the line yet',
100
- planning: 'Being planned',
101
- planned: 'Planned',
102
- building: 'Being built',
103
- in_review: 'Its pull request is in review',
104
- done: 'Done',
105
- canceled: 'Canceled',
106
- };
107
-
108
- /**
109
- * Where an initiative stands and what moves it on, in one sentence (PRO-2800).
110
- * Five fields carried it (status, state, planningStatus, needsHumanCause,
111
- * lifecycle) and a reviewing agent had to read the queue, the initiative and
112
- * the run together to say what had happened; the lifecycle the API derives
113
- * already decides it. Null for a row without a lifecycle.
114
- */
115
- export function situationOf(item: Record<string, unknown>): string | null {
116
- const life = item['lifecycle'] as
117
- | { turn?: unknown; stage?: unknown; cta?: unknown; planFailureCause?: unknown; latestBuild?: unknown }
118
- | null
119
- | undefined;
120
- if (!life || typeof life.stage !== 'string') return null;
121
- const parts: string[] = [];
122
- let stage = STAGE[life.stage] ?? life.stage;
123
- if (life.stage === 'planning' && life.turn === 'you') {
124
- stage = `Planning stopped${typeof life.planFailureCause === 'string' ? ` (${life.planFailureCause})` : ''}`;
125
- }
126
- const build = life.latestBuild as { status?: unknown; prNumber?: unknown; prMerged?: unknown } | null | undefined;
127
- if (build && typeof build.status === 'string' && life.stage !== 'done' && life.stage !== 'canceled') {
128
- const pr = typeof build.prNumber === 'number' ? ` (PR #${build.prNumber})` : '';
129
- const said = (BUILD_RUN_STATUSES as readonly string[]).includes(build.status)
130
- ? RUN_STANDS[landedDisposition(build.status as BuildRunStatus, build.prMerged === true)]
131
- : build.status;
132
- stage += `; its latest run ${said}${pr}`;
133
- // Handed back: what the agent could not get past, which "in review" alone hides.
134
- const handback = handbackSaid((build as { reviewEscalation?: unknown }).reviewEscalation);
135
- if (handback) stage += `, handed back because ${handback}`;
136
- }
137
- if (life.stage === 'done' || life.stage === 'canceled') stage += endingSaid(item);
138
- parts.push(`${stage}.`);
139
- if (typeof item['needsHumanCause'] === 'string') {
140
- const halt = renderHalt({ cause: item['needsHumanCause'], params: {} } as unknown as Halt);
141
- const detail = typeof item['needsHumanDetail'] === 'string' ? item['needsHumanDetail'] : '';
142
- // The halt says what stopped the line; the detail is the run's own reason, kept beside it.
143
- const why = [halt, detail]
144
- .filter(Boolean)
145
- .map((text) => text.replace(/\.$/, ''))
146
- .join('. ');
147
- if (why) parts.push(`The line stopped on it: ${why}.`);
148
- }
149
- const turn = life.turn === 'you' ? "A person's turn" : life.turn === 'agent' ? "The agent's turn" : null;
150
- const handedBack = handbackSaid((build as { reviewEscalation?: unknown } | null | undefined)?.reviewEscalation);
151
- const move =
152
- life.cta === 'review_pr' && handedBack
153
- ? `a person should ${handbackNextStep((build as { reviewEscalation?: unknown }).reviewEscalation)}`
154
- : typeof life.cta === 'string'
155
- ? CTA_MOVE[life.cta]
156
- : undefined;
157
- if (life.cta === 'view_pr') parts.push(`Nothing to do: ${move}.`);
158
- else if (turn && move) parts.push(`${turn}: ${move}.`);
159
- else if (turn) parts.push(`${turn}.`);
160
- else if (move) parts.push(`Next: ${move}.`);
161
- return parts.join(' ');
162
- }
163
-
164
- /**
165
- * How a settled initiative ended, as the rest of its "Done" or "Canceled"
166
- * sentence (PRO-2821): the pull request that landed it, the move that settled
167
- * it and who made it, and what took over. Only the single read carries
168
- * `ending`; a list row says nothing more.
169
- */
170
- function endingSaid(item: Record<string, unknown>) {
171
- const ending = item['ending'] as
172
- | {
173
- via?: unknown;
174
- at?: unknown;
175
- by?: { name?: unknown; type?: unknown } | null;
176
- landedPr?: { number?: unknown } | null;
177
- supersededBy?: { id?: unknown; title?: unknown } | null;
178
- }
179
- | null
180
- | undefined;
181
- // The single read's ending decides. A list row has none, and its latest run cannot say how it ended: a merged
182
- // initiative can be reopened and moved to Done by hand, keeping that run. It says Done or Canceled alone.
183
- const merged = typeof ending?.landedPr?.number === 'number' ? ending.landedPr.number : null;
184
- const said: string[] = [];
185
- if (merged !== null) said.push(`: PR #${merged} merged`);
186
- else if (ending && (ending.via === 'moved' || ending.via === 'off_the_line')) {
187
- const who =
188
- typeof ending.by?.name === 'string' ? ending.by.name : ending.by?.type === 'system' ? 'Taiga' : 'someone';
189
- const when = typeof ending.at === 'string' ? ` on ${ending.at.slice(0, 10)}` : '';
190
- said.push(ending.via === 'off_the_line' ? ` by ${who}${when}, who took it off the line` : ` by ${who}${when}`);
191
- }
192
- const next = ending?.supersededBy;
193
- if (next && typeof next.id === 'string') {
194
- said.push(`; superseded by "${String(next.title)}" (${next.id})`);
195
- }
196
- return said.join('');
197
- }
198
-
199
- /** A row or item with its situation added when it has a lifecycle. */
200
- export function withSituation(
201
- item: Record<string, unknown>,
202
- row: Record<string, unknown> = item
203
- ): Record<string, unknown> {
204
- const situation = situationOf(item);
205
- return situation ? { ...row, situation } : row;
206
- }
207
-
208
- /** List rows, each with its situation. */
209
- export function situatedRows(items: Record<string, unknown>[]): Record<string, unknown>[] {
210
- return itemRows(items, BACKLOG_SUMMARY_FIELDS).map((row, index) => withSituation(items[index]!, row));
211
- }
212
-
213
- /** What each plan failure cause means for whoever reads it next (PLAN_FAILURE_CAUSES in @taiga/types). */
214
- const PLAN_FAILURE_MEANING: Record<string, string> = {
215
- agent_error: 'The planning agent failed. Planning again is the usual next step.',
216
- worker_lost: 'Nobody was running the planning any more (the worker stopped). Planning again is the usual next step.',
217
- cancelled: 'A person stopped the planning (by says who). Ask before planning it again.',
218
- };
219
-
220
- /**
221
- * Who or what ended a failed plan, and when, from the initiative's history
222
- * (PRO-2780): planFailureCause alone said "cancelled" with nobody behind it.
223
- * Best effort: a history the API will not give leaves the item as it was.
224
- */
225
- async function planFailureOf(
226
- client: TaigaClient,
227
- initiativeId: string,
228
- itemCause: unknown
229
- ): Promise<Record<string, unknown> | null> {
230
- let events: WithoutTenant<ActivityEvent>[];
231
- try {
232
- events = (await client.listBacklogItemActivity(initiativeId)).data;
233
- } catch (error) {
234
- if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
235
- throw error;
236
- }
237
- // Only a failure of the latest attempt: one before the last plan_started
238
- // belongs to an earlier attempt, even when it failed for the same reason.
239
- let failed: WithoutTenant<ActivityEvent> | undefined;
240
- for (const event of [...events].reverse()) {
241
- if (event.kind !== 'turn') continue;
242
- if (event.trigger === 'plan_started') break;
243
- if (event.trigger === 'plan_failed') {
244
- failed = event;
245
- break;
246
- }
247
- }
248
- if (!failed) return null;
249
- const actor: Partial<ActivityActor> = failed.actor ?? {};
250
- const data: { cause?: unknown } = failed.data ?? {};
251
- const cause = typeof data.cause === 'string' ? data.cause : null;
252
- // The event is recorded best effort: one that disagrees with the item's own
253
- // cause belongs to an earlier failure, and its who and when are not this one's.
254
- if (typeof itemCause === 'string' && cause !== itemCause) {
255
- return {
256
- cause: itemCause,
257
- meaning: PLAN_FAILURE_MEANING[itemCause] ?? 'Planning ended without a plan.',
258
- at: null,
259
- by: null,
260
- };
261
- }
262
- return {
263
- cause,
264
- meaning: (cause && PLAN_FAILURE_MEANING[cause]) ?? 'Planning ended without a plan.',
265
- at: failed.occurredAt ?? null,
266
- by: typeof actor.name === 'string' ? actor.name : (actor.type ?? null),
267
- };
268
- }
269
-
270
- const planId = idInput('plan', "taiga_get_plan gives the initiative's active plan and its ID");
271
-
272
- const initiativeId = idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it');
273
-
274
- /** The plan without its steps' prose: enough to approve it or say what a run will do. */
275
- const PLAN_SUMMARY_FIELDS = ['id', 'backlogItemId', 'version', 'status', 'createdAt'] as const;
276
- const PLAN_STEP_SUMMARY_FIELDS = [
277
- 'id',
278
- 'orderIndex',
279
- 'title',
280
- 'layer',
281
- 'dependencies',
282
- 'writes',
283
- 'reads',
284
- 'createsResources',
285
- ] as const;
286
-
287
- type Item = WithoutTenant<BacklogItem>;
288
-
289
- /** The outcome fields of an initiative, with how many of its criteria are met, and where it stands. */
290
- const outcomeOf = (item: Item) =>
291
- withSituation(item as unknown as Record<string, unknown>, {
292
- ...pickFields([item], ITEM_OUTCOME_FIELDS)[0],
293
- criteria: criteriaCounts(item),
294
- });
295
-
296
- const outcome = (item: Item) => toolResult(outcomeOf(item));
297
-
298
- /** The statuses an initiative in each board column can have: the API keeps in_progress after a first run. */
299
- const COLUMN_STATUSES: Record<(typeof INITIATIVE_STATES)[number], readonly string[]> = {
300
- backlog: ['all', 'open', 'backlog', 'in_progress'],
301
- todo: ['all', 'open', 'backlog', 'in_progress'],
302
- queue: ['all', 'open', 'backlog', 'in_progress'],
303
- plan: ['all', 'open', 'backlog', 'in_progress'],
304
- build: ['all', 'open', 'in_progress'],
305
- done: ['all', 'done'],
306
- canceled: ['all', 'canceled'],
307
- };
308
-
309
- /**
310
- * Each tool is registered under the capability its route requires, so a
311
- * key sees exactly what it may call: reads on backlog:read; the board move on
312
- * backlog:write; queueing on both backlog:write and products:write (the
313
- * state route checks both for a drop into queue); an initiative request
314
- * (an agent job), the resume and the review gate on products:write alone, as
315
- * their routes are (the item ID they take also comes back from a request or a
316
- * queue); the plan verdicts on products:write together with backlog:read,
317
- * because the plan ID they take comes only from the plan read; and Plan again
318
- * on the state route's two (backlog:write, then products:write for a move
319
- * into plan) together with backlog:read, because it reads the initiative's
320
- * place at the Build station first.
321
- *
322
- * The tools follow how Taiga is meant to be used (docs.tai.ga, Deliver):
323
- * new work is described and an agent writes the initiative; the queue is
324
- * the only way to start work; a run is stopped by moving its initiative out
325
- * of Build and carried on from the initiative.
326
- */
327
- export function registerBacklogTools(server: McpServer, client: TaigaClient) {
328
- const may = (capability: string) => client.hasCapability(capability);
329
- if (may('backlog:read')) registerBacklogReads(server, client);
330
- if (may('backlog:write')) registerMove(server, client);
331
- if (may('products:write')) registerRequest(server, client);
332
- if (may('backlog:write') && may('products:write')) registerQueue(server, client);
333
- if (may('products:write')) registerResume(server, client);
334
- if (may('products:write')) registerReviewGate(server, client);
335
- if (may('backlog:read') && may('products:write')) registerPlanVerdicts(server, client);
336
- if (may('backlog:read') && may('backlog:write') && may('products:write')) registerPlanAgain(server, client);
337
- }
338
-
339
- function registerBacklogReads(server: McpServer, client: TaigaClient) {
340
- server.registerTool(
341
- 'taiga_list_initiatives',
342
- {
343
- title: 'List initiatives',
344
- description:
345
- "List a product's open initiatives (not done or canceled; status: 'all' for every one): title, " +
346
- 'category, status, place on the line (backlog, todo, queue, plan, build, done), situation (where it ' +
347
- 'stands and what moves it on) and criteria (acceptance criteria met, confirmed with evidence or checked ' +
348
- 'off by a person; null before planning, so a done initiative can show fewer met than it has). query ' +
349
- 'finds one by title, state one board column; taiga_get_initiative reads it in full. Archived ones are ' +
350
- 'left out unless asked for.',
351
- inputSchema: z.object({
352
- productId,
353
- query: z
354
- .string()
355
- .trim()
356
- .min(1)
357
- .max(200)
358
- .optional()
359
- .describe('Part of the title, any case: only the items that contain it'),
360
- status: z
361
- .enum(['open', 'all', 'backlog', 'in_progress', 'done', 'canceled'])
362
- .optional()
363
- .describe(
364
- 'open (default without state): not done or canceled; all: every one; in_progress covers everything between backlog and done'
365
- ),
366
- planningStatus: z
367
- .enum(['unplanned', 'planning', 'planned', 'failed'])
368
- .optional()
369
- .describe('Only items whose plan is in this state'),
370
- state: z
371
- .enum(INITIATIVE_STATES)
372
- .optional()
373
- .describe(
374
- 'Only items in this column of the board: backlog, todo, queue, plan, build, done or canceled (status groups them coarser)'
375
- ),
376
- category: z.enum(['feature', 'bug', 'tech_debt', 'improvement']).optional().describe('Only this category'),
377
- includeArchived: z.boolean().optional().describe('Include archived items (default false)'),
378
- ...pagination,
379
- }),
380
- annotations: READ_ONLY,
381
- },
382
- (params) =>
383
- runTool(async () => {
384
- const { query, state, status = state === undefined ? 'open' : 'all', ...filters } = params;
385
- // A column and a status that cannot meet answered an empty list with no complaint (PRO-2800).
386
- if (state !== undefined && !COLUMN_STATUSES[state].includes(status)) {
387
- return toolError(
388
- 'VALIDATION_ERROR',
389
- `No initiative is in the ${state} column with status ${status}: pass state alone, or a status it can have (${COLUMN_STATUSES[state].join(', ')}).`
390
- );
391
- }
392
- // Two reviews asked "what is in the backlog" and got the whole history, done and canceled first
393
- // (PRO-2800): open is the default. The API filters by one status only, so open, a board column and a
394
- // title are read across pages here.
395
- const apiFilters = { ...filters, status: status === 'open' || status === 'all' ? undefined : status };
396
- const open = status === 'open';
397
- if (query === undefined && state === undefined && !open) {
398
- const result = await client.listBacklogItems(params.productId, apiFilters);
399
- return toolResult(page(situatedRows(result.data as Record<string, unknown>[]), result.nextCursor));
400
- }
401
- if (params.cursor !== undefined) {
402
- return toolError(
403
- 'VALIDATION_ERROR',
404
- "Open items, a board column or a title search answer one page; narrow them, or pass status: 'all' " +
405
- '(or one status) to page through every item.'
406
- );
407
- }
408
- // A title search under the open default said nothing of the done and canceled ones it matched: a
409
- // reviewer searched "secret detection", got [], and concluded the work never existed (PRO-2800). Counted
410
- // in the same pass; exact when the pass read every page.
411
- let closedSeen = 0;
412
- const needle = (query ?? '').toLowerCase();
413
- const found = await searchPages(
414
- (cursor) => client.listBacklogItems(params.productId, { ...apiFilters, limit: SEARCH_PAGE_SIZE, cursor }),
415
- query ?? '',
416
- (row) => [row.title],
417
- params.limit ?? 25,
418
- (row) => {
419
- if (state !== undefined && row.state !== state) return false;
420
- if (!open || !['done', 'canceled'].includes(row.status)) return true;
421
- if (query !== undefined && String(row.title).toLowerCase().includes(needle)) closedSeen += 1;
422
- return false;
423
- }
424
- );
425
- const hidden = query !== undefined && state === undefined && !found.more ? closedSeen : 0;
426
- return toolResult({
427
- ...page(situatedRows(found.rows), null),
428
- ...(hidden > 0
429
- ? {
430
- closedMatches: `${hidden} done or canceled initiative${hidden === 1 ? ' also matches' : 's also match'}; status: 'all' lists them.`,
431
- }
432
- : {}),
433
- ...(found.more
434
- ? {
435
- more:
436
- 'There may be more than the limit carries: narrow it with state or query, or list one status ' +
437
- '(backlog, in_progress, done or canceled) without them, which pages.',
438
- }
439
- : {}),
440
- });
441
- })
442
- );
443
-
444
- server.registerTool(
445
- 'taiga_get_initiative',
446
- {
447
- title: 'Get initiative',
448
- description:
449
- 'Read one initiative in full: description, acceptance criteria (which are met), technical context, ' +
450
- 'dependencies and source documents. Read it before implementing. A failed plan carries planFailure ' +
451
- `(cause, meaning, when, by whom). ${LIFECYCLE_MEANING}`,
452
- inputSchema: z.object({
453
- initiativeId,
454
- }),
455
- annotations: READ_ONLY,
456
- },
457
- (params) =>
458
- runTool(async () => {
459
- const item = (await client.getBacklogItem(params.initiativeId)).data;
460
- const row = item as unknown as Record<string, unknown>;
461
- if (item.planningStatus !== 'failed') return toolResult(withSituation(row));
462
- // Always undefined: the API sends the cause only under `lifecycle`, so
463
- // planFailureOf's cross-check never fires (PRO-2799). Kept as it was.
464
- const ownCause = (item as { planFailureCause?: unknown }).planFailureCause;
465
- const planFailure = await planFailureOf(client, params.initiativeId, ownCause);
466
- return toolResult(withSituation(row, planFailure ? { ...item, planFailure } : item));
467
- })
468
- );
469
-
470
- server.registerTool(
471
- 'taiga_get_plan',
472
- {
473
- title: 'Get the active plan',
474
- description:
475
- "Read an initiative's active plan: its steps in order, each with title, layer, dependencies (the " +
476
- 'orderIndex of the steps it builds on), files written and read, and resources created. Enough to approve ' +
477
- "or reject it (taiga_approve_plan, taiga_reject_plan); full: true adds each step's complete instructions " +
478
- '(thousands of tokens). NOT_FOUND when there is no active plan yet; planningStatus says whether one is ' +
479
- 'being generated.',
480
- inputSchema: z.object({
481
- initiativeId,
482
- full: z.boolean().optional().describe("Carry each step's complete instructions (default false: a summary)"),
483
- }),
484
- annotations: READ_ONLY,
485
- },
486
- (params) =>
487
- runTool(async () => {
488
- const plan = (await client.getActivePlan(params.initiativeId)).data;
489
- if (params.full) return toolResult(plan);
490
- const steps = Array.isArray(plan.steps) ? plan.steps : [];
491
- return toolResult({
492
- ...pickFields([plan], PLAN_SUMMARY_FIELDS)[0],
493
- steps: pickFields(steps, PLAN_STEP_SUMMARY_FIELDS),
494
- });
495
- })
496
- );
497
- }
498
-
499
- function registerRequest(server: McpServer, client: TaigaClient) {
500
- server.registerTool(
501
- 'taiga_request_initiatives',
502
- {
503
- title: 'Request new initiatives',
504
- description:
505
- "Ask an agent to turn described work into initiatives in the product's Backlog, and return the job. One " +
506
- 'piece of work becomes one initiative, a program an ordered set with dependencies. It may add none and ' +
507
- 'say why (already covered, not work, outside the product, or a named policy forbids it). Omit title and ' +
508
- 'description for the one next initiative the product most needs. taiga_wait_for_job gives the outcome. ' +
509
- 'One request per product at a time (CONFLICT). Nothing goes on the line.',
510
- inputSchema: z.object({
511
- productId,
512
- title: z.string().max(500).optional().describe('What the work is, in a line'),
513
- description: z
514
- .string()
515
- .max(20_000)
516
- .optional()
517
- .describe('The request in Markdown: the problem, what it should look like, what it covers and leaves alone'),
518
- }),
519
- annotations: OPERATE,
520
- },
521
- (params) =>
522
- runTool(async () => {
523
- const userPrompt = requestPrompt(params.title, params.description);
524
- const job = (
525
- await client.createAgentJob({
526
- scope: 'product',
527
- type: 'generate_backlog',
528
- targetId: params.productId,
529
- ...(userPrompt ? { metadata: { userPrompt } } : {}),
530
- })
531
- ).data;
532
- return toolResult(jobReport(job));
533
- })
534
- );
535
- }
536
-
537
- function registerMove(server: McpServer, client: TaigaClient) {
538
- server.registerTool(
539
- 'taiga_move_initiative',
540
- {
541
- title: 'Move initiative on the board',
542
- description:
543
- 'Move an initiative between columns, take it off the line, or reorder it. The line never takes from ' +
544
- 'backlog or todo; queue puts it on the line (taiga_queue_initiative), the only way to start work; done ' +
545
- 'and canceled finish it by hand. Nothing moves into Build by hand; moving out of Build stops and ' +
546
- `discards the plan or run in flight. A move to ${PR_CLOSING_STATES.join(', ')} CLOSES a pull request the ` +
547
- 'initiative waits on (with a comment on it); queue keeps it open, and done leaves it to merge. Refused (CONFLICT) if not allowed; queue needs products:write. Ask first before ' +
548
- 'starting or stopping work or closing a pull request. archived: true archives it in place (pass its ' +
549
- 'current column as state; never from the Build station); false restores it.',
550
- inputSchema: z.object({
551
- initiativeId,
552
- state: z.enum(USER_DROP_STATES).describe('The column to move into'),
553
- afterId: idInput('initiative', 'taiga_list_initiatives gives it')
554
- .nullable()
555
- .optional()
556
- .describe('The initiative to follow in that column; null for the head; omit to keep rank order'),
557
- archived: z
558
- .boolean()
559
- .optional()
560
- .describe('true files the initiative where it stands, false brings it back; omit to leave as is'),
561
- }),
562
- annotations: OPERATE,
563
- },
564
- (params) =>
565
- runTool(async () => {
566
- const { initiativeId: id, ...body } = params;
567
- // What the route checks on top of backlog:write for a drop into the
568
- // queue; refused here so the model learns which capability, not just
569
- // that it was forbidden.
570
- if (body.state === 'queue' && !client.hasCapability('products:write')) {
571
- return toolError(
572
- 'FORBIDDEN',
573
- 'A move into queue starts agent work and needs products:write, which this key lacks.'
574
- );
575
- }
576
- return outcome((await client.transitionInitiative(id, body)).data);
577
- })
578
- );
579
- }
580
-
581
- /** The two halves of the Build station, where Plan again is offered. */
582
- const BUILD_STATION: ReadonlySet<string> = new Set(['plan', 'build']);
583
-
584
- function registerPlanAgain(server: McpServer, client: TaigaClient) {
585
- server.registerTool(
586
- 'taiga_plan_again',
587
- {
588
- title: 'Plan an initiative again',
589
- description:
590
- 'Replace the plan of the initiative at the Build station with a new one: for a failed plan, one you do ' +
591
- 'not want built, or a stopped run that went wrong. The old plan is kept; an open pull request stays ' +
592
- 'open. Refused (CONFLICT) while an agent holds the turn, and while a dependency is in flight or a UI ' +
593
- 'direction has no chosen concept unless force is set (the person decides). Off the Build station, use ' +
594
- 'taiga_queue_initiative; to send a plan back with direction, taiga_reject_plan.',
595
- inputSchema: z.object({
596
- initiativeId,
597
- force: z
598
- .boolean()
599
- .optional()
600
- .describe(
601
- 'Plan anyway past an unsettled dependency or an unchosen UI direction; only when the person says so'
602
- ),
603
- }),
604
- annotations: OPERATE,
605
- },
606
- (params) =>
607
- runTool(async () => {
608
- const item = (await client.getBacklogItem(params.initiativeId)).data;
609
- if (!BUILD_STATION.has(String(item.state))) {
610
- return toolError(
611
- 'CONFLICT',
612
- `This initiative is in ${String(item.state)}, not at the Build station. The queue is the only way to ` +
613
- 'start work: put it in the queue with taiga_queue_initiative and it is planned when its turn comes.'
614
- );
615
- }
616
- const body = { state: 'plan', ...(params.force ? { force: true } : {}) };
617
- return outcome((await client.transitionInitiative(params.initiativeId, body)).data);
618
- })
619
- );
620
- }
621
-
622
- /** Whether a failure is the API's (refused, missing, unreachable) rather than a bug here. */
623
- const isApiFailure = (error: unknown) => error instanceof TaigaApiError || isNetworkFailure(error);
624
-
625
- /**
626
- * Whether this initiative's plan builds the moment it lands, and which scope
627
- * decided it: its own setting, or the product's default when it has none.
628
- * Null when the product cannot be read; the outcome then says so instead.
629
- */
630
- async function reviewGate(client: TaigaClient, item: Item) {
631
- const own = typeof item.autoBuild === 'boolean' ? item.autoBuild : null;
632
- try {
633
- // The product's default answers only when the initiative has no answer of its own.
634
- const product: { autoBuild?: boolean | null } = own === null ? (await client.getProduct(item.productId)).data : {};
635
- // Absent on the product reads as its true default (@taiga/types).
636
- const productDefault = typeof product.autoBuild === 'boolean' ? product.autoBuild : true;
637
- const builds = resolveAutoBuild({ initiative: own, product: productDefault });
638
- return {
639
- buildsWithoutReview: builds,
640
- decidedBy: own === null ? 'product' : 'initiative',
641
- meaning: builds
642
- ? 'The plan is built as soon as it lands; nobody reads it first.'
643
- : 'The line stops at the plan for a person to approve or reject it (taiga_get_plan, taiga_approve_plan).',
644
- };
645
- } catch (error) {
646
- if (!isApiFailure(error)) throw error;
647
- return null;
648
- }
649
- }
650
-
651
- /** Where a queued initiative stands in its product's line; null, and the outcome says so, when the queue cannot be read. */
652
- async function placeInQueue(client: TaigaClient, item: Item) {
653
- try {
654
- const view = await queueView(client, item.productId, { name: 'blocking' });
655
- const entry = view.entries.find((row) => row.backlogItemId === item.id);
656
- return { position: entry?.position ?? null, of: view.entries.length, waitingOn: view.waitingOn };
657
- } catch (error) {
658
- if (!isApiFailure(error)) throw error;
659
- return null;
660
- }
661
- }
662
-
663
- const autoBuildInput = z
664
- .boolean()
665
- .nullable()
666
- .describe(
667
- "false stops the line at this initiative's plan for a person to read it; true builds it as soon as it lands; " +
668
- "null follows the product's default"
669
- );
670
-
671
- function registerQueue(server: McpServer, client: TaigaClient) {
672
- server.registerTool(
673
- 'taiga_queue_initiative',
674
- {
675
- title: 'Queue initiative',
676
- description:
677
- "Put an initiative on the product's line, where initiatives are planned and built one at a time, each " +
678
- 'advancing when the previous pull request merges. Starts a planner run, then a coding run that pushes ' +
679
- 'commits and opens a pull request. autoBuild: false makes the plan wait for a person before it is built ' +
680
- '(otherwise the product default; the result says which); the gate is set first and stays set if the move ' +
681
- 'is refused. An initiative already queued stays put unless afterId says where; one being planned or ' +
682
- 'built cannot move (CONFLICT). Returns the initiative with its state, review gate and place on the line.',
683
- inputSchema: z.object({
684
- initiativeId,
685
- afterId: idInput('initiative', 'taiga_list_initiatives gives it')
686
- .nullable()
687
- .optional()
688
- .describe(
689
- 'The queued initiative to follow; null for the head of the queue; omit to append a new entry or leave a queued one where it is'
690
- ),
691
- autoBuild: autoBuildInput
692
- .optional()
693
- .describe(
694
- "Set the initiative's review gate before queueing: false to read the plan first, true to build it at " +
695
- 'once, null to follow the product; omit to leave the gate as it is'
696
- ),
697
- }),
698
- annotations: OPERATE,
699
- },
700
- (params) =>
701
- runTool(async () => {
702
- const { initiativeId: id, autoBuild, ...placement } = params;
703
- // The gate first: set after the move, a plan landing in between
704
- // would be built under the old answer.
705
- if (autoBuild !== undefined) await client.setAutoBuild(id, autoBuild);
706
- // The move the web makes: one write that answers with the refreshed
707
- // item, rather than the queue route plus two reads around it.
708
- const item = (await client.transitionInitiative(id, { state: 'queue', ...placement })).data;
709
- const [gate, queue] = await Promise.all([reviewGate(client, item), placeInQueue(client, item)]);
710
- return toolResult({
711
- ...outcomeOf(item),
712
- reviewGate: gate,
713
- queue,
714
- ...(gate && queue
715
- ? {}
716
- : { note: 'Part of the outcome could not be read; taiga_get_line and taiga_get_product say the rest.' }),
717
- });
718
- })
719
- );
720
- }
721
-
722
- function registerReviewGate(server: McpServer, client: TaigaClient) {
723
- server.registerTool(
724
- 'taiga_set_auto_build',
725
- {
726
- title: "Set an initiative's review gate",
727
- description:
728
- "Set an initiative's review gate: true builds each plan as soon as it lands, false makes it wait for a " +
729
- 'person to approve or reject it, null follows the product default. Applies to every plan the initiative ' +
730
- 'gets, including one being written now; opening the gate on a plan already waiting starts its run at ' +
731
- 'once. Returns the initiative with the gate now in force.',
732
- inputSchema: z.object({ initiativeId, autoBuild: autoBuildInput }),
733
- annotations: OPERATE_SETTING,
734
- },
735
- (params) =>
736
- runTool(async () => {
737
- const item = (await client.setAutoBuild(params.initiativeId, params.autoBuild)).data;
738
- const gate = await reviewGate(client, item);
739
- return toolResult({
740
- ...outcomeOf(item),
741
- reviewGate: gate,
742
- ...(gate ? {} : { note: "The product's default could not be read; taiga_get_product says it." }),
743
- });
744
- })
745
- );
746
- }
747
-
748
- function registerResume(server: McpServer, client: TaigaClient) {
749
- server.registerTool(
750
- 'taiga_resume_initiative',
751
- {
752
- title: 'Resume initiative',
753
- description:
754
- 'Carry on an initiative the line stopped on (needsHumanCause is set): a failed or cancelled plan is ' +
755
- 'planned again, a stopped or failed run resumes from its first unfinished step (committed steps are ' +
756
- 'kept), a refused run start is cleared and retried. Refused (CONFLICT) when nothing is flagged or the ' +
757
- 'cause has no resume; a failed plan with needsHumanCause null (lifecycle.cta retry_plan, ' +
758
- 'planFailureCause says why) needs taiga_plan_again.',
759
- inputSchema: z.object({ initiativeId }),
760
- annotations: OPERATE,
761
- },
762
- (params) => runTool(async () => outcome((await client.resumeInitiative(params.initiativeId)).data))
763
- );
764
- }
765
-
766
- function registerPlanVerdicts(server: McpServer, client: TaigaClient) {
767
- server.registerTool(
768
- 'taiga_approve_plan',
769
- {
770
- title: 'Approve a plan',
771
- description:
772
- "Approve an initiative's plan and start its run, as the Start run button does. Read the plan first with " +
773
- 'taiga_get_plan and pass its ID. Refused (CONFLICT) if the plan was replaced since, while a new plan is ' +
774
- 'being generated, or while a run is already going. The run pushes commits and opens a pull request.',
775
- inputSchema: z.object({ planId }),
776
- annotations: OPERATE,
777
- },
778
- (params) => runTool(async () => outcome((await client.approvePlan(params.planId)).data))
779
- );
780
-
781
- server.registerTool(
782
- 'taiga_reject_plan',
783
- {
784
- title: 'Reject a plan',
785
- description:
786
- "Send an initiative's plan back with a comment: the planner writes a new plan from it, with the comment " +
787
- 'as reviewer feedback, and the new plan replaces this one when it lands. Read the plan first with ' +
788
- 'taiga_get_plan and pass its ID. Starts a planner run.',
789
- inputSchema: z.object({
790
- planId,
791
- comment: z
792
- .string()
793
- .trim()
794
- .min(1)
795
- .max(PLAN_REJECTION_COMMENT_MAX_CHARS)
796
- .describe('What the next plan should do differently: a paragraph of direction'),
797
- }),
798
- annotations: OPERATE,
799
- },
800
- (params) => runTool(async () => outcome((await client.rejectPlan(params.planId, { comment: params.comment })).data))
801
- );
802
- }