@taiga-ai/mcp-server 0.17.0-rc.442 → 0.18.0-dev.443

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.
@@ -66,6 +66,63 @@ const LIFECYCLE_MEANING =
66
66
  'resume_build taiga_resume_backlog_item, retry_build a fresh run (start over, in the web), review_pr and ' +
67
67
  'view_pr the pull request. needsHumanCause is set only when the line stopped on it.';
68
68
 
69
+ /** What each plan failure cause means for whoever reads it next (PLAN_FAILURE_CAUSES in @taiga/types). */
70
+ const PLAN_FAILURE_MEANING: Record<string, string> = {
71
+ agent_error: 'The planning agent failed. Planning again is the usual next step.',
72
+ worker_lost: 'Nobody was running the planning any more (the worker stopped). Planning again is the usual next step.',
73
+ cancelled: 'A person stopped the planning (by says who). Ask before planning it again.',
74
+ };
75
+
76
+ /**
77
+ * Who or what ended a failed plan, and when, from the initiative's history
78
+ * (PRO-2780): planFailureCause alone said "cancelled" with nobody behind it.
79
+ * Best effort: a history the API will not give leaves the item as it was.
80
+ */
81
+ async function planFailureOf(
82
+ client: TaigaClient,
83
+ itemId: string,
84
+ itemCause: unknown
85
+ ): Promise<Record<string, unknown> | null> {
86
+ let events: Record<string, unknown>[];
87
+ try {
88
+ events = (await client.listBacklogItemActivity(itemId)).data as Record<string, unknown>[];
89
+ } catch (error) {
90
+ if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
91
+ throw error;
92
+ }
93
+ // Only a failure of the latest attempt: one before the last plan_started
94
+ // belongs to an earlier attempt, even when it failed for the same reason.
95
+ let failed: Record<string, unknown> | undefined;
96
+ for (const event of [...events].reverse()) {
97
+ if (event.kind !== 'turn') continue;
98
+ if (event.trigger === 'plan_started') break;
99
+ if (event.trigger === 'plan_failed') {
100
+ failed = event;
101
+ break;
102
+ }
103
+ }
104
+ if (!failed) return null;
105
+ const actor = (failed.actor ?? {}) as { type?: unknown; name?: unknown };
106
+ const data = (failed.data ?? {}) as { cause?: unknown };
107
+ const cause = typeof data.cause === 'string' ? data.cause : null;
108
+ // The event is recorded best effort: one that disagrees with the item's own
109
+ // cause belongs to an earlier failure, and its who and when are not this one's.
110
+ if (typeof itemCause === 'string' && cause !== itemCause) {
111
+ return {
112
+ cause: itemCause,
113
+ meaning: PLAN_FAILURE_MEANING[itemCause] ?? 'Planning ended without a plan.',
114
+ at: null,
115
+ by: null,
116
+ };
117
+ }
118
+ return {
119
+ cause,
120
+ meaning: (cause && PLAN_FAILURE_MEANING[cause]) ?? 'Planning ended without a plan.',
121
+ at: failed.occurredAt ?? null,
122
+ by: typeof actor.name === 'string' ? actor.name : (actor.type ?? null),
123
+ };
124
+ }
125
+
69
126
  const planId = idInput('plan', "taiga_get_plan gives the initiative's active plan and its ID");
70
127
 
71
128
  const itemId = idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it');
@@ -189,6 +246,7 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
189
246
  'Get one backlog item in full: description, acceptance criteria (and which are met), technical ' +
190
247
  'context (patterns, constraints, and the assumptions to verify before starting), dependencies and source documents. Read this before ' +
191
248
  'implementing an item, and read its runs with taiga_list_builds to see what the coding agent already did. ' +
249
+ 'A failed plan carries planFailure: the cause, what it means, when, and by whom. ' +
192
250
  LIFECYCLE_MEANING,
193
251
  inputSchema: z.object({
194
252
  itemId,
@@ -197,8 +255,10 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
197
255
  },
198
256
  (params) =>
199
257
  runTool(async () => {
200
- const result = await client.getBacklogItem(params.itemId);
201
- return toolResult(result.data);
258
+ const item = (await client.getBacklogItem(params.itemId)).data as Record<string, unknown>;
259
+ if (item.planningStatus !== 'failed') return toolResult(item);
260
+ const planFailure = await planFailureOf(client, params.itemId, item.planFailureCause);
261
+ return toolResult(planFailure ? { ...item, planFailure } : item);
202
262
  })
203
263
  );
204
264
 
