@taiga-ai/mcp-server 0.18.0-rc.445 → 0.19.0-dev.451

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.
@@ -1,21 +1,36 @@
1
1
  import type { McpServer, CallToolResult } from '@modelcontextprotocol/server';
2
2
  import { isSettledOrArchived } from '@taiga/types';
3
+ import type { BacklogItem, Product } from '@taiga/types';
3
4
  import { z } from 'zod';
4
- import { TaigaApiError, type TaigaClient } from '../client.js';
5
+ import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
5
6
  import { READ_ONLY } from './annotations.js';
6
- import { BACKLOG_SUMMARY_FIELDS } from './backlogs.js';
7
+ import { situatedRows } from './backlogs.js';
8
+ import { POLICY_TYPES, defaultOriginFor, isImportedProduct } from './catalogue.js';
7
9
  import {
8
- POLICY_TYPES,
9
- defaultOriginFor,
10
- isImportedProduct,
11
- setupNextStep,
12
- type ProductSettingsView,
13
- } from './catalogue.js';
14
- import { apiFailureText, isNetworkFailure, networkFailureDetail, runTool, toolError, toolResult } from './errors.js';
10
+ apiFailureText,
11
+ inProductWords,
12
+ isNetworkFailure,
13
+ networkFailureDetail,
14
+ runTool,
15
+ toolError,
16
+ toolResult,
17
+ } from './errors.js';
15
18
  import { productId, remoteUrl, slug } from './inputs.js';
16
19
  import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
17
- import { budgetSections, instructionsText, isRecord, sectionContent, sectionsOf, stripProvenance } from './sections.js';
18
- import { itemRows, pickFields } from './summarize.js';
20
+ import {
21
+ budgetSections,
22
+ instructionsText,
23
+ isRecord,
24
+ sectionContent,
25
+ sectionsOf,
26
+ stripProvenance,
27
+ type DocumentData,
28
+ type StoredDocument,
29
+ } from './sections.js';
30
+ import { pickFields } from './summarize.js';
31
+ import { readableRun } from './builds.js';
32
+ import { setupReadiness } from './readiness.js';
33
+ import { standingSummary } from './maintenance.js';
19
34
 
20
35
  /**
21
36
  * One call at the start of a coding session: everything the model would
@@ -39,10 +54,10 @@ const REPOSITORY_SUMMARY_FIELDS = [
39
54
  'isManaged',
40
55
  ] as const;
41
56
 
42
- /** How many of the latest runs to carry; taiga_list_builds pages the rest. */
57
+ /** How many of the latest runs to carry; taiga_list_runs pages the rest. */
43
58
  const BUILDS_IN_CONTEXT = 10;
44
59
 
