@taiga-ai/mcp-server 0.24.0-rc.462 → 0.25.0-dev.465

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,15 +66,12 @@ const ALL_TOOL_NAMES = [
66
66
  'taiga_ignore_finding',
67
67
  'taiga_link_repository',
68
68
  'taiga_list_action_required',
69
- 'taiga_list_documents',
70
69
  'taiga_list_environments',
71
70
  'taiga_list_factories',
72
- 'taiga_list_factory_documents',
73
71
  'taiga_list_findings',
74
72
  'taiga_list_initiatives',
75
73
  'taiga_list_knowledge',
76
74
  'taiga_list_linkable_repositories',
77
- 'taiga_list_product_documents',
78
75
  'taiga_list_product_repositories',
79
76
  'taiga_list_products',
80
77
  'taiga_list_repository_branches',
@@ -326,9 +323,7 @@ describe('MCP server integration', () => {
326
323
  const names = toolNames(tools);
327
324
 
328
325
  expect(names).toContain('taiga_list_factories');
329
- expect(names).toContain('taiga_list_factory_documents');
330
326
  expect(names).toContain('taiga_get_factory_document');
331
- expect(names).not.toContain('taiga_list_documents');
332
327
  expect(names).not.toContain('taiga_update_document');
333
328
  });
334
329
 
@@ -338,7 +333,6 @@ describe('MCP server integration', () => {
338
333
  const { tools } = await client.listTools();
339
334
  const names = toolNames(tools);
340
335
 
341
- expect(names).toContain('taiga_list_documents');
342
336
  expect(names).toContain('taiga_get_document');
343
337
  expect(names).not.toContain('taiga_update_document');
344
338
  });
@@ -399,9 +393,7 @@ describe('MCP server integration', () => {
399
393
 
400
394
  // teams:read tools present
401
395
  expect(names).toContain('taiga_list_factories');
402
- expect(names).toContain('taiga_list_factory_documents');
403
396
  // tenant:documents:read tools present
404
- expect(names).toContain('taiga_list_documents');
405
397
  // write tools absent
406
398
  expect(names).not.toContain('taiga_update_document');
407
399
  });
@@ -438,7 +430,6 @@ describe('MCP server integration', () => {
438
430
  'taiga_list_products',
439
431
  'taiga_get_product',
440
432
  'taiga_get_product_context',
441
- 'taiga_list_product_documents',
442
433
  'taiga_get_product_document',
443
434
  'taiga_list_runs',
444
435
  'taiga_get_run',
@@ -467,8 +458,8 @@ describe('MCP server integration', () => {
467
458
  ],
468
459
  'vcs:manage': ['taiga_link_repository', 'taiga_set_repository_branch', 'taiga_unlink_repository'],
469
460
  'backlog:read': ['taiga_list_initiatives', 'taiga_get_initiative', 'taiga_get_plan', 'taiga_wait_for_initiative'],
470
- 'factories:read': ['taiga_list_factories', 'taiga_list_factory_documents', 'taiga_get_factory_document'],
471
- 'tenant:documents:read': ['taiga_list_documents', 'taiga_get_document'],
461
+ 'factories:read': ['taiga_list_factories', 'taiga_get_factory_document'],
462
+ 'tenant:documents:read': ['taiga_get_document'],
472
463
  'customDocuments:read': ['taiga_list_knowledge', 'taiga_get_knowledge'],
473
464
  };
474
465
  const PAIRED_CONTRACT: Record<string, string[]> = {
@@ -562,7 +553,7 @@ describe('MCP server integration', () => {
562
553
  routes: { 'GET /v1/tenants/current/documents': { data: [doc], nextCursor: null } },
563
554
  });
564
555
 
565
- const result = await client.callTool({ name: 'taiga_list_documents', arguments: {} });
556
+ const result = await client.callTool({ name: 'taiga_get_document', arguments: {} });
566
557
 
567
558
  expect(parseToolResult(result).data).toEqual([
568
559
  {
@@ -1292,7 +1283,7 @@ describe('MCP server integration', () => {
1292
1283
  });
1293
1284
 
1294
1285
  const result = await client.callTool({
1295
- name: 'taiga_list_product_documents',
1286
+ name: 'taiga_get_product_document',
1296
1287
  arguments: { productId: PRODUCT_ID },
1297
1288
  });
1298
1289
 
@@ -1426,7 +1417,7 @@ describe('MCP server integration', () => {
1426
1417
  arguments: { productId: PRODUCT_ID, type: 'architecture' },
1427
1418
  });
1428
1419
  const list = await client.callTool({
1429
- name: 'taiga_list_product_documents',
1420
+ name: 'taiga_get_product_document',
1430
1421
  arguments: { productId: PRODUCT_ID },
1431
1422
  });
1432
1423
 
@@ -1565,7 +1556,7 @@ describe('MCP server integration', () => {
1565
1556
  });
1566
1557
 
1567
1558
  const result = await client.callTool({
1568
- name: 'taiga_list_product_documents',
1559
+ name: 'taiga_get_product_document',
1569
1560
  arguments: { productId: PRODUCT_ID },
1570
1561
  });
1571
1562
 
@@ -1604,7 +1595,7 @@ describe('MCP server integration', () => {
1604
1595
  });
1605
1596
 
1606
1597
  const parsed = parseToolResult(
1607
- await client.callTool({ name: 'taiga_list_product_documents', arguments: { productId: PRODUCT_ID } })
1598
+ await client.callTool({ name: 'taiga_get_product_document', arguments: { productId: PRODUCT_ID } })
1608
1599
  );
1609
1600
 
1610
1601
  expect(parsed.plan[0].outdated).toBe(false);
@@ -1623,7 +1614,7 @@ describe('MCP server integration', () => {
1623
1614
  });
1624
1615
 
1625
1616
  const parsed = parseToolResult(
1626
- await client.callTool({ name: 'taiga_list_product_documents', arguments: { productId: PRODUCT_ID } })
1617
+ await client.callTool({ name: 'taiga_get_product_document', arguments: { productId: PRODUCT_ID } })
1627
1618
  );
1628
1619
 
1629
1620
  expect(parsed.plan[0]).not.toHaveProperty('outdated');
@@ -1642,7 +1633,7 @@ describe('MCP server integration', () => {
1642
1633
  });
1643
1634
 
1644
1635
  const list = parseToolResult(
1645
- await client.callTool({ name: 'taiga_list_product_documents', arguments: { productId: PRODUCT_ID } })
1636
+ await client.callTool({ name: 'taiga_get_product_document', arguments: { productId: PRODUCT_ID } })
1646
1637
  );
1647
1638
  const miss = parseToolResult(
1648
1639
  await client.callTool({
@@ -1941,7 +1932,7 @@ describe('MCP server integration', () => {
1941
1932
  nextCursor: null,
1942
1933
  },
1943
1934
  [`GET /v1/products/${PRODUCT_ID}/repositories`]: { data: [repo], nextCursor: null },
1944
- [`GET /v1/products/${PRODUCT_ID}/builds?limit=10`]: { data: [run], nextCursor: null },
1935
+ [`GET /v1/products/${PRODUCT_ID}/builds?limit=5`]: { data: [run], nextCursor: null },
1945
1936
  [`GET /v1/products/${PRODUCT_ID}/inbox`]: {
1946
1937
  data: [
1947
1938
  { id: 'i1', kind: 'review_pr', status: 'open' },
@@ -2003,7 +1994,7 @@ describe('MCP server integration', () => {
2003
1994
  });
2004
1995
  expect(bundle.documents.architecture).toBeNull();
2005
1996
  expect(bundle.documents.notes).toEqual([
2006
- 'No published plan architecture; taiga_list_product_documents shows what the product has.',
1997
+ 'No published plan architecture; taiga_get_product_document without type shows what the product has.',
2007
1998
  ]);
2008
1999
  expect(bundle.policies).toEqual([{ type: 'policy_security', name: 'Security Policy', version: 3, sections: 2 }]);
2009
2000
  expect(bundle.initiatives.items.map((row: { id: string }) => row.id)).toEqual([ITEM_ID]);
@@ -2026,9 +2017,9 @@ describe('MCP server integration', () => {
2026
2017
  });
2027
2018
  expect(bundle.runs).toEqual([
2028
2019
  {
2020
+ // The disposition says how it ended; status, the raw word, is taiga_get_run's (PRO-2800).
2029
2021
  id: BUILD_ID,
2030
2022
  runNumber: 1,
2031
- status: 'completed',
2032
2023
  targetTitle: 'Add login',
2033
2024
  disposition: 'unmerged',
2034
2025
  outcome: 'Settled without a merged pull request.',
@@ -2364,13 +2355,7 @@ describe('MCP server integration', () => {
2364
2355
  it.each([
2365
2356
  ['taiga_list_factories', {}, '/v1/factories', ['factories:read']],
2366
2357
  ['taiga_list_products', {}, '/v1/products', ['products:read']],
2367
- ['taiga_list_documents', {}, '/v1/tenants/current/documents', ['tenant:documents:read']],
2368
- [
2369
- 'taiga_list_factory_documents',
2370
- { factoryId: FACTORY_ID },
2371
- `/v1/factories/${FACTORY_ID}/documents`,
2372
- ['factories:read'],
2373
- ],
2358
+ ['taiga_get_document', {}, '/v1/tenants/current/documents', ['tenant:documents:read']],
2374
2359
  [
2375
2360
  'taiga_list_initiatives',
2376
2361
  { productId: PRODUCT_ID, status: 'all' },
@@ -2567,7 +2552,7 @@ describe('MCP server integration', () => {
2567
2552
  expect(String((own as Error).message)).toContain('403 FORBIDDEN: Not a member of this product');
2568
2553
  expect(named).toMatchObject({ code: -32602 });
2569
2554
  expect(String((named as Error).message)).toContain(
2570
- 'taiga_list_product_documents shows what exists (403 FORBIDDEN'
2555
+ 'taiga_get_product_document without type shows what exists (403 FORBIDDEN'
2571
2556
  );
2572
2557
  });
2573
2558
 
@@ -2640,7 +2625,7 @@ describe('MCP server integration', () => {
2640
2625
  [`GET /v1/products/${PRODUCT_ID}/backlog?status=backlog&limit=100`]: { data: [], nextCursor: null },
2641
2626
  [`GET /v1/products/${PRODUCT_ID}/backlog?status=in_progress&limit=100`]: { data: [item], nextCursor: null },
2642
2627
  [`GET /v1/products/${PRODUCT_ID}/repositories`]: { data: [], nextCursor: null },
2643
- [`GET /v1/products/${PRODUCT_ID}/builds?limit=10`]: { data: [], nextCursor: null },
2628
+ [`GET /v1/products/${PRODUCT_ID}/builds?limit=5`]: { data: [], nextCursor: null },
2644
2629
  'GET /v1/products?slug=web&limit=1': { data: [product], nextCursor: null },
2645
2630
  };
2646
2631
 
@@ -2731,7 +2716,9 @@ describe('MCP server integration', () => {
2731
2716
  .catch((e: unknown) => e);
2732
2717
 
2733
2718
  expect(failure).toMatchObject({ code: -32602 });
2734
- expect(String((failure as Error).message)).toContain('taiga_list_products shows the ones it can');
2719
+ expect(String((failure as Error).message)).toContain(
2720
+ 'taiga_list_products (query finds one by name) shows the ones it can; a slug or git remote URL names one too.'
2721
+ );
2735
2722
  });
2736
2723
 
2737
2724
  it('should refuse a session with no product named', async () => {
@@ -8427,7 +8414,7 @@ describe('what the sixth agent review found (PRO-2800)', () => {
8427
8414
  expect(plan.summary.length).toBeLessThan(240);
8428
8415
  expect(doc).not.toHaveProperty('status');
8429
8416
  expect(doc).not.toHaveProperty('blocking');
8430
- expect(doc.readWith).toBe('taiga_list_product_documents (productId)');
8417
+ expect(doc.readWith).toBe('taiga_get_product_document (productId, no type)');
8431
8418
  expect(plan.blocking).toBe(true);
8432
8419
  expect(plan.readWith).toBe('taiga_get_plan (initiativeId: subjectId)');
8433
8420
  expect(task.status).toBe('in_progress');
@@ -8606,6 +8593,9 @@ describe('what the seventh agent review found (PRO-2800)', () => {
8606
8593
  const refused = await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: 'my item' } });
8607
8594
 
8608
8595
  expect(listing).not.toMatch(/"pattern":"\^\(\[0-9a-fA-F\]\{8\}/);
8596
+ // Nor the date-time one: a pattern left is a short one that says something (a slug, a file extension).
8597
+ const patterns = [...listing.matchAll(/"pattern":"((?:[^"\\]|\\.)*)"/g)].map((match) => match[1]!);
8598
+ expect(patterns.filter((pattern) => pattern.length > 60)).toEqual([]);
8609
8599
  expect(listing).toMatch(/"format":"uuid"/);
8610
8600
  expect(JSON.stringify(refused.content)).toMatch(/An initiative ID is a UUID/);
8611
8601
  });
@@ -8813,4 +8803,100 @@ describe('what the seventh agent review found (PRO-2800)', () => {
8813
8803
  expect(critical).not.toHaveProperty('totalNote');
8814
8804
  expect(secrets).not.toHaveProperty('totalNote');
8815
8805
  });
8806
+
8807
+ it('reads a document by type and lists them without one, refusing sections with nothing to read', async () => {
8808
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
8809
+
8810
+ const company = parseToolResult(await client.callTool({ name: 'taiga_get_document', arguments: { sections: [] } }));
8811
+ const product = parseToolResult(
8812
+ await client.callTool({
8813
+ name: 'taiga_get_product_document',
8814
+ arguments: { productId: PRODUCT_ID, origin: 'plan' },
8815
+ })
8816
+ );
8817
+
8818
+ expect(company).toMatchObject({ error: 'VALIDATION_ERROR' });
8819
+ expect(product).toMatchObject({ error: 'VALIDATION_ERROR' });
8820
+ });
8821
+
8822
+ it("keeps a run's hardening note in the compact context row", async () => {
8823
+ const { client } = await bootMcpPair({
8824
+ capabilities: ['products:read'],
8825
+ routes: {
8826
+ [`GET /v1/products/${PRODUCT_ID}`]: {
8827
+ data: { id: PRODUCT_ID, name: 'Web', status: 'active', factoryId: FACTORY_ID, settings: {} },
8828
+ },
8829
+ [`/v1/products/${PRODUCT_ID}/builds`]: {
8830
+ data: [
8831
+ {
8832
+ id: BUILD_ID,
8833
+ status: 'completed',
8834
+ prMerged: true,
8835
+ prNumber: 3,
8836
+ hardeningResult: { passed: false, blockingFindings: 2 },
8837
+ },
8838
+ ],
8839
+ nextCursor: null,
8840
+ },
8841
+ },
8842
+ });
8843
+
8844
+ const bundle = parseToolResult(
8845
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
8846
+ );
8847
+
8848
+ expect(bundle.runs[0].hardeningNote).toMatch(/^Hardening ended with 2 blocking findings/);
8849
+ expect(bundle.runs[0]).not.toHaveProperty('hardeningResult');
8850
+ });
8851
+
8852
+ it('says what the API says when it names the product holding a repository', async () => {
8853
+ const { client, fetchSpy } = await bootMcpPair({
8854
+ capabilities: ALL_CAPABILITIES,
8855
+ routes: {
8856
+ [`POST /v1/products/${PRODUCT_ID}/repositories`]: refusal(
8857
+ 409,
8858
+ 'CONFLICT',
8859
+ 'taiga-sandbox/import-test-chi is already linked to Orders. A repository belongs to one product; unlink it there first.',
8860
+ { holder: { productId: PRODUCT_ID_2, name: 'Orders' } }
8861
+ ),
8862
+ },
8863
+ });
8864
+
8865
+ const result = parseToolResult(
8866
+ await client.callTool({
8867
+ name: 'taiga_link_repository',
8868
+ arguments: { productId: PRODUCT_ID, linkableRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
8869
+ })
8870
+ );
8871
+
8872
+ expect(result.message).toMatch(/already linked to Orders\./);
8873
+ expect(result.details.holder).toEqual({ productId: PRODUCT_ID_2, name: 'Orders' });
8874
+ // No second lookup when the API named it.
8875
+ expect(fetchSpy.mock.calls.some((call: unknown[]) => String(call[0]).includes('/v1/products?'))).toBe(false);
8876
+ });
8877
+
8878
+ it('passes on the product holding a linked repository when the API names it', async () => {
8879
+ const INSTALL = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f0b';
8880
+ const { client } = await bootMcpPair({
8881
+ capabilities: ALL_CAPABILITIES,
8882
+ routes: {
8883
+ 'GET /v1/github/installations': { data: [{ id: INSTALL, accountLogin: 'acme' }], nextCursor: null },
8884
+ [`/v1/github/installations/${INSTALL}/repositories`]: {
8885
+ data: [
8886
+ {
8887
+ id: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f0c',
8888
+ fullName: 'acme/web',
8889
+ linked: true,
8890
+ linkedProduct: { id: PRODUCT_ID_2, name: 'Orders' },
8891
+ },
8892
+ ],
8893
+ nextCursor: null,
8894
+ },
8895
+ },
8896
+ });
8897
+
8898
+ const result = parseToolResult(await client.callTool({ name: 'taiga_list_linkable_repositories', arguments: {} }));
8899
+
8900
+ expect(result.repositories.data[0].linkedProduct).toEqual({ id: PRODUCT_ID_2, name: 'Orders' });
8901
+ });
8816
8902
  });
@@ -102,8 +102,9 @@ describe('tool descriptions against the catalogue', () => {
102
102
 
103
103
  it('names every policy where the company documents are explained', async () => {
104
104
  const tools = await allTools();
105
- const list = tools.find((t) => t.name === 'taiga_list_documents')!;
105
+ // One tool lists and reads the company documents.
106
106
  const get = tools.find((t) => t.name === 'taiga_get_document')!;
107
+ const list = get;
107
108
  const typeEnum = (get.inputSchema.properties as Record<string, { enum?: string[] }>)['type']!.enum;
108
109
 
109
110
  for (const type of POLICY_TYPES) {
@@ -182,7 +182,7 @@ export function setupNextStep(
182
182
  );
183
183
  case 'complete':
184
184
  return (
185
- 'The import is done: review the reality documents (taiga_list_product_documents), then ' +
185
+ 'The import is done: review the reality documents (taiga_get_product_document without type), then ' +
186
186
  'taiga_finish_discovery opens the product.'
187
187
  );
188
188
  case 'failed':
@@ -56,7 +56,7 @@ const REPOSITORY_SUMMARY_FIELDS = [
56
56
  ] as const;
57
57
 
58
58
  /** How many of the latest runs to carry; taiga_list_runs pages the rest. */
59
- const BUILDS_IN_CONTEXT = 10;
59
+ const BUILDS_IN_CONTEXT = 5;
60
60
 
61
61
  /** A run as the bundle carries it: enough to say what was built and where; taiga_get_run has the rest. */
62
62
  const CONTEXT_BUILD_FIELDS = [
@@ -76,6 +76,8 @@ const CONTEXT_BUILD_FIELDS = [
76
76
  'prDraft',
77
77
  'reviewEscalation',
78
78
  'error',
79
+ // Read for hardeningNote; the compact row drops it.
80
+ 'hardeningResult',
79
81
  'createdAt',
80
82
  ] as const;
81
83
 
@@ -240,7 +242,7 @@ async function documentsPart(client: TaigaClient, product: WithoutTenant<Product
240
242
  }
241
243
  } else if (isNotFound(read.reason)) {
242
244
  out[type] = null;
243
- notes.push(`No published ${origin} ${type}; taiga_list_product_documents shows what the product has.`);
245
+ notes.push(`No published ${origin} ${type}; taiga_get_product_document without type shows what the product has.`);
244
246
  } else throw read.reason;
245
247
  });
246
248
  return { ...out, notes };
@@ -337,11 +339,29 @@ async function repositoriesPart(links: () => ReturnType<TaigaClient['listProduct
337
339
  async function buildsPart(client: TaigaClient, productId: string) {
338
340
  const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
339
341
  const states = await initiativeStates(client, rows);
342
+ // The fields the outcome is made from are read, then the row is cut to what the bundle says: each run with its
343
+ // two-sentence outcome and every PR field was half the context, repeated by taiga_list_runs (PRO-2800).
340
344
  return pickFields(rows, CONTEXT_BUILD_FIELDS)
341
345
  .map(readableRow)
342
- .map((row) => withInitiativeState(row, states));
346
+ .map((row) => withInitiativeState(row, states))
347
+ .map((row) => pickFields([row], CONTEXT_RUN_SAID)[0]!);
343
348
  }
344
349
 
350
+ /** A run in the bundle: which one, for what, how it ended, and its pull request. taiga_get_run has the rest. */
351
+ const CONTEXT_RUN_SAID = [
352
+ 'id',
353
+ 'runNumber',
354
+ 'backlogItemId',
355
+ 'targetTitle',
356
+ 'disposition',
357
+ 'outcome',
358
+ // Rare and short, and what the outcome sentence does not say.
359
+ 'failedReasonMeans',
360
+ 'hardeningNote',
361
+ 'prUrl',
362
+ 'createdAt',
363
+ ] as const;
364
+
345
365
  /**
346
366
  * What waits on a person for this product, counted as taiga_list_action_required's summary counts it (PRO-2800):
347
367
  * the reviewing agent called four tools to answer "how does it stand", and two later reviews found this count and
@@ -466,7 +486,7 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
466
486
  'or git remote URL. Returns product (in setup, readiness and next move), factory with its instructions, ' +
467
487
  'specification and architecture outlines (section ids, sizes), policies with section counts, open ' +
468
488
  "initiatives with a count per board column (byColumn; taiga_list_initiatives status: 'all' has the done " +
469
- 'and canceled), repositories, the latest ten runs, and counts for Action Required, open findings and ' +
489
+ 'and canceled), repositories, the latest five runs (taiga_list_runs has more), and counts for Action Required, open findings and ' +
470
490
  'policy violations. A part that failed is under unavailable, one the key cannot read under notGranted; ' +
471
491
  `neither shows as empty. documents: budget returns whole sections up to about ${DOCUMENT_BUDGET / 1000}k ` +
472
492
  'characters each, full returns all.',
@@ -75,7 +75,8 @@ export const sectionContentInput = z
75
75
  .describe('The full new Markdown text of the section; it replaces the old text');
76
76
  const tenantDocumentType = z
77
77
  .enum(TENANT_DOCUMENT_TYPES)
78
- .describe(`Document type: a policy (${named(POLICY_TYPES)}) or ${named(OTHER_TENANT_DOCUMENT_TYPES)}`);
78
+ // The enum lists every type; the description only says which are policies.
79
+ .describe('Document type: the policy_* ones are policies');
79
80
 
80
81
  /**
81
82
  * The read, save and publish calls for one document scope. The tools are the
@@ -150,52 +151,48 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
150
151
  // ── Company-level policy documents ──────────────────────────────────
151
152
 
152
153
  if (client.hasCapability('tenant:documents:read')) {
153
- server.registerTool(
154
- 'taiga_list_documents',
155
- {
156
- title: 'List company documents',
157
- description:
158
- "List the organization's documents as summaries (type, name, description, version, draft state). The " +
159
- `${POLICY_TYPES.length} policies (${named(POLICY_TYPES)}) set the security, data privacy, SDLC, ` +
160
- 'operations, architecture and compliance rules for every product; the others ' +
161
- `(${named(OTHER_TENANT_DOCUMENT_TYPES)}) carry goals, standards, voice and standing instructions. ` +
162
- 'taiga_get_document reads one.',
163
- inputSchema: z.object({
164
- ...pagination,
165
- }),
166
- annotations: READ_ONLY,
167
- },
168
- (params) =>
169
- runTool(async () => {
170
- const result = await client.listTenantDocuments(params);
171
- return toolResult(page(summaries(result.data), result.nextCursor));
172
- })
173
- );
174
-
175
154
  server.registerTool(
176
155
  'taiga_get_document',
177
156
  {
178
157
  title: 'Get company document',
179
158
  description:
180
- "Read one of the organization's documents (a policy, goals, standards). Policies are long: pass " +
159
+ "Read one of the organization's documents by type, or without type list them all (type, name, version, " +
160
+ `draft state). The ${POLICY_TYPES.length} policies (${named(POLICY_TYPES)}) set the rules for every ` +
161
+ `product; the others (${named(OTHER_TENANT_DOCUMENT_TYPES)}) carry goals, standards, voice and ` +
162
+ 'instructions. Policies are long: pass ' +
181
163
  'sections: [] for the outline, then the section ids you need, or a control id such as SEC-5.1; ' +
182
164
  'unknownSections names ids it lacks. A miss says which documents are published and whether a draft ' +
183
165
  "exists. A section's source (template, overlay-<id>, cloud-preset:<providers>) and priority (core: every " +
184
166
  'organization keeps it) do not change what it requires.',
185
167
  inputSchema: z.object({
186
- type: tenantDocumentType,
168
+ type: tenantDocumentType.optional(),
187
169
  sections: policySectionsInput,
170
+ ...pagination,
188
171
  }),
189
172
  annotations: READ_ONLY,
190
173
  },
191
174
  (params) =>
192
175
  runTool(async () => {
176
+ // One tool for the list and the read (PRO-2800): the catalogue's weight is the cost every review ranks
177
+ // first, and a list tool beside each read was the read without a type.
178
+ if (params.type === undefined) {
179
+ if (params.sections) return toolError('VALIDATION_ERROR', 'Name the document (type) to read its sections.');
180
+ const result = await client.listTenantDocuments({ limit: params.limit, cursor: params.cursor });
181
+ return toolResult(page(summaries(result.data), result.nextCursor));
182
+ }
183
+ const type = params.type;
184
+ if (params.limit !== undefined || params.cursor !== undefined) {
185
+ return toolError(
186
+ 'VALIDATION_ERROR',
187
+ 'limit and cursor page the list; omit type to list, or drop them to read.'
188
+ );
189
+ }
193
190
  try {
194
- const result = await client.getTenantDocument(params.type);
191
+ const result = await client.getTenantDocument(type);
195
192
  const doc = stripProvenance(result.data);
196
193
  return params.sections
197
- ? sectionReadResult(doc, params.sections, params.type, {
198
- controlIds: (POLICY_TYPES as readonly string[]).includes(params.type),
194
+ ? sectionReadResult(doc, params.sections, type, {
195
+ controlIds: (POLICY_TYPES as readonly string[]).includes(type),
199
196
  })
200
197
  : toolResult(doc);
201
198
  } catch (error) {
@@ -203,10 +200,10 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
203
200
  // Best effort, each part on its own: a failed lookup must not turn
204
201
  // a miss into an outage, nor blank the part that did answer.
205
202
  const [draft, all] = await Promise.allSettled([
206
- client.getTenantDocumentDraft(params.type),
203
+ client.getTenantDocumentDraft(type),
207
204
  client.listTenantDocuments({ limit: 100 }),
208
205
  ]);
209
- const parts = [`No published ${params.type}.`];
206
+ const parts = [`No published ${type}.`];
210
207
  if (draft.status === 'fulfilled') {
211
208
  parts.push(
212
209
  draft.value.data
@@ -277,28 +274,6 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
277
274
  // ── Factory instructions ────────────────────────────────────────────
278
275
 
279
276
  if (client.hasCapability('factories:read')) {
280
- server.registerTool(
281
- 'taiga_list_factory_documents',
282
- {
283
- title: 'List factory documents',
284
- description:
285
- "List a factory's documents, published and draft (isDraft), as summaries (type, name, version). The only " +
286
- `type is ${named(FACTORY_DOCUMENT_TYPES)}: standing guidance (domain context, tech preferences, ` +
287
- 'conventions) for every product in the factory. An empty list means no publication or draft exists. ' +
288
- 'taiga_get_factory_document reads it.',
289
- inputSchema: z.object({
290
- factoryId: idInput('factory', 'taiga_list_factories (query finds one by name) gives it'),
291
- ...pagination,
292
- }),
293
- annotations: READ_ONLY,
294
- },
295
- (params) =>
296
- runTool(async () => {
297
- const result = await client.listFactoryDocuments(params.factoryId, params);
298
- return toolResult(page(summaries(result.data), result.nextCursor));
299
- })
300
- );
301
-
302
277
  server.registerTool(
303
278
  'taiga_get_factory_document',
304
279
  {
@@ -63,7 +63,7 @@ function readWith(row: Row, backlogRead: boolean): string | undefined {
63
63
  const type = /^[^:]+:([a-z_]+):decision:/.exec(String(row['subjectId']))?.[1];
64
64
  return type
65
65
  ? `taiga_get_product_document (productId, type: ${type}); the decision's text is this row with full: true`
66
- : 'taiga_list_product_documents (productId)';
66
+ : 'taiga_get_product_document (productId, no type)';
67
67
  }
68
68
  case 'inbox_task':
69
69
  return 'taiga_list_setup_tasks (productId)';
@@ -73,7 +73,7 @@ function readWith(row: Row, backlogRead: boolean): string | undefined {
73
73
  review_backlog: backlogRead ? 'taiga_list_initiatives (productId)' : undefined,
74
74
  review_maintenance: 'taiga_list_findings (productId)',
75
75
  review_policy: 'taiga_get_policy_standing (productId)',
76
- review_doc: 'taiga_list_product_documents (productId)',
76
+ review_doc: 'taiga_get_product_document (productId, no type)',
77
77
  }[String(row['kind'])];
78
78
  default:
79
79
  return undefined;
@@ -517,6 +517,8 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
517
517
  snoozedUntil: z
518
518
  .string()
519
519
  .datetime()
520
+ // format: date-time says it; the leap-year pattern beside it ran to hundreds of characters (PRO-2800).
521
+ .meta({ pattern: undefined })
520
522
  .nullable()
521
523
  .optional()
522
524
  .describe('Hide it until this time (ISO 8601 in UTC, ending in Z); null wakes it now'),
@@ -209,8 +209,10 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
209
209
  "List what maintenance sweeps found in a product's repository: dependencies, secrets, insecure code " +
210
210
  '(sast) and infrastructure. The first page opens with sweep (open counts, latest sweeps), so an old or ' +
211
211
  'failed sweep is not read as an all-clear. Rows: severity, tier (act_now: known exploited; urgent: ' +
212
- 'likely and serious; elevated: one of the two; routine), location, remediableBy (upstream_only: no fixed ' +
213
- 'version), breached controls and initiativeId of a fix under way. Omit status for open findings. taiga_get_finding reads one.',
212
+ 'likely and serious; elevated: one of the two; routine), location, remediableBy (coding_agent: a run here ' +
213
+ 'can fix it; upstream_only: the code is in a submodule, so the fix belongs in its own repository), fixable: false ' +
214
+ 'when no fixed version is known yet, breached controls and initiativeId of a fix under way. Omit status ' +
215
+ 'for open findings. taiga_get_finding reads one.',
214
216
  inputSchema: z.object({
215
217
  productId,
216
218
  status: z
@@ -429,6 +431,8 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
429
431
  expiresAt: z
430
432
  .string()
431
433
  .datetime({ offset: true })
434
+ // format: date-time says it; the leap-year pattern beside it ran to hundreds of characters (PRO-2800).
435
+ .meta({ pattern: undefined })
432
436
  .optional()
433
437
  .describe('When the ignore lapses and the finding resurfaces if it still exists (ISO 8601, in the future)'),
434
438
  }),
@@ -460,8 +464,8 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
460
464
  "Raise an initiative that fixes a maintenance finding, at the end of the product's line; it ends in a " +
461
465
  "pull request a person merges, and the initiative's review gate applies. wholeGroup: true fixes the " +
462
466
  'group in one initiative: every open advisory on that package version in one bump, or every location the ' +
463
- 'same rule flagged. Refused for a secret only in history, anything in a submodule, and remediableBy ' +
464
- 'upstream_only.',
467
+ 'same rule flagged. Refused for a secret only in history, remediableBy upstream_only (code in a ' +
468
+ 'submodule), and a dependency with no fixed version yet (fixable: false).',
465
469
  inputSchema: z.object({
466
470
  productId,
467
471
  findingId,
@@ -34,7 +34,7 @@ const EDITABLE_TYPES = PRODUCT_DOCUMENT_TYPES.filter((type): type is EditableTyp
34
34
 
35
35
  const documentType = z
36
36
  .enum(EDITABLE_TYPES)
37
- .describe('Document type -- taiga_list_product_documents shows which ones the product has');
37
+ .describe('Document type -- taiga_get_product_document without type shows which ones the product has');
38
38
 
39
39
  /** The documents a person reviews: the discovery chain, not the snapshot or the product's own instructions. */
40
40
  const reviewableType = z