@@ -341,8 +401,7 @@ function registerPlanAgain(server: McpServer, client: TaigaClient) {
341
401
  'building, reviewing). Also refused while a dependency is still in flight or a UI direction has no ' +
342
402
  'chosen concept, unless force says to plan anyway, which only the person decides. Anywhere off the ' +
343
403
  'Build station, put the initiative in the queue instead (taiga_queue_backlog_item): the queue is the ' +
344
- 'only way to start work. To send a plan back with direction, taiga_reject_plan. Starts a planner run; ' +
345
- 'ask the person first.',
404
+ 'only way to start work. To send a plan back with direction, taiga_reject_plan. Starts a planner run.',
346
405
  inputSchema: z.object({
347
406
  itemId,
348
407
  force: z
@@ -486,7 +545,7 @@ function registerReviewGate(server: McpServer, client: TaigaClient) {
486
545
  "Set whether an initiative's plan is built as soon as it lands (true) or waits for a person to read it and " +
487
546
  "approve or reject it (false); null follows the product's default. Standing: it applies to every plan the " +
488
547
  'initiative gets, including one being written now. Opening the gate on a plan that is already waiting ' +
489
- 'starts its build, so ask the person first. Returns the initiative with the gate that now applies.',
548
+ 'starts its build at once. Returns the initiative with the gate that now applies.',
490
549
  inputSchema: z.object({ itemId, autoBuild: autoBuildInput }),
491
550
  annotations: OPERATE_SETTING,
492
551
  },
@@ -515,7 +574,7 @@ function registerResume(server: McpServer, client: TaigaClient) {
515
574
  'way to carry work on: a run is the record of an attempt, and decisions about the work are made on the ' +
516
575
  'initiative. Refused (CONFLICT) when nothing is flagged or the cause has no resume: a plan that failed ' +
517
576
  'with needsHumanCause null (lifecycle.cta retry_plan, planFailureCause says why) is planned again with ' +
518
- 'taiga_plan_again. Starts paid work; ask the person first.',
577
+ 'taiga_plan_again. Starts paid work.',
519
578
  inputSchema: z.object({ itemId }),
520
579
  annotations: OPERATE,
521
580
  },
@@ -532,7 +591,7 @@ function registerPlanVerdicts(server: McpServer, client: TaigaClient) {
532
591
  "Approve an initiative's plan: its build starts now, as the approver, the same start the Start run " +
533
592
  'button makes. Read the plan first with taiga_get_plan and name its ID; an approval of a plan that ' +
534
593
  'was replaced since is refused (CONFLICT), as is one while a new plan is being generated or a build is ' +
535
- 'already running. Starts a coding run that pushes commits and opens a pull request; ask the person first.',
594
+ 'already running. Starts a coding run that pushes commits and opens a pull request.',
536
595
  inputSchema: z.object({ planId }),
537
596
  annotations: OPERATE,
538
597
  },
@@ -546,7 +605,7 @@ function registerPlanVerdicts(server: McpServer, client: TaigaClient) {
546
605
  description:
547
606
  "Send an initiative's plan back with direction: a new plan is generated with the comment as reviewer " +
548
607
  'feedback and the rejected plan as its starting point, which supersedes it when the new one lands. ' +
549
- 'Read the plan first with taiga_get_plan and name its ID. Starts a planner run; ask the person first.',
608
+ 'Read the plan first with taiga_get_plan and name its ID. Starts a planner run.',
550
609
  inputSchema: z.object({
551
610
  planId,
552
611
  comment: z
@@ -9,7 +9,7 @@ import {
9
9
  TENANT_DOCUMENT_TYPES,
10
10
  } from '@taiga/types';
11
11
  import { TaigaClient } from '../client.js';
12
- import { registerAllTools } from './index.js';
12
+ import { registerAllTools, writeNotice } from './index.js';
13
13
  import { INSTRUCTIONS_HEAD, INSTRUCTIONS_HEAD_LIMIT, SERVER_INSTRUCTIONS } from './instructions.js';
14
14
  import { PRODUCT_DOCUMENT_NAMES } from './catalogue.js';
15
15
 
@@ -150,3 +150,17 @@ describe('tool descriptions against the catalogue', () => {
150
150
  }
151
151
  });
152
152
  });
153
+
154
+ // PRO-2778: the notice is what tells the model a tool writes, so a tool must say whether it does.
155
+ describe('writeNotice', () => {
156
+ it('refuses a tool whose annotations do not say whether it is read-only', () => {
157
+ expect(() => writeNotice('taiga_x', undefined)).toThrow(/taiga_x: annotations must say/);
158
+ expect(() => writeNotice('taiga_x', {})).toThrow(/annotations must say/);
159
+ });
160
+
161
+ it('says nothing for a read, and what a write does otherwise', () => {
162
+ expect(writeNotice('r', { readOnlyHint: true })).toBe('');
163
+ expect(writeNotice('w', { readOnlyHint: false })).toBe('Writes. ');
164
+ expect(writeNotice('o', { readOnlyHint: false, destructiveHint: true })).toMatch(/^Acts on paid or shared work/);
165
+ });
166
+ });
@@ -14,7 +14,7 @@ import {
14
14
  import { apiFailureText, isNetworkFailure, networkFailureDetail, runTool, toolError, toolResult } from './errors.js';
15
15
  import { productId, remoteUrl, slug } from './inputs.js';
16
16
  import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
17
- import { budgetSections, instructionsText, isRecord, sectionsOf, stripProvenance } from './sections.js';
17
+ import { budgetSections, instructionsText, isRecord, sectionContent, sectionsOf, stripProvenance } from './sections.js';
18
18
  import { itemRows, pickFields } from './summarize.js';
19
19
 
20
20
  /**
@@ -87,8 +87,14 @@ function withoutCitations(doc: Record<string, unknown>): { doc: Record<string, u
87
87
  return { doc: { ...doc, data: { ...(doc.data as Record<string, unknown>), sections: kept } }, dropped };
88
88
  }
89
89
 
90
- /** How much of the two documents the bundle carries. */
91
- export type DocumentDepth = 'full' | 'budget' | 'sectionIds';
90
+ /**
91
+ * How much of the two documents the bundle carries. The outline by default
92
+ * (PRO-2778): the whole sections that fit a budget made the opening read run
93
+ * to 43k characters for an active product, most of it prose a "where does it
94
+ * stand" question never reads.
95
+ */
96
+ export const DOCUMENT_DEPTHS = ['outline', 'budget', 'full'] as const;
97
+ export type DocumentDepth = (typeof DOCUMENT_DEPTHS)[number];
92
98
 
93
99
  /** Pages of 100 the backlog part follows before it stops and says so. */
94
100
  const BACKLOG_PAGE_LIMIT = 10;
@@ -156,7 +162,7 @@ async function factoryPart(client: TaigaClient, factoryId: string) {
156
162
 
157
163
  /**
158
164
  * The specification and architecture from the product's own timeline: their
159
- * whole sections up to a budget by default, in full or as section ids when
165
+ * outline by default, the whole sections up to a budget or in full when
160
166
  * asked. Null, with a note, where unpublished.
161
167
  */
162
168
  async function documentsPart(client: TaigaClient, product: Record<string, unknown>, depth: DocumentDepth) {
@@ -195,8 +201,15 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
195
201
  } else out[type] = cut.doc;
196
202
  } else {
197
203
  const sections = sectionsOf((doc.data as Record<string, unknown>) ?? {});
198
- if (sections) out[type] = { version: doc.version, sections: Object.keys(sections) };
199
- else {
204
+ if (sections) {
205
+ // Each id with the length of its text, so the next read is sized before it is
206
+ // made (a reality section's citations come on top).
207
+ const outline = Object.keys(sections).map((id) => ({
208
+ id,
209
+ characters: sectionContent(sections, id)?.length ?? 0,
210
+ }));
211
+ out[type] = { version: doc.version, sections: outline };
212
+ } else {
200
213
  out[type] = { version: doc.version, sections: null };
201
214
  notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
202
215
  }
@@ -340,8 +353,8 @@ export async function productContext(
340
353
  notGranted: notGranted.map((part) => `${part} (needs ${PARTS[part]})`),
341
354
  unavailable,
342
355
  readNext:
343
- 'Read a policy with taiga_get_document (sections: the ids listed here, for only those), another product ' +
344
- 'document with taiga_get_product_document, ' +
356
+ 'Read a policy with taiga_get_document (sections: the ids listed here, for only those), the ' +
357
+ "specification's or architecture's sections or another product document with taiga_get_product_document, " +
345
358
  'a backlog item with taiga_get_backlog_item (taiga_list_backlog_items pages a truncated backlog) and a run ' +
346
359
  'with taiga_get_build.',
347
360
  };
@@ -368,20 +381,21 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
368
381
  'every published policy as its section ids, the open backlog items, the linked repositories and the ' +
369
382
  'latest runs. Name the product by ID, slug or the git remote URL of the checkout. A part the API refused ' +
370
383
  'or failed is named under unavailable with what it said; a part the key cannot read is named under ' +
371
- 'notGranted; neither is shown as empty. The two documents bring as many whole sections as fit, about ' +
372
- `${DOCUMENT_BUDGET / 1000}k characters each, and name the sections left out (omittedSections) for ` +
373
- 'taiga_get_product_document to read; fullDocuments: true carries them whole, tens of thousands of ' +
374
- 'tokens on a large product and more than some clients accept, and false carries their section ids only. ' +
375
- 'characters says how large the result is. Call this first, then read what you need.',
384
+ 'notGranted; neither is shown as empty. The two documents come as their outline by default: each ' +
385
+ 'section id with its size, to read with taiga_get_product_document naming the sections. Before writing ' +
386
+ `code, documents: budget brings as many whole sections as fit, about ${DOCUMENT_BUDGET / 1000}k ` +
387
+ 'characters each, naming the ones left out; full brings them whole, tens of thousands of tokens on a ' +
388
+ 'large product and more than some clients accept. characters says how large the result is. Call this ' +
389
+ 'first, then read what you need.',
376
390
  inputSchema: z.object({
377
391
  productId: productId.optional(),
378
392
  slug: slug.optional(),
379
393
  remoteUrl: remoteUrl.optional(),
380
- fullDocuments: z
381
- .boolean()
394
+ documents: z
395
+ .enum(DOCUMENT_DEPTHS)
382
396
  .optional()
383
397
  .describe(
384
- 'true carries the specification and architecture whole, false as section ids only; omit for the whole sections that fit a budget'
398
+ 'The specification and architecture as outline (section ids and sizes, the default), budget (the whole sections that fit) or full'
385
399
  ),
386
400
  }),
387
401
  annotations: READ_ONLY,
@@ -390,8 +404,7 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
390
404
  runTool(async () => {
391
405
  const resolved = await resolveProductRef(client, params, ['productId', 'slug', 'remoteUrl']);
392
406
  if ('refusal' in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
393
- const documents = params.fullDocuments === undefined ? 'budget' : params.fullDocuments ? 'full' : 'sectionIds';
394
- return productContext(client, resolved.product, { documents });
407
+ return productContext(client, resolved.product, { documents: params.documents ?? 'outline' });
395
408
  })
396
409
  );
397
410
  }
@@ -11,7 +11,14 @@ import {
11
11
  named,
12
12
  } from './catalogue.js';
13
13
  import { runTool, toolError, toolResult } from './errors.js';
14
- import { applySectionEdit, sectionContent, sectionReadResult, sectionsOf, stripProvenance } from './sections.js';
14
+ import {
15
+ applySectionEdit,
16
+ sectionContent,
17
+ sectionReadResult,
18
+ sectionsInput,
19
+ sectionsOf,
20
+ stripProvenance,
21
+ } from './sections.js';
15
22
  import { page, pickFields } from './summarize.js';
16
23
 
17
24
  /**
@@ -170,17 +177,13 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
170
177
  'Use this when you need the security requirements, data handling rules, architecture standards ' +
171
178
  'or compliance controls that apply to your code, or the goals, standards and voice the ' +
172
179
  'organization has set down. A policy runs to thousands of tokens: name the sections you need (the ' +
173
- 'ids taiga_get_product_context lists for every policy) and the read carries only those, listing the ' +
174
- 'rest with their sizes and any id the document lacks (unknownSections). A read that finds nothing ' +
175
- 'says which documents are published and whether a draft exists.',
180
+ 'ids taiga_get_product_context lists for every policy, or sections: [] for the outline) and the read ' +
181
+ 'carries only those, listing the rest with their sizes and any id the document lacks ' +
182
+ '(unknownSections). A read that finds no document says which documents are published and whether a ' +
183
+ 'draft exists.',
176
184
  inputSchema: z.object({
177
185
  type: tenantDocumentType,
178
- sections: z
179
- .array(z.string().min(1).max(200))
180
- .min(1)
181
- .max(50)
182
- .optional()
183
- .describe('Only these sections, by id; omit for the whole document'),
186
+ sections: sectionsInput,
184
187
  }),
185
188
  annotations: READ_ONLY,
186
189
  },
@@ -21,7 +21,7 @@ const BY_ARGUMENT: Record<string, string> = {
21
21
  factoryId: 'taiga_list_factories lists the factories the key can see.',
22
22
  findingId: "taiga_list_findings lists a product's findings.",
23
23
  violationId: "taiga_get_policy_standing lists a product's policy violations.",
24
- environmentId: "taiga_get_deployments lists the product's environments with their IDs; a product ID is not one.",
24
+ environmentId: "taiga_list_environments lists the product's environments with their IDs; a product ID is not one.",
25
25
  knowledgeId: 'taiga_list_knowledge lists the knowledge files.',
26
26
  productId:
27
27
  'taiga_list_products lists the products the key can see; a name is not an ID (taiga_get_product takes a slug).',
@@ -51,6 +51,11 @@ const SETUP_TASK_FIELDS = [
51
51
  'createdAt',
52
52
  ] as const;
53
53
 
54
+ /** The API's count across every page, on the first page; nothing from an API that does not send one. */
55
+ function totalOf(result: { totalCount?: unknown }): { totalCount?: number } {
56
+ return typeof result.totalCount === 'number' ? { totalCount: result.totalCount } : {};
57
+ }
58
+
54
59
  export function registerInboxTools(server: McpServer, client: TaigaClient) {
55
60
  if (!client.hasCapability('products:read')) return;
56
61
 
@@ -66,7 +71,8 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
66
71
  'can open (each row names its product), or one product. category gate is the reviews, setup the setup items; resolved: true reads the ' +
67
72
  "archive of done and dismissed ones. The inbox is shared: it is the team's, not one person's. " +
68
73
  'An item snoozed until later is left out until then, as the page leaves it out (snoozedHidden counts ' +
69
- 'them). taiga_list_setup_tasks has the setup tasks with what exactly to set.',
74
+ 'them). The first page says how many there are on every page (totalCount, snoozed ones included). ' +
75
+ 'taiga_list_setup_tasks has the setup tasks with what exactly to set.',
70
76
  inputSchema: z.object({
71
77
  productId: productId.optional(),
72
78
  category: z.enum(['gate', 'setup']).optional().describe('gate: things to review; setup: things to set up'),
@@ -80,7 +86,11 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
80
86
  // The route coerces any value to a boolean, so "false" would read the
81
87
  // archive: send resolved only when it is asked for.
82
88
  const { resolved, ...rest } = params;
83
- const result = await client.listInbox({ ...rest, ...(resolved ? { resolved: true } : {}) });
89
+ const result = await client.listInbox({
90
+ ...rest,
91
+ ...(resolved ? { resolved: true } : {}),
92
+ ...(params.cursor === undefined ? { withTotal: true } : {}),
93
+ });
84
94
  // The API returns snoozed items, as they are not done; the page hides
85
95
  // one until its snooze ends, and so does this, so an agent does not
86
96
  // act on what the team put off.
@@ -93,6 +103,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
93
103
  const hidden = rows.length - due.length;
94
104
  return toolResult({
95
105
  ...page(pickFields(due, ITEM_FIELDS), result.nextCursor),
106
+ ...totalOf(result),
96
107
  ...(hidden > 0 ? { snoozedHidden: hidden } : {}),
97
108
  });
98
109
  })
@@ -110,7 +121,8 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
110
121
  'providers; followup: billing approvals, extra permissions and the like that can wait until after launch). ' +
111
122
  'A build does not wait for any of them. Across every product the key can open (each row names its ' +
112
123
  'product), or one. The tasks still to do, oldest first, the order they depend on each other; ' +
113
- 'includeDone: true adds the done and dismissed ones.',
124
+ 'includeDone: true adds the done and dismissed ones. The first page says how many there are on every ' +
125
+ 'page (totalCount).',
114
126
  inputSchema: z.object({
115
127
  productId: productId.optional(),
116
128
  includeDone: z.boolean().optional().describe('Also the tasks already done or dismissed (default false)'),
@@ -121,8 +133,15 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
121
133
  (params) =>
122
134
  runTool(async () => {
123
135
  const { includeDone, ...rest } = params;
124
- const result = await client.listSetupTasks({ ...rest, ...(includeDone ? {} : { openOnly: true }) });
125
- return toolResult(page(pickFields(result.data as Row[], SETUP_TASK_FIELDS), result.nextCursor));
136
+ const result = await client.listSetupTasks({
137
+ ...rest,
138
+ ...(includeDone ? {} : { openOnly: true }),
139
+ ...(params.cursor === undefined ? { withTotal: true } : {}),
140
+ });
141
+ return toolResult({
142
+ ...page(pickFields(result.data as Row[], SETUP_TASK_FIELDS), result.nextCursor),
143
+ ...totalOf(result),
144
+ });
126
145
  })
127
146
  );
128
147
 
@@ -32,7 +32,11 @@ import { DEFAULT_WAIT_OPTIONS, registerWaitTools, type WaitOptions } from './wai
32
32
  */
33
33
  function withStrictInputs(server: McpServer): McpServer {
34
34
  const register = server.registerTool.bind(server) as (...args: unknown[]) => unknown;
35
- server.registerTool = ((name: string, config: { inputSchema?: unknown }, callback: unknown) => {
35
+ server.registerTool = ((
36
+ name: string,
37
+ config: { inputSchema?: unknown; description?: string; annotations?: ToolAnnotations },
38
+ callback: unknown
39
+ ) => {
36
40
  const schema = config.inputSchema;
37
41
  if (schema !== undefined && !(schema instanceof z.ZodObject)) {
38
42
  throw new Error(`Tool ${name}: inputSchema must be a z.object() so unknown arguments can be refused`);
@@ -42,11 +46,28 @@ function withStrictInputs(server: McpServer): McpServer {
42
46
  const handler = callback as (args: unknown, ctx: unknown) => unknown;
43
47
  const withHint = (args: unknown, ctx: unknown) =>
44
48
  notFoundHints.run(notFoundHint(name, args), () => handler(args, ctx));
45
- return register(name, schema ? { ...config, inputSchema: schema.strict() } : config, withHint);
49
+ const described = { ...config, description: `${writeNotice(name, config.annotations)}${config.description ?? ''}` };
50
+ return register(name, schema ? { ...described, inputSchema: schema.strict() } : described, withHint);
46
51
  }) as unknown as McpServer['registerTool'];
47
52
  return server;
48
53
  }
49
54
 
55
+ type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean };
56
+
57
+ /**
58
+ * What a tool's annotations say, in its description's first words (PRO-2778).
59
+ * Annotations are for the client: Claude Code does not show them to the
60
+ * model, so an agent read 79 tools without knowing which ones write. A tool
61
+ * with none is refused here, so no tool reaches the catalogue unclassified.
62
+ */
63
+ export function writeNotice(name: string, annotations: ToolAnnotations | undefined): string {
64
+ if (!annotations || annotations.readOnlyHint === undefined) {
65
+ throw new Error(`Tool ${name}: annotations must say whether it is read-only`);
66
+ }
67
+ if (annotations.readOnlyHint) return '';
68
+ return annotations.destructiveHint ? 'Acts on paid or shared work: ask the person first. ' : 'Writes. ';
69
+ }
70
+
50
71
  /**
51
72
  * Register all MCP tools, resources and prompts based on the API key's effective capabilities.
52
73
  * Only tools matching the key's scopes are registered -- the AI assistant
@@ -25,15 +25,16 @@ export const INSTRUCTIONS_HEAD = [
25
25
  '**Start here.** Call taiga_get_product_context first, naming the product by its git remote URL inside a',
26
26
  'checkout (git remote get-url origin), else by slug or ID (taiga_list_products finds one by name with',
27
27
  'query). It returns the product (and, while it is in setup, nextStep: what it waits for), its factory',
28
- 'instructions, its specification and architecture, every policy as its section ids, the open initiatives,',
29
- 'the repositories and the latest runs. Then read only what you need: the policy sections with',
30
- 'taiga_get_document, an initiative with taiga_get_backlog_item. Check Action Required',
31
- '(taiga_list_action_required) for what waits on a person.',
28
+ 'instructions, the outline of its specification and architecture, every policy as its section ids, the',
29
+ 'open initiatives, the repositories and the latest runs. Then read only what you need: sections by id with',
30
+ "taiga_get_document or taiga_get_product_document (sections: [] is any document's outline), an",
31
+ 'initiative with taiga_get_backlog_item. Check Action Required (taiga_list_action_required) for what',
32
+ 'waits on a person.',
32
33
  '',
33
34
  '**Before you write.** Tell the person what you are about to do and wait for their yes before a tool that',
34
35
  'starts paid agent work (queueing, approving, resuming, requesting, importing, generating), interrupts it',
35
- '(stopping, pausing) or changes what others read (publishing, resolving, settings, repositories). A client',
36
- 'that prompts on destructive tools does part of that for you.',
36
+ '(stopping, pausing) or changes what others read (publishing, resolving, settings, repositories). Such a',
37
+ 'tool says so first in its description; a client that prompts on destructive tools does part of it.',
37
38
  '',
38
39
  '**Answers.** A refusal carries the API reason: say it as it is. NOT_FOUND names the tool that lists the',
39
40
  'right IDs; an ID is a UUID, never a name. An empty result says why when it has a reason.',
@@ -183,7 +183,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
183
183
  'file is processed before agents can read it; taiga_list_knowledge says when it is ready. PDFs and ' +
184
184
  'spreadsheets are added in the web. The same content already filed at that level comes back as that ' +
185
185
  'file, with deduplicated: true: it was not created here, so do not delete it as if it were. Every agent at that level reads it from then on, and processing it ' +
186
- 'is paid work; ask the person first.',
186
+ 'is paid work.',
187
187
  inputSchema: z.object({
188
188
  ...scopeInputs,
189
189
  fileName: z
@@ -225,7 +225,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
225
225
  title: 'Retry processing a knowledge file',
226
226
  description:
227
227
  'Retry processing a knowledge file that failed (status failed): until it is processed, nothing reads it. ' +
228
- 'Processing is paid work; ask the person first.',
228
+ 'Processing is paid work.',
229
229
  inputSchema: z.object({ knowledgeId }),
230
230
  annotations: OPERATE,
231
231
  },
@@ -244,7 +244,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
244
244
  description:
245
245
  'Delete a knowledge file that is stale or wrong: stale reference material is worse than none, because ' +
246
246
  'it is confidently specific, and nothing expires on its own. Agents stop reading it at once. It cannot ' +
247
- 'be undone; ask the person first. Organization knowledge takes an owner or admin.',
247
+ 'be undone. Organization knowledge takes an owner or admin.',
248
248
  inputSchema: z.object({ knowledgeId }),
249
249
  annotations: OPERATE,
250
250
  },
@@ -171,7 +171,8 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
171
171
  "each with its violations (critical and high only, each citing the policy's sentence and the document " +
172
172
  'passage that contradicts it), what is being done about each (an initiative raised, a fix in progress, ' +
173
173
  'awaiting verification), and when the documents were derived and assessed, led by a summary of the ' +
174
- 'counts. A control with no violation ' +
174
+ 'counts. A control breached by scanner findings counts them by tier (findingsByTier): a breach of ' +
175
+ 'routine findings only, such as secrets in test fixtures, is not an urgent one. A control with no violation ' +
175
176
  'is not listed, and it is not "passing" either: there is no such state. Pass full: true for every ' +
176
177
  "violation's evidence and the resolved ones. Fix a control with taiga_fix_policy_violation.",
177
178
  inputSchema: z.object({
@@ -219,6 +220,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
219
220
  'state',
220
221
  'divergenceCount',
221
222
  'findingCount',
223
+ 'findingsByTier',
222
224
  'plannedCount',
223
225
  ]),
224
226
  violations: pickFields(violations, VIOLATION_FIELDS),
@@ -283,7 +285,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
283
285
  'dependency, every open advisory on that package at that installed version in one version bump; for ' +
284
286
  'code or infrastructure, every location the same rule flagged. Absent for a secret only in history, ' +
285
287
  'anything in a submodule, and a dependency with no fixed version (remediableBy says upstream_only); the ' +
286
- 'API refuses those. Starts paid work; ask the person first.',
288
+ 'API refuses those. Starts paid work.',
287
289
  inputSchema: z.object({
288
290
  productId,
289
291
  findingId,
@@ -334,7 +336,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
334
336
  'against it that has no initiative yet, or a single violation (violationId) when the control has more ' +
335
337
  "than one. Taiga raises one initiative and adds it to the end of the product's queue, exactly as a " +
336
338
  'finding fix does. The violation stays listed while the work is under way: only the next assessment ' +
337
- 'can record that the documents no longer show it. Starts paid work; ask the person first.',
339
+ 'can record that the documents no longer show it. Starts paid work.',
338
340
  inputSchema: z.object({
339
341
  productId,
340
342
  controlId: z.string().min(1).max(40).optional().describe("The control's identifier, e.g. SEC-3.4"),
@@ -39,9 +39,9 @@ const DEPLOYMENT_FIELDS = [
39
39
 
40
40
  const ENVIRONMENT_FIELDS = ['id', 'name', 'slug', 'type', 'ordinal', 'status', 'isProtected', 'url'] as const;
41
41
 
42
- const environmentId = idInput('environment', 'taiga_get_deployments lists them; a product ID is not one');
43
- const windowDays = (choices: string) =>
44
- z.number().int().optional().describe(`Days to look back: ${choices} (default 30)`);
42
+ const environmentId = idInput('environment', 'taiga_list_environments lists them; a product ID is not one');
43
+ /** The windows the pages offer, as the schema's own values rather than a number checked afterwards. */
44
+ const windowDays = z.literal([30, 60, 90]).optional().describe('Days to look back: 30, 60 or 90 (default 30)');
45
45
 
46
46
  /** Whether a failure is the API's (refused, missing, unreachable) rather than a bug here. */
47
47
  const isApiFailure = (error: unknown) => error instanceof TaigaApiError || isNetworkFailure(error);
@@ -129,6 +129,28 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
129
129
  })
130
130
  );
131
131
 
132
+ server.registerTool(
133
+ 'taiga_list_environments',
134
+ {
135
+ title: "List a product's environments",
136
+ description:
137
+ "The places a product's software runs (development, staging, production or its own ladder), each with " +
138
+ 'its ID, type, order and App URL. The environment IDs taiga_get_delivery_metrics, taiga_get_monitoring ' +
139
+ 'and taiga_update_environment take. taiga_get_deployments lists them too, beside what runs where.',
140
+ inputSchema: z.object({ productId }),
141
+ annotations: READ_ONLY,
142
+ },
143
+ (params) =>
144
+ runTool(async () => {
145
+ const { rows, more } = await allEnvironments(client, params.productId);
146
+ return toolResult({
147
+ data: pickFields(rows, ENVIRONMENT_FIELDS),
148
+ ...(more ? { more: 'More environments than this answer reads; the rest were left out.' } : {}),
149
+ ...(rows.length === 0 ? { note: 'No environment is defined yet; taiga_define_environment adds one.' } : {}),
150
+ });
151
+ })
152
+ );
153
+
132
154
  server.registerTool(
133
155
  'taiga_get_delivery_metrics',
134
156
  {
@@ -141,15 +163,12 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
141
163
  'rework rate, which DORA has published none for. Failures and recoveries are read from deployments, not ' +
142
164
  'incidents. A trend built on fewer than ten deployments is approximate; a metric no deployment fed says ' +
143
165
  'so instead of showing a number. Previews and checks do not count.',
144
- inputSchema: z.object({ productId, environmentId, windowDays: windowDays('30, 60 or 90') }),
166
+ inputSchema: z.object({ productId, environmentId, windowDays }),
145
167
  annotations: READ_ONLY,
146
168
  },
147
169
  (params) =>
148
170
  runTool(async () => {
149
171
  const days = params.windowDays ?? 30;
150
- if (![30, 60, 90].includes(days)) {
151
- return toolError('VALIDATION_ERROR', 'windowDays is 30, 60 or 90.');
152
- }
153
172
  return toolResult((await client.getDeliveryMetrics(params.productId, params.environmentId, days)).data);
154
173
  })
155
174
  );
@@ -169,7 +188,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
169
188
  inputSchema: z.object({
170
189
  productId,
171
190
  environmentId: environmentId.optional(),
172
- windowDays: windowDays('30, 60 or 90'),
191
+ windowDays,
173
192
  }),
174
193
  annotations: READ_ONLY,
175
194
  },
@@ -189,9 +208,6 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
189
208
  });
190
209
  }
191
210
  const days = params.windowDays ?? 30;
192
- if (![30, 60, 90].includes(days)) {
193
- return toolError('VALIDATION_ERROR', 'windowDays is 30, 60 or 90, the windows the page offers.');
194
- }
195
211
  const env = params.environmentId;
196
212
  const [uptime, experience, clientHealth] = await Promise.all([
197
213
  part(() => client.getMonitoringSignal(params.productId, 'uptime', env, days)),
@@ -240,7 +256,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
240
256
  'read where the work will run. Describing one gives Taiga no access to it: the pipeline deploys, Taiga ' +
241
257
  'learns what happened from source control. Member-level work. Give it its App URL afterwards with ' +
242
258
  'taiga_update_environment, which is all monitoring needs to start checking. Changes the ' +
243
- "product's setup for everybody; ask the person first.",
259
+ "product's setup for everybody.",
244
260
  inputSchema: z.object({
245
261
  productId,
246
262
  name: z.string().trim().min(1).max(100).describe('What the team calls it, e.g. staging'),
@@ -264,7 +280,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
264
280
  'Rename an environment, change its kind, or set its public App URL (null clears it). The App URL is all ' +
265
281
  "monitoring needs: once it is saved, Taiga checks the app's availability every few minutes on its own. " +
266
282
  'Marking an environment production makes it the one delivery metrics report on. Changes the ' +
267
- "product's setup for everybody; ask the person first.",
283
+ "product's setup for everybody.",
268
284
  inputSchema: z.object({
269
285
  environmentId,
270
286
  name: z.string().trim().min(1).max(100).optional().describe('A new name'),