45
- /** A run as the bundle carries it: enough to say what was built and where; taiga_get_build has the rest. */
60
+ /** A run as the bundle carries it: enough to say what was built and where; taiga_get_run has the rest. */
46
61
  const CONTEXT_BUILD_FIELDS = [
47
62
  'id',
48
63
  'runNumber',
@@ -52,6 +67,8 @@ const CONTEXT_BUILD_FIELDS = [
52
67
  'failedReason',
53
68
  'completedSteps',
54
69
  'totalSteps',
70
+ 'baseBranch',
71
+ 'prNumber',
55
72
  'prUrl',
56
73
  'prState',
57
74
  'prMerged',
@@ -72,7 +89,7 @@ export const DOCUMENT_BUDGET = 12_000;
72
89
  * which a session's opening read does not need. The full read keeps them.
73
90
  * Says whether any were dropped, so the bundle can tell the reader.
74
91
  */
75
- function withoutCitations(doc: Record<string, unknown>): { doc: Record<string, unknown>; dropped: boolean } {
92
+ function withoutCitations(doc: StoredDocument): { doc: StoredDocument; dropped: boolean } {
76
93
  const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
77
94
  if (!sections) return { doc, dropped: false };
78
95
  let dropped = false;
@@ -84,7 +101,7 @@ function withoutCitations(doc: Record<string, unknown>): { doc: Record<string, u
84
101
  return [id, rest];
85
102
  })
86
103
  );
87
- return { doc: { ...doc, data: { ...(doc.data as Record<string, unknown>), sections: kept } }, dropped };
104
+ return { doc: { ...doc, data: { ...(doc.data as DocumentData), sections: kept } }, dropped };
88
105
  }
89
106
 
90
107
  /**
@@ -101,7 +118,7 @@ const BACKLOG_PAGE_LIMIT = 10;
101
118
 
102
119
  /**
103
120
  * Open items the bundle carries, the newest of those read; past this it says truncated and
104
- * taiga_list_backlog_items pages the rest. Keeps a product with hundreds of
121
+ * taiga_list_initiatives pages the rest. Keeps a product with hundreds of
105
122
  * open initiatives inside the tool result the document budget is sized for.
106
123
  */
107
124
  const BACKLOG_IN_CONTEXT = 50;
@@ -113,6 +130,9 @@ const PARTS = {
113
130
  backlog: 'backlog:read',
114
131
  repositories: 'products:read',
115
132
  builds: 'products:read',
133
+ attention: 'products:read',
134
+ maintenance: 'products:read',
135
+ standing: 'products:read',
116
136
  } as const;
117
137
  type Part = keyof typeof PARTS;
118
138
 
@@ -133,7 +153,7 @@ function isNotFound(reason: unknown): boolean {
133
153
 
134
154
  /** The factory's standing guidance in full; null, with a note, when none is published or it is not section-shaped. */
135
155
  async function factoryPart(client: TaigaClient, factoryId: string) {
136
- const factory = (await client.getFactory(factoryId)).data as Record<string, unknown>;
156
+ const factory = (await client.getFactory(factoryId)).data;
137
157
  const base = { id: factory.id, name: factory.name, slug: factory.slug };
138
158
  try {
139
159
  const doc = (await client.getFactoryDocument(factoryId, 'factory_instructions')).data;
@@ -165,9 +185,9 @@ async function factoryPart(client: TaigaClient, factoryId: string) {
165
185
  * outline by default, the whole sections up to a budget or in full when
166
186
  * asked. Null, with a note, where unpublished.
167
187
  */
168
- async function documentsPart(client: TaigaClient, product: Record<string, unknown>, depth: DocumentDepth) {
169
- const productId = String(product.id);
170
- const origin = defaultOriginFor(product as ProductSettingsView);
188
+ async function documentsPart(client: TaigaClient, product: WithoutTenant<Product>, depth: DocumentDepth) {
189
+ const productId = product.id;
190
+ const origin = defaultOriginFor(product);
171
191
  const reads = await Promise.allSettled(
172
192
  CONTEXT_DOCUMENT_TYPES.map((type) => client.getProductDocument(productId, type, origin))
173
193
  );
@@ -176,7 +196,7 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
176
196
  CONTEXT_DOCUMENT_TYPES.forEach((type, index) => {
177
197
  const read = reads[index]!;
178
198
  if (read.status === 'fulfilled') {
179
- const doc = stripProvenance(read.value.data as Record<string, unknown>);
199
+ const doc = stripProvenance(read.value.data);
180
200
  if (depth === 'full') out[type] = doc;
181
201
  else if (depth === 'budget') {
182
202
  const lean = withoutCitations(doc);
@@ -200,7 +220,7 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
200
220
  );
201
221
  } else out[type] = cut.doc;
202
222
  } else {
203
- const sections = sectionsOf((doc.data as Record<string, unknown>) ?? {});
223
+ const sections = sectionsOf(doc.data ?? {});
204
224
  if (sections) {
205
225
  // Each id with the length of its text, so the next read is sized before it is
206
226
  // made (a reality section's citations come on top).
@@ -224,14 +244,14 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
224
244
 
225
245
  /** Every published policy as its section ids, so the model knows which one to read with taiga_get_document. */
226
246
  async function policiesPart(client: TaigaClient) {
227
- const rows = (await client.listTenantDocuments({ limit: 100 })).data as Record<string, unknown>[];
247
+ const rows = (await client.listTenantDocuments({ limit: 100 })).data;
228
248
  return rows
229
249
  .filter((row) => (POLICY_TYPES as readonly string[]).includes(String(row.type)) && row.isDraft === false)
230
250
  .map((row) => ({
231
251
  type: row.type,
232
252
  name: row.name,
233
253
  version: row.version,
234
- sections: Object.keys(sectionsOf((row.data as Record<string, unknown>) ?? {}) ?? {}),
254
+ sections: Object.keys(sectionsOf(row.data ?? {}) ?? {}),
235
255
  }));
236
256
  }
237
257
 
@@ -249,7 +269,7 @@ const OPEN_BACKLOG_STATUSES = ['backlog', 'in_progress'] as const;
249
269
  async function backlogPart(client: TaigaClient, productId: string) {
250
270
  // Keyed by id: an item that moves from backlog to in_progress between the
251
271
  // two reads would otherwise appear twice.
252
- const open = new Map<string, Record<string, unknown>>();
272
+ const open = new Map<string, WithoutTenant<BacklogItem>>();
253
273
  let truncated = false;
254
274
  let pageCapped = false;
255
275
  for (const status of OPEN_BACKLOG_STATUSES) {
@@ -261,10 +281,8 @@ async function backlogPart(client: TaigaClient, productId: string) {
261
281
  break;
262
282
  }
263
283
  const result = await client.listBacklogItems(productId, { status, limit: 100, cursor });
264
- for (const row of result.data as Record<string, unknown>[]) {
265
- if (
266
- !isSettledOrArchived({ status: String(row.status), archivedAt: (row.archivedAt as string | null) ?? null })
267
- ) {
284
+ for (const row of result.data) {
285
+ if (!isSettledOrArchived({ status: String(row.status), archivedAt: row.archivedAt ?? null })) {
268
286
  open.set(String(row.id), row);
269
287
  }
270
288
  }
@@ -279,7 +297,7 @@ async function backlogPart(client: TaigaClient, productId: string) {
279
297
  );
280
298
  if (rows.length > BACKLOG_IN_CONTEXT) truncated = true;
281
299
  return {
282
- items: itemRows(rows.slice(-BACKLOG_IN_CONTEXT), BACKLOG_SUMMARY_FIELDS),
300
+ items: situatedRows(rows.slice(-BACKLOG_IN_CONTEXT)),
283
301
  truncated,
284
302
  // The list pages oldest first and takes no order, so a status past the
285
303
  // page cap was read only up to its oldest items: these are the newest of
@@ -288,7 +306,7 @@ async function backlogPart(client: TaigaClient, productId: string) {
288
306
  ? {
289
307
  note:
290
308
  `More than ${BACKLOG_PAGE_LIMIT * 100} open items in one status: only the oldest of them were read, so ` +
291
- 'the newest may be missing here; taiga_list_backlog_items pages the rest.',
309
+ 'the newest may be missing here; taiga_list_initiatives pages the rest.',
292
310
  }
293
311
  : {}),
294
312
  };
@@ -301,22 +319,67 @@ async function backlogPart(client: TaigaClient, productId: string) {
301
319
  async function repositoriesPart(client: TaigaClient, productId: string) {
302
320
  const result = await client.listProductRepositories(productId);
303
321
  return {
304
- items: pickFields(result.data as Record<string, unknown>[], REPOSITORY_SUMMARY_FIELDS),
322
+ items: pickFields(result.data, REPOSITORY_SUMMARY_FIELDS),
305
323
  truncated: result.nextCursor !== null,
306
324
  };
307
325
  }
308
326
 
309
327
  async function buildsPart(client: TaigaClient, productId: string) {
310
328
  const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
311
- return pickFields(rows, CONTEXT_BUILD_FIELDS);
329
+ return pickFields(rows, CONTEXT_BUILD_FIELDS).map(readableRun);
330
+ }
331
+
332
+ /**
333
+ * What waits on a person for this product, counted by kind (PRO-2800): the
334
+ * reviewing agent called four tools to answer "how does it stand"; the
335
+ * counts say whether taiga_list_action_required is worth a call.
336
+ */
337
+ async function attentionPart(client: TaigaClient, productId: string) {
338
+ const result = await client.listInbox({ productId, limit: 100, withTotal: true });
339
+ const now = Date.now();
340
+ const rows = result.data as Record<string, unknown>[];
341
+ const byKind: Record<string, number> = {};
342
+ for (const row of rows) byKind[String(row['kind'])] = (byKind[String(row['kind'])] ?? 0) + 1;
343
+ // Snoozed items are open too, so they count; the number says how many of
344
+ // them the team has put off for now.
345
+ const snoozed = rows.filter(
346
+ (row) =>
347
+ row['status'] === 'snoozed' && typeof row['snoozedUntil'] === 'string' && Date.parse(row['snoozedUntil']) > now
348
+ ).length;
349
+ return {
350
+ open: typeof result.totalCount === 'number' ? result.totalCount : rows.length,
351
+ byKind,
352
+ ...(snoozed > 0 ? { snoozed } : {}),
353
+ ...(result.nextCursor
354
+ ? { note: 'byKind and snoozed count the first 100; taiga_list_action_required pages the rest.' }
355
+ : {}),
356
+ };
357
+ }
358
+
359
+ /** The latest sweep and what it left open, as counts; taiga_list_findings lists them. */
360
+ async function maintenancePart(client: TaigaClient, productId: string) {
361
+ const summary = (await client.getMaintenanceSummary(productId)).data as Record<string, unknown>;
362
+ const last = summary['lastRun'] as Record<string, unknown> | null | undefined;
363
+ return {
364
+ openFindings: summary['openFindings'],
365
+ bySeverity: summary['severityCounts'],
366
+ lastSweep: last ? { status: last['status'], completedAt: last['completedAt'] } : null,
367
+ ...(last ? {} : { note: 'Never swept: no findings is not an all-clear.' }),
368
+ };
369
+ }
370
+
371
+ /** Where the product stands against the policies, as counts; taiga_get_policy_standing has each violation. */
372
+ async function standingPart(client: TaigaClient, productId: string) {
373
+ const summary = standingSummary((await client.getPolicyStanding(productId)).data);
374
+ return { openViolations: summary.violations, bySeverity: summary.bySeverity, controlsViolated: summary.controls };
312
375
  }
313
376
 
314
377
  export async function productContext(
315
378
  client: TaigaClient,
316
- product: Record<string, unknown>,
379
+ product: WithoutTenant<Product>,
317
380
  options: { documents: DocumentDepth }
318
381
  ): Promise<CallToolResult> {
319
- const productId = String(product.id);
382
+ const productId = product.id;
320
383
  const granted = (part: Part) => client.hasCapability(PARTS[part]);
321
384
  const notGranted = (Object.keys(PARTS) as Part[]).filter((part) => !granted(part));
322
385
 
@@ -327,18 +390,23 @@ export async function productContext(
327
390
  backlog: () => backlogPart(client, productId),
328
391
  repositories: () => repositoriesPart(client, productId),
329
392
  builds: () => buildsPart(client, productId),
393
+ attention: () => attentionPart(client, productId),
394
+ maintenance: () => maintenancePart(client, productId),
395
+ standing: () => standingPart(client, productId),
330
396
  };
331
397
  const parts = (Object.keys(fetchers) as Part[]).filter(granted);
332
- const settled = await Promise.allSettled(parts.map((part) => fetchers[part]()));
398
+ const [settled, readiness] = await Promise.all([
399
+ Promise.allSettled(parts.map((part) => fetchers[part]())),
400
+ setupReadiness(client, product),
401
+ ]);
333
402
 
334
- const nextStep = setupNextStep(product);
335
403
  const bundle: Record<string, unknown> = {
336
404
  product: {
337
405
  ...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
338
- projectType: isImportedProduct(product as ProductSettingsView) ? 'brownfield' : 'greenfield',
339
- defaultOrigin: defaultOriginFor(product as ProductSettingsView),
340
- // A product in setup says what it waits for: nothing else in the bundle does.
341
- ...(nextStep ? { nextStep } : {}),
406
+ projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
407
+ defaultOrigin: defaultOriginFor(product),
408
+ // A product in setup says what it still needs: nothing else in the bundle does.
409
+ ...(readiness ? { readiness } : {}),
342
410
  },
343
411
  };
344
412
  const unavailable: Record<string, string> = {};
@@ -355,15 +423,16 @@ export async function productContext(
355
423
  readNext:
356
424
  'Read a policy with taiga_get_document (sections: the ids listed here, for only those), the ' +
357
425
  "specification's or architecture's sections or another product document with taiga_get_product_document, " +
358
- 'a backlog item with taiga_get_backlog_item (taiga_list_backlog_items pages a truncated backlog) and a run ' +
359
- 'with taiga_get_build.',
426
+ 'an initiative with taiga_get_initiative (taiga_list_initiatives pages a truncated backlog) and a run ' +
427
+ 'with taiga_get_run.',
360
428
  };
361
429
  // Its own size, so a model can judge what one more read would cost: counted
362
430
  // with the field in place, until the count no longer changes its own length.
363
- let characters = JSON.stringify(result, null, 2).length;
431
+ // Measured as sent: toolResult renames keys into the product's words.
432
+ let characters = JSON.stringify(inProductWords(result), null, 2).length;
364
433
  for (let measured = -1; measured !== characters;) {
365
434
  measured = characters;
366
- characters = JSON.stringify({ ...result, characters: measured }, null, 2).length;
435
+ characters = JSON.stringify(inProductWords({ ...result, characters: measured }), null, 2).length;
367
436
  }
368
437
  return toolResult({ ...result, characters });
369
438
  }
@@ -376,17 +445,18 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
376
445
  {
377
446
  title: 'Get product context',
378
447
  description:
379
- 'Everything a coding session starts from, in one call: the product and which document timeline is its ' +
380
- 'own, its factory with the instructions in full, the specification and architecture from that timeline, ' +
381
- 'every published policy as its section ids, the open backlog items, the linked repositories and the ' +
382
- 'latest runs. Name the product by ID, slug or the git remote URL of the checkout. A part the API refused ' +
383
- 'or failed is named under unavailable with what it said; a part the key cannot read is named under ' +
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.',
448
+ 'Where a product stands and everything a coding session starts from, in one call. Name the product by ' +
449
+ 'ID, slug or the git remote URL of a checkout. It carries: the product (and, while in setup, readiness: ' +
450
+ 'what it still needs and the next move); its factory with the instructions in full; the specification ' +
451
+ 'and architecture as outlines (section ids and sizes); every published policy as its section ids; the ' +
452
+ 'open initiatives (not the done or canceled ones: taiga_list_initiatives has those); the linked ' +
453
+ 'repositories; the latest runs, each with its outcome in a sentence; attention, what waits on a person ' +
454
+ 'for it, counted by kind; maintenance, open findings by severity and the last sweep; standing, open ' +
455
+ 'policy violations by severity. A part the API refused or failed is named under unavailable; a part the ' +
456
+ 'key cannot read under notGranted; neither is shown as empty. Before writing code, documents: budget ' +
457
+ `brings the whole sections that fit, about ${DOCUMENT_BUDGET / 1000}k characters each, and full brings ` +
458
+ 'them whole (tens of thousands of tokens on a large product). Unlike taiga_get_product, which is the ' +
459
+ 'product record and its settings alone. Call this first, then read what you need.',
390
460
  inputSchema: z.object({
391
461
  productId: productId.optional(),
392
462
  slug: slug.optional(),
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
+ import type { FactoryDocument, TenantDocument } from '@taiga/types';
2
3
  import { z } from 'zod';
3
- import { TaigaApiError, type TaigaClient, type TaigaDocument } from '../client.js';
4
+ import { TaigaApiError, type TaigaClient, type TaigaDocument, type WithoutTenant } from '../client.js';
4
5
  import { READ_ONLY } from './annotations.js';
5
6
  import { idInput, pagination } from './inputs.js';
6
7
  import {
@@ -27,7 +28,10 @@ import { page, pickFields } from './summarize.js';
27
28
  */
28
29
  const DOCUMENT_SUMMARY_FIELDS = ['type', 'name', 'description', 'version', 'isDraft', 'hasDraft', 'updatedAt'] as const;
29
30
 
30
- const summaries = (rows: unknown[]) => pickFields(rows as Record<string, unknown>[], DOCUMENT_SUMMARY_FIELDS);
31
+ /** A row of the company or factory document list; product documents list through their own tools. */
32
+ type ListedDocument = WithoutTenant<TenantDocument | FactoryDocument>;
33
+
34
+ const summaries = (rows: ListedDocument[]) => pickFields(rows, DOCUMENT_SUMMARY_FIELDS);
31
35
 
32
36
  /** What a lookup behind a miss said when it failed, so an unknown stays unknown. */
33
37
  function failedLookup(what: string, reason: unknown): string {
@@ -36,10 +40,10 @@ function failedLookup(what: string, reason: unknown): string {
36
40
  }
37
41
 
38
42
  /** `a (v3), b (v1)` for a not-found message. */
39
- function publishedList(rows: unknown[]): string {
43
+ function publishedList(rows: ListedDocument[]): string {
40
44
  // The list routes return the latest draft rows beside the published ones,
41
45
  // and a draft named as published sends the model into a second miss.
42
- return (rows as { type: string; version: unknown; isDraft: boolean }[])
46
+ return rows
43
47
  .filter((row) => row.isDraft === false)
44
48
  .map((row) => `${row.type} (v${String(row.version)})`)
45
49
  .join(', ');
@@ -180,7 +184,10 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
180
184
  'ids taiga_get_product_context lists for every policy, or sections: [] for the outline) and the read ' +
181
185
  'carries only those, listing the rest with their sizes and any id the document lacks ' +
182
186
  '(unknownSections). A read that finds no document says which documents are published and whether a ' +
183
- 'draft exists.',
187
+ "draft exists. Each section's source says where its text came from (template: Taiga's standard text; " +
188
+ 'overlay-<id>: a jurisdiction or industry overlay; cloud-preset:<providers>: rendered for the ' +
189
+ "organization's clouds) and priority: core marks the sections every organization keeps; neither changes " +
190
+ 'what the section requires.',
184
191
  inputSchema: z.object({
185
192
  type: tenantDocumentType,
186
193
  sections: sectionsInput,
@@ -2,9 +2,41 @@ import type { CallToolResult } from '@modelcontextprotocol/server';
2
2
  import { TaigaApiError } from '../client.js';
3
3
  import { withNotFoundHint } from './hints.js';
4
4
 
5
+ /**
6
+ * The API's names for the two IDs an agent chains most, in the product's
7
+ * words the tools take them in (PRO-2800): a row says initiativeId and runId,
8
+ * as taiga_get_initiative and taiga_get_run ask for them, rather than the
9
+ * storage names backlogItemId and buildRunId.
10
+ */
11
+ const PRODUCT_WORDS: Record<string, string> = { backlogItemId: 'initiativeId', buildRunId: 'runId' };
12
+
13
+ /**
14
+ * Maps whose keys are content, not API fields: a document's sections are keyed
15
+ * by ids a person or agent chose (a section may be called backlogItemId), and
16
+ * metadata and settings hold whatever their writer put there. Copied as they
17
+ * are, keys and all.
18
+ */
19
+ const OPAQUE_MAPS = new Set(['sections', 'metadata', 'settings']);
20
+
21
+ /** A copy of an API value with its keys in the product's words, values untouched. */
22
+ export function inProductWords(value: unknown): unknown {
23
+ if (Array.isArray(value)) return value.map(inProductWords);
24
+ if (value === null || typeof value !== 'object') return value;
25
+ const out: Record<string, unknown> = {};
26
+ for (const [key, inner] of Object.entries(value)) {
27
+ Object.defineProperty(out, PRODUCT_WORDS[key] ?? key, {
28
+ value: OPAQUE_MAPS.has(key) ? inner : inProductWords(inner),
29
+ enumerable: true,
30
+ writable: true,
31
+ configurable: true,
32
+ });
33
+ }
34
+ return out;
35
+ }
36
+
5
37
  /** A successful tool result: one JSON text block the model reads. */
6
38
  export function toolResult(value: unknown): CallToolResult {
7
- return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
39
+ return { content: [{ type: 'text', text: JSON.stringify(inProductWords(value), null, 2) }] };
8
40
  }
9
41
 
10
42
  /**
@@ -14,7 +46,13 @@ export function toolResult(value: unknown): CallToolResult {
14
46
  */
15
47
  export function toolError(error: string, message: string, details?: Record<string, unknown>): CallToolResult {
16
48
  return {
17
- content: [{ type: 'text', text: JSON.stringify(details ? { error, message, details } : { error, message }) }],
49
+ // Details in the product's words too: a conflict naming the run in its way says runId, as the inputs do.
50
+ content: [
51
+ {
52
+ type: 'text',
53
+ text: JSON.stringify(details ? { error, message, details: inProductWords(details) } : { error, message }),
54
+ },
55
+ ],
18
56
  isError: true,
19
57
  };
20
58
  }
@@ -2,14 +2,16 @@ import { AsyncLocalStorage } from 'node:async_hooks';
2
2
 
3
3
  /**
4
4
  * The next step after the API answers 404. The API's own word is bare
5
- * ("Backlog item not found", "Repository link not found"), and an agent that
5
+ * ("Initiative not found", "Repository link not found"), and an agent that
6
6
  * hears only that guesses another ID, or a name. Each ID a tool takes names
7
7
  * the tool that lists the right ones; the tool's arguments say which apply.
8
8
  */
9
+ const ITEM_ID_HINT =
10
+ "taiga_list_initiatives lists a product's initiatives with their IDs; a run or plan ID is not one.";
11
+
9
12
  const BY_ARGUMENT: Record<string, string> = {
10
- itemId: "taiga_list_backlog_items lists a product's initiatives with their IDs; a run or plan ID is not one.",
11
- backlogItemId: "taiga_list_backlog_items lists a product's initiatives with their IDs.",
12
- buildId: 'taiga_list_builds lists the runs, of one product or of every product the key can see.',
13
+ initiativeId: ITEM_ID_HINT,
14
+ runId: 'taiga_list_runs lists the runs, of one product or of every product the key can see.',
13
15
  planId: "taiga_get_plan gives the initiative's active plan and its ID.",
14
16
  repositoryId:
15
17
  "taiga_list_product_repositories lists the product's repository links; a GitHub repository id is not a link ID.",
@@ -38,12 +40,35 @@ const BY_TOOL: Record<string, Hint> = {
38
40
  // paid planning to fix a typo.
39
41
  taiga_get_plan: (said) =>
40
42
  /\bplan\b/i.test(said)
41
- ? 'taiga_get_backlog_item says whether one is being generated (planningStatus planning) or failed, and ' +
43
+ ? 'taiga_get_initiative says whether one is being generated (planningStatus planning) or failed, and ' +
42
44
  'taiga_plan_again asks for a new one.'
43
- : BY_ARGUMENT.itemId,
45
+ : ITEM_ID_HINT,
46
+ };
47
+
48
+ /**
49
+ * What the API calls the thing each ID names, as its 404 says it ("Environment
50
+ * not found"). A tool given a right product and a wrong environment hinted
51
+ * at both, and the product's hint told the agent its correct product ID was
52
+ * the mistake (PRO-2800): a 404 that names its thing gets that ID's hint only.
53
+ */
54
+ const MISSING_THING: Record<string, RegExp> = {
55
+ initiativeId: /backlog item|initiative/i,
56
+ runId: /\b(build )?run\b/i,
57
+ planId: /\bplan\b/i,
58
+ repositoryId: /repository link|\blink\b/i,
59
+ replacesRepositoryId: /repository link|\blink\b/i,
60
+ githubRepositoryId: /github repository/i,
61
+ installationId: /installation|account/i,
62
+ jobId: /\bjob\b/i,
63
+ factoryId: /factory|team/i,
64
+ findingId: /finding/i,
65
+ violationId: /violation/i,
66
+ environmentId: /environment/i,
67
+ knowledgeId: /knowledge|document/i,
68
+ productId: /\b(product|project) not found/i,
44
69
  };
45
70
 
46
- /** The hint for a 404 from one tool call: the tool's own, else one per ID it was given. */
71
+ /** The hint for a 404 from one tool call: the tool's own, else the one for the ID the API says is missing. */
47
72
  export function notFoundHint(tool: string, args: unknown): Hint | undefined {
48
73
  if (BY_TOOL[tool]) return BY_TOOL[tool];
49
74
  if (typeof args !== 'object' || args === null) return undefined;
@@ -52,8 +77,11 @@ export function notFoundHint(tool: string, args: unknown): Hint | undefined {
52
77
  // found", which already says which ID it was; the others are named first.
53
78
  given.sort((a, b) => Number(a === 'productId') - Number(b === 'productId'));
54
79
  if (given.length === 0) return undefined;
55
- const hint = given.map((key) => BY_ARGUMENT[key]).join(' ');
56
- return () => hint;
80
+ return (said) => {
81
+ const named = given.filter((key) => MISSING_THING[key]?.test(said));
82
+ // A 404 that names none of them (or a bare one) still gets them all.
83
+ return (named.length > 0 ? named : given).map((key) => BY_ARGUMENT[key]).join(' ');
84
+ };
57
85
  }
58
86
 
59
87
  /** The hint of the tool call in progress, read by runTool when the API answers 404. */