@taiga-ai/mcp-server 0.22.0-dev.457 → 0.23.0-dev.459

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.
@@ -46,17 +46,66 @@ function waitedUntil(resolvedAt: unknown, now: number): number {
46
46
  return Number.isNaN(at) ? now : at;
47
47
  }
48
48
 
49
+ /**
50
+ * The read that opens an item's subject (PRO-2800): rows carried subjectType and subjectId, and the reviewing agent
51
+ * had to infer which tool takes them.
52
+ */
53
+ function readWith(row: Row, backlogRead: boolean): string | undefined {
54
+ switch (row['subjectType']) {
55
+ case 'build_run':
56
+ return 'taiga_get_run (runId: subjectId)';
57
+ case 'backlog_item':
58
+ // Only a tool the key has: the backlog reads need backlog:read.
59
+ return backlogRead ? 'taiga_get_plan (initiativeId: subjectId)' : undefined;
60
+ case 'product_document':
61
+ return 'taiga_list_product_documents (productId)';
62
+ case 'inbox_task':
63
+ return 'taiga_list_setup_tasks (productId)';
64
+ case 'product':
65
+ return {
66
+ // Open, not one column: the item stands while one sits in backlog or todo.
67
+ review_backlog: backlogRead ? 'taiga_list_initiatives (productId)' : undefined,
68
+ review_maintenance: 'taiga_list_findings (productId)',
69
+ review_policy: 'taiga_get_policy_standing (productId)',
70
+ review_doc: 'taiga_list_product_documents (productId)',
71
+ }[String(row['kind'])];
72
+ default:
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ /** A summary past its first sentence: review_doc rows quoted two documents, 800 to 1,200 characters each. */
78
+ const SUMMARY_LIMIT = 200;
79
+
80
+ function firstSentence(text: unknown): unknown {
81
+ if (typeof text !== 'string') return text;
82
+ // The opening paragraph, as far as SUMMARY_LIMIT: a list after a colon ("could not resolve:\n1. ...") is where
83
+ // the quoted documents start, and a short summary keeps its second sentence ("N manual setup tasks flagged").
84
+ const head = text.split(/\n/)[0]!.trim();
85
+ if (head.length <= SUMMARY_LIMIT)
86
+ return head.length < text.trim().length ? `${head} (full: true has the rest)` : head;
87
+ const cut = head.slice(0, SUMMARY_LIMIT);
88
+ const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('; '));
89
+ return `${end > 40 ? cut.slice(0, end + 1) : cut}... (full: true has the rest)`;
90
+ }
91
+
49
92
  /** A row as an agent reads it: the pull request to open when there is one, and how many days it has waited. */
50
- function itemRow(row: Row, now: number, full: boolean): Row {
93
+ function itemRow(row: Row, now: number, full: boolean, backlogRead: boolean): Row {
51
94
  const picked = pickFields([row], ITEM_FIELDS)[0]!;
52
95
  const { snoozedUntil, resolvedAt, ...rest } = picked;
53
96
  const metadata = (row['metadata'] ?? {}) as Row;
54
97
  const created = typeof row['createdAt'] === 'string' ? Date.parse(row['createdAt']) : NaN;
55
98
  // A pull request review's summary is one sentence per row ("The coding agent finished its turn on X. Review PR
56
99
  // #N...") that the title and kind already say: the org-wide page was 5k tokens of it (PRO-2800).
57
- const { summary, ...lean } = rest;
100
+ // Fields at their usual value say nothing: open, not blocking (the org-wide page repeated them on every row).
101
+ const { summary, status, blocking, ...lean } = rest;
102
+ const read = readWith(row, backlogRead);
58
103
  return {
59
- ...(row['kind'] === 'review_pr' && !full ? lean : { ...lean, summary }),
104
+ ...lean,
105
+ ...(full ? { summary } : row['kind'] === 'review_pr' ? {} : { summary: firstSentence(summary) }),
106
+ ...(full || status !== 'open' ? { status } : {}),
107
+ ...(full || blocking === true ? { blocking } : {}),
108
+ ...(read ? { readWith: read } : {}),
60
109
  ...(snoozedUntil ? { snoozedUntil } : {}),
61
110
  ...(resolvedAt ? { resolvedAt } : {}),
62
111
  ...(typeof metadata['prUrl'] === 'string' ? { prUrl: metadata['prUrl'] } : {}),
@@ -144,7 +193,7 @@ async function staleReviews(
144
193
  }
145
194
 
146
195
  /**
147
- * "Review Initiatives" items whose product has no open initiative left (PRO-2800): two reviews found one
196
+ * "Review Initiatives" items whose product has no initiative left in its backlog or todo column (PRO-2800): two reviews found one
148
197
  * waiting 15 days on a product whose initiatives had all moved on, with nothing an agent could act on. Best effort,
149
198
  * a handful of products; returns how many could not be checked.
150
199
  */
@@ -166,13 +215,23 @@ async function staleBacklogReviews(client: TaigaClient, rows: Row[], out: Map<st
166
215
  await Promise.all(
167
216
  checked.map(async (productId) => {
168
217
  try {
169
- // Any open initiative keeps the item standing: status stays in_progress after a first run even when the
170
- // initiative is moved back to todo, so backlog alone would call waiting work gone.
171
- const [waiting, started] = await Promise.all([
172
- client.listBacklogItems(productId, { status: 'backlog', limit: 1 }),
173
- client.listBacklogItems(productId, { status: 'in_progress', limit: 1 }),
174
- ]);
175
- if (waiting.data.length === 0 && started.data.length === 0) empty.add(productId);
218
+ // The item asks for initiatives to be reviewed before they are planned: it stands while one sits in the
219
+ // backlog or todo column. Read by column, not status: status stays in_progress after a first run even when
220
+ // the initiative is moved back to todo, and one in Build is past review.
221
+ const pages = await Promise.all(
222
+ (['backlog', 'in_progress'] as const).map((status) =>
223
+ client.listBacklogItems(productId, { status, limit: 100 })
224
+ )
225
+ );
226
+ const waiting = pages.some((page) =>
227
+ page.data.some((item) => item.state === 'backlog' || item.state === 'todo')
228
+ );
229
+ if (waiting) return;
230
+ if (pages.some((page) => page.nextCursor)) {
231
+ unchecked += asks.filter((row) => row['productId'] === productId).length;
232
+ return;
233
+ }
234
+ empty.add(productId);
176
235
  } catch (error) {
177
236
  if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
178
237
  unchecked += asks.filter((row) => row['productId'] === productId).length;
@@ -181,12 +240,48 @@ async function staleBacklogReviews(client: TaigaClient, rows: Row[], out: Map<st
181
240
  );
182
241
  for (const row of asks) {
183
242
  if (empty.has(String(row['productId']))) {
184
- out.set(String(row['id']), 'no initiative is open now: the ones it asked about are all done or canceled');
243
+ out.set(String(row['id']), 'no initiative is in the backlog or todo now: the ones it asked about have moved on');
185
244
  }
186
245
  }
187
246
  return unchecked;
188
247
  }
189
248
 
249
+ /**
250
+ * One product's Action Required as counts, the way the list's summary counts them: open is what still stands and is
251
+ * not put off, stale and snoozed are counted apart. The product context reports these, so the two reads agree.
252
+ */
253
+ export async function attentionCounts(
254
+ client: TaigaClient,
255
+ rows: Row[]
256
+ ): Promise<{ open: number; byKind: Record<string, number>; stale?: number; snoozed?: number; staleNote?: string }> {
257
+ const now = Date.now();
258
+ const due = rows.filter((row) => isDue(row, now));
259
+ const { stale, unchecked } = await staleReviews(client, due);
260
+ const [entry] = summaryOf(due, stale);
261
+ const snoozed = rows.length - due.length;
262
+ return {
263
+ open: entry ? Number(entry['open']) : 0,
264
+ byKind: entry ? (entry['byKind'] as Record<string, number>) : {},
265
+ ...(entry && typeof entry['stale'] === 'number' ? { stale: entry['stale'] } : {}),
266
+ ...(snoozed > 0 ? { snoozed } : {}),
267
+ // Past the cap a review is counted open: say so, as the list does.
268
+ ...(unchecked > 0
269
+ ? {
270
+ staleNote: `${unchecked} review${unchecked === 1 ? ' was' : 's were'} not checked for staleness and count as open.`,
271
+ }
272
+ : {}),
273
+ };
274
+ }
275
+
276
+ /** An item the team has not put off: the page hides one until its snooze ends, and so does this. */
277
+ function isDue(row: Row, now: number): boolean {
278
+ return !(
279
+ row['status'] === 'snoozed' &&
280
+ typeof row['snoozedUntil'] === 'string' &&
281
+ Date.parse(row['snoozedUntil']) > now
282
+ );
283
+ }
284
+
190
285
  /** Pages read for the summary; past this it says it counted only those. */
191
286
  const SUMMARY_PAGE = 100;
192
287
 
@@ -270,8 +365,10 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
270
365
  'List what waits on a person (Action Required), across every product the key can open or one; the list ' +
271
366
  "is the team's. Reviews (plans, initiative sets, documents, pull requests, findings, policy violations) " +
272
367
  'and setup only a person can do (secrets, variables, accounts, permissions). The first page opens with ' +
273
- 'summary (open items by product and kind) and totalCount. Rows carry waitingDays and, for a pull ' +
274
- 'request, prUrl; a review that no longer stands is marked stale. category: gate or setup; resolved: true ' +
368
+ 'summary (open items by product and kind; stale ones apart) and totalCount. Rows carry waitingDays, ' +
369
+ 'readWith (the read that opens the subject), summary (its opening paragraph, up to 200 characters; none ' +
370
+ 'on a pull request review, whose title says it) and, for a pull request, prUrl; ' +
371
+ 'status and blocking appear when not open or when blocking; a review that no longer stands is marked stale. category: gate or setup; resolved: true ' +
275
372
  'reads the archive; snoozed items are hidden (snoozedHidden); full: true adds metadata. Unlike ' +
276
373
  'taiga_list_setup_tasks, which says exactly what to set and where.',
277
374
  inputSchema: z.object({
@@ -311,16 +408,11 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
311
408
  // one until its snooze ends, and so does this, so an agent does not
312
409
  // act on what the team put off.
313
410
  const now = Date.now();
314
- const isDue = (row: Row) =>
315
- !(
316
- row['status'] === 'snoozed' &&
317
- typeof row['snoozedUntil'] === 'string' &&
318
- Date.parse(row['snoozedUntil']) > now
319
- );
411
+ const due_ = (row: Row) => isDue(row, now);
320
412
  const rows = result.data as Row[];
321
- const due = rows.filter(isDue);
413
+ const due = rows.filter(due_);
322
414
  const hidden = rows.length - due.length;
323
- const overviewDue = overview ? (overview.data as Row[]).filter(isDue) : [];
415
+ const overviewDue = overview ? (overview.data as Row[]).filter(due_) : [];
324
416
  const checked = resolved
325
417
  ? { stale: new Map<string, string>(), unchecked: 0 }
326
418
  : await staleReviews(client, [...due, ...overviewDue]);
@@ -334,7 +426,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
334
426
  : {}),
335
427
  ...page(
336
428
  due.map((row) => {
337
- const item = itemRow(row, now, full === true);
429
+ const item = itemRow(row, now, full === true, client.hasCapability('backlog:read'));
338
430
  const why = stale.get(String(row['id']));
339
431
  return why ? { ...item, stale: why } : item;
340
432
  }),
@@ -10,30 +10,31 @@
10
10
  * (PRO-2800).
11
11
  */
12
12
  export const SERVER_INSTRUCTIONS = [
13
- 'Taiga is a policy-aware software delivery platform. Use these tools (taiga_*) whenever the person asks about a',
14
- 'Taiga product, its initiatives or runs, policies, findings, deployments, or setting a product up.',
13
+ 'Taiga is a policy-aware software delivery platform. Use these tools (taiga_*) when the person asks about a Taiga',
14
+ 'product, its initiatives or runs, policies, findings, deployments, or setting a product up.',
15
15
  '',
16
- '**Which read.** Where a product stands: taiga_get_product_context (by git remote URL, slug or ID). What waits on',
17
- 'people: taiga_list_action_required. A policy: taiga_get_document (sections: [] for the outline, or a control id',
18
- 'such as SEC-5.1). Findings: taiga_list_findings. One initiative or run: taiga_get_initiative, taiga_get_run.',
19
- "This key's role and which tools write: taiga_whoami.",
16
+ '**Which read.** Where a product stands (counts, not detail): taiga_get_product_context, by git remote URL, slug',
17
+ 'or ID. What waits on people: taiga_list_action_required. A policy: taiga_get_document (sections: [], or a',
18
+ 'control id such as SEC-5.1). Findings: taiga_list_findings. One initiative or run: taiga_get_initiative,',
19
+ 'taiga_get_run. Which tools this key may call: taiga_whoami.',
20
20
  '',
21
- '**Words.** An initiative is a unit of work; its board columns are backlog, todo, queue, plan, build, done, canceled. The',
22
- "line is the product's queue, worked one initiative at a time through plan and build. A run is one attempt by the",
23
- 'coding agent; hardening is its pass of security scans and fixes; handed back means the agent stopped and a person',
24
- 'must act. A factory owns products. People stamp checks in the web. IDs are UUIDs, never names; a NOT_FOUND names',
25
- 'the tool that lists them, and a GitHub repository id is not a repository link id.',
21
+ '**Words.** An initiative is a unit of work, on a board of columns (backlog, todo, queue, plan, build, done,',
22
+ 'canceled); the plan and build columns are stations. The line works the queue one initiative at a time. A run is',
23
+ 'one coding-agent attempt; hardening is its security pass; handed back means a person must act. A gate is a review',
24
+ 'a person makes; a sweep is a scan for findings; standing is how a product meets the policies. Discovery is the web',
25
+ 'conversation that specifies a new product. A factory owns products. People stamp checks in the web.',
26
26
  '',
27
- '**Reading.** Lists page with nextCursor. A document has two timelines: plan (what was meant) and reality',
28
- '(derived from the code). outcome, situation and nextStep are one-sentence summaries of their record.',
27
+ '**IDs** are UUIDs; the product, factory and initiative lists take query to find one by name. A NOT_FOUND names the tool that lists the right',
28
+ 'ones. Lists page with nextCursor. outcome, situation and nextStep are one-sentence summaries; setup writes',
29
+ 'return readiness with nextStep, so there is no need to read the product again. A product document has two',
30
+ 'timelines: plan (what was meant) and reality (derived from the code).',
29
31
  '',
30
- '**Writing.** A tool that writes opens with "Writes.", "Ask first (paid agent work)." or "Ask first (others see',
31
- 'it)."; before an ask-first tool, tell the person what you will do and wait for their yes. Others share this',
32
- 'organization: a taken factory name (or product name in its factory) is refused unless allowDuplicateName, and',
33
- "the one that exists may not be this person's.",
32
+ '**Writing.** A tool that writes opens with "Writes.", "Ask first (paid agent work)." or "Ask first (others',
33
+ 'see it)."; before an ask-first tool, tell the person and wait for their yes. Others share the organization: a',
34
+ "taken name is refused unless allowDuplicateName, and the one that exists may not be this person's.",
34
35
  '',
35
- '**Only in the web:** connecting GitHub, members and roles, the Discovery conversation of a product started from',
36
- 'scratch, stamping checks, starting a run over, and deleting factories and products.',
36
+ '**Only in the web:** connecting GitHub, members and roles, Discovery, stamping checks, starting a run over, and',
37
+ 'deleting factories and products.',
37
38
  ].join('\n');
38
39
 
39
40
  /** The most the instructions may take: Claude Code cuts each server's at 2,048 characters. */
@@ -326,6 +326,17 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
326
326
  'all-clear. An assessment runs when the reality documents are derived or refreshed ' +
327
327
  '(taiga_refresh_reality_documents, paid work: ask first).'
328
328
  : null;
329
+ // Each assessment refreshes a violation's evidence but rewords its title and summary only when it
330
+ // re-describes the gap, so the words can describe code the evidence says is gone (PRO-2810). Said once.
331
+ const resolvedAny = Array.isArray(standing.resolvedDivergences) && standing.resolvedDivergences.length > 0;
332
+ const wording =
333
+ violations.length > 0 || resolvedAny
334
+ ? {
335
+ wordingNote:
336
+ "A violation's evidence is from its assessedAt; its title and summary may be from when it was " +
337
+ 'first recorded. Where they disagree, the evidence is current.',
338
+ }
339
+ : {};
329
340
  if (params.violationId) {
330
341
  const one = [
331
342
  ...violations,
@@ -337,7 +348,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
337
348
  'No violation with that ID on this product; this tool without violationId lists them.'
338
349
  );
339
350
  }
340
- return toolResult(one);
351
+ return toolResult({ ...(one as Row), ...wording });
341
352
  }
342
353
  // The full form ran to 228k characters on a product with eleven violations (PRO-2800): each text is
343
354
  // cut and the resolved list bounded; violationId reads one whole.
@@ -346,6 +357,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
346
357
  return toolResult({
347
358
  summary,
348
359
  ...(note ? { note } : {}),
360
+ ...wording,
349
361
  ...standing,
350
362
  // Only the controls something is recorded against, as the summary answer lists them.
351
363
  controls: listed,
@@ -359,6 +371,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
359
371
  return toolResult({
360
372
  summary,
361
373
  ...(note ? { note } : {}),
374
+ ...wording,
362
375
  productId: standing.productId,
363
376
  hasPolicies: standing.hasPolicies,
364
377
  assessment: standing.assessment,
@@ -323,6 +323,30 @@ export async function resolveProductRef(
323
323
  return { product: products[0]! };
324
324
  }
325
325
 
326
+ /**
327
+ * NOT_FOUND for a factory ID the key cannot see; null when it exists or the read failed. A key that cannot read
328
+ * factories cannot tell an empty factory from a wrong ID, and the answer says so.
329
+ */
330
+ async function factoryMissing(client: TaigaClient, factoryId: string) {
331
+ if (!client.hasCapability('factories:read')) {
332
+ return toolResult({
333
+ data: [],
334
+ nextCursor: null,
335
+ note: 'No products here. This key cannot read factories, so whether that factoryId exists was not checked.',
336
+ });
337
+ }
338
+ try {
339
+ await client.getFactory(factoryId);
340
+ return null;
341
+ } catch (error) {
342
+ if (error instanceof TaigaApiError && error.status === 404) {
343
+ return toolError('NOT_FOUND', 'Factory not found. taiga_list_factories lists the factories the key can see.');
344
+ }
345
+ if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
346
+ throw error;
347
+ }
348
+ }
349
+
326
350
  export function registerProductTools(server: McpServer, client: TaigaClient) {
327
351
  if (client.hasCapability('factories:read')) {
328
352
  server.registerTool(
@@ -387,6 +411,11 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
387
411
  const { query: text, ...filters } = params;
388
412
  if (text === undefined) {
389
413
  const result = await client.listProducts(filters);
414
+ // A factory that does not exist is a miss, as everywhere else, not an empty factory (PRO-2800).
415
+ if (result.data.length === 0 && filters.factoryId && !filters.cursor) {
416
+ const missing = await factoryMissing(client, filters.factoryId);
417
+ if (missing) return missing;
418
+ }
390
419
  return toolResult(
391
420
  page(await withFactoryNames(client, pickFields(result.data, PRODUCT_SUMMARY_FIELDS)), result.nextCursor)
392
421
  );
@@ -404,6 +433,10 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
404
433
  (row) => [row.name, row.slug],
405
434
  params.limit ?? 25
406
435
  );
436
+ if (found.rows.length === 0 && filters.factoryId) {
437
+ const missing = await factoryMissing(client, filters.factoryId);
438
+ if (missing) return missing;
439
+ }
407
440
  return toolResult({
408
441
  ...page(await withFactoryNames(client, pickFields(found.rows, PRODUCT_SUMMARY_FIELDS)), null),
409
442
  ...(found.more ? { more: SEARCH_NOTE } : {}),
@@ -132,7 +132,8 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
132
132
  title: 'Find a repository to link',
133
133
  description:
134
134
  "List the repositories the organization's GitHub connection can reach, to link one with " +
135
- 'taiga_link_repository (it takes the id). query searches by name; account (login, owner, or ' +
135
+ "taiga_link_repository (it takes the row's id: Taiga's record of the repository, not GitHub's numeric " +
136
+ 'id, and not a link ID). query searches by name; account (login, owner, or ' +
136
137
  'installationId) reads one account, else rows come from every account in turn. linked: true means a ' +
137
138
  'product already links it (taiga_get_product with its URL says which). No connection means GitHub is not ' +
138
139
  'connected yet; someone who can install the app does that once, in the web.',
@@ -194,7 +195,8 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
194
195
  });
195
196
  const found = rows(result.data);
196
197
  return toolResult({
197
- accounts,
198
+ // The account asked for, not every connected one (PRO-2800).
199
+ accounts: params.account || params.installationId ? pickFields(chosen, INSTALLATION_FIELDS) : accounts,
198
200
  repositories: page(found.slice(0, max), result.nextCursor),
199
201
  ...(found.length > max
200
202
  ? { more: 'More matched than the limit; narrow the query or raise the limit.' }
@@ -234,7 +236,10 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
234
236
  'history is imported.',
235
237
  inputSchema: z.object({
236
238
  productId,
237
- githubRepositoryId: idInput('GitHub repository', 'taiga_list_linkable_repositories gives it'),
239
+ githubRepositoryId: idInput(
240
+ 'GitHub repository',
241
+ "taiga_list_linkable_repositories gives it (the row's id, Taiga's record; not GitHub's numeric id)"
242
+ ),
238
243
  replacesRepositoryId: repositoryId.optional().describe('The link this one replaces'),
239
244
  }),
240
245
  annotations: OPERATE,