@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.
@@ -20,8 +20,6 @@ import { page, pickFields } from './summarize.js';
20
20
  * next sweep no longer detects it, so there is no tool for either.
21
21
  */
22
22
 
23
- type Row = Record<string, unknown>;
24
-
25
23
  /** A finding as a list row: enough to decide whether to open, fix or ignore it. */
26
24
  const FINDING_ROW_FIELDS = [
27
25
  'id',
@@ -75,6 +73,31 @@ const VIOLATION_FIELDS = [
75
73
  'assessedAt',
76
74
  ] as const;
77
75
 
76
+ type Row = Record<string, unknown>;
77
+
78
+ /**
79
+ * How bad a product's policy standing is, as counts: the controls something is
80
+ * recorded against, the open violations by severity, the resolved ones. The
81
+ * API's standing carries the lists only; this tool and the product context
82
+ * both count them here.
83
+ */
84
+ export function standingSummary(standing: {
85
+ controls?: unknown;
86
+ divergences?: unknown;
87
+ resolvedDivergences?: unknown;
88
+ }): { controls: number; violations: number; bySeverity: Record<string, number>; resolvedViolations: number } {
89
+ const controls = Array.isArray(standing.controls) ? (standing.controls as Row[]) : [];
90
+ const violations = Array.isArray(standing.divergences) ? (standing.divergences as Row[]) : [];
91
+ const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
92
+ const listed = controls.filter((c) => Number(c['divergenceCount'] ?? 0) > 0 || Number(c['findingCount'] ?? 0) > 0);
93
+ const bySeverity: Record<string, number> = {};
94
+ for (const violation of violations) {
95
+ const severity = String(violation['severity'] ?? 'unknown');
96
+ bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
97
+ }
98
+ return { controls: listed.length, violations: violations.length, bySeverity, resolvedViolations: resolved };
99
+ }
100
+
78
101
  const findingId = idInput('finding', 'taiga_list_findings gives it');
79
102
 
80
103
  export function registerMaintenanceTools(server: McpServer, client: TaigaClient) {
@@ -88,8 +111,9 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
88
111
  "Where a product's repository stands: open findings by severity and kind, the most pressing ones, and " +
89
112
  'the latest sweeps, so you can tell when the product was last checked and whether that check succeeded. A ' +
90
113
  'quiet list from a sweep that last ran long ago, or that failed, is not an all-clear; a product never swept ' +
91
- 'says so. Sweeps run on their own after a push and periodically; nothing here starts one. Read the ' +
92
- 'findings with taiga_list_findings and the policy standing with taiga_get_policy_standing.',
114
+ 'says so. Sweeps run on their own after a push and periodically; nothing here starts one. This is the ' +
115
+ 'overview of the scanners alone; taiga_list_findings lists each finding to act on, and ' +
116
+ 'taiga_get_policy_standing judges the product against the policies (scanner findings and the documents).',
93
117
  inputSchema: z.object({ productId }),
94
118
  annotations: READ_ONLY,
95
119
  },
@@ -99,11 +123,11 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
99
123
  client.getMaintenanceSummary(params.productId),
100
124
  client.listMaintenanceSweeps(params.productId, { limit: 3 }),
101
125
  ]);
102
- const latest = pickFields(sweeps.data as Row[], SWEEP_FIELDS);
126
+ const latest = pickFields(sweeps.data, SWEEP_FIELDS);
103
127
  // The sweeps are reported as `latestSweeps`, narrowed to SWEEP_FIELDS;
104
128
  // the summary's own run rows (PRO-2527: one per repository) would
105
129
  // repeat them with every column.
106
- const { lastRun: _lastRun, lastRuns: _lastRuns, ...standing } = summary.data as Row;
130
+ const { lastRun: _lastRun, lastRuns: _lastRuns, ...standing } = summary.data;
107
131
  return toolResult({
108
132
  ...standing,
109
133
  latestSweeps: latest,
@@ -122,8 +146,9 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
122
146
  'severity and its priority tier (act_now: known exploited; urgent: likely and serious; elevated: one of ' +
123
147
  'the two; routine), where it is, who can fix it (remediableBy: coding_agent, or upstream_only for a ' +
124
148
  'submodule or a dependency with no fixed version), the policy controls it breaches, and the initiative ' +
125
- 'already fixing it (backlogItemId). Omit status for the findings that need action; work from the top tier ' +
126
- 'down. Read one in full with taiga_get_finding.',
149
+ 'already fixing it (initiativeId). Omit status for the findings that need action; work from the top tier ' +
150
+ 'down. Read one in full with taiga_get_finding. taiga_get_maintenance has the counts and when the last ' +
151
+ 'sweep ran, which says whether an empty list can be trusted.',
127
152
  inputSchema: z.object({
128
153
  productId,
129
154
  status: z
@@ -141,7 +166,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
141
166
  const { productId: id, ...filters } = params;
142
167
  const result = await client.listFindings(id, filters);
143
168
  return toolResult({
144
- ...page(pickFields(result.data as Row[], FINDING_ROW_FIELDS), result.nextCursor),
169
+ ...page(pickFields(result.data, FINDING_ROW_FIELDS), result.nextCursor),
145
170
  ...(typeof result.totalCount === 'number' ? { totalCount: result.totalCount } : {}),
146
171
  });
147
172
  })
@@ -159,7 +184,13 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
159
184
  inputSchema: z.object({ findingId }),
160
185
  annotations: READ_ONLY,
161
186
  },
162
- (params) => runTool(async () => toolResult((await client.getFinding(params.findingId)).data))
187
+ (params) =>
188
+ runTool(async () => {
189
+ // The finding's runId is the sweep that found it, not a coding run:
190
+ // in the product's words runId is what taiga_get_run takes (PRO-2800).
191
+ const { runId, ...finding } = (await client.getFinding(params.findingId)).data as Row;
192
+ return toolResult(runId === undefined ? finding : { sweepId: runId, ...finding });
193
+ })
163
194
  );
164
195
 
165
196
  server.registerTool(
@@ -171,7 +202,11 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
171
202
  "each with its violations (critical and high only, each citing the policy's sentence and the document " +
172
203
  'passage that contradicts it), what is being done about each (an initiative raised, a fix in progress, ' +
173
204
  'awaiting verification), and when the documents were derived and assessed, led by a summary of the ' +
174
- 'counts. A control breached by scanner findings counts them by tier (findingsByTier): a breach of ' +
205
+ 'counts. A control is breached when open scanner findings map to it, diverged when the assessment of the ' +
206
+ 'product documents found them contradicting it (a control can be both; state names the stronger, ' +
207
+ "breached). A violation's work is the initiative raised for it; live: false means that initiative is " +
208
+ 'done or canceled without the violation going away, so it needs a person or a new fix. A control ' +
209
+ 'breached by scanner findings counts them by tier (findingsByTier): a breach of ' +
175
210
  'routine findings only, such as secrets in test fixtures, is not an urgent one. A control with no violation ' +
176
211
  'is not listed, and it is not "passing" either: there is no such state. Pass full: true for every ' +
177
212
  "violation's evidence and the resolved ones. Fix a control with taiga_fix_policy_violation.",
@@ -186,24 +221,13 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
186
221
  },
187
222
  (params) =>
188
223
  runTool(async () => {
189
- const standing = (await client.getPolicyStanding(params.productId)).data as Row;
190
- const controls = Array.isArray(standing.controls) ? (standing.controls as Row[]) : [];
191
- const violations = Array.isArray(standing.divergences) ? (standing.divergences as Row[]) : [];
192
- const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
224
+ const standing = (await client.getPolicyStanding(params.productId)).data;
225
+ const controls = Array.isArray(standing.controls) ? standing.controls : [];
226
+ const violations = Array.isArray(standing.divergences) ? standing.divergences : [];
193
227
  const listed = controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0);
194
- const bySeverity: Record<string, number> = {};
195
- for (const violation of violations) {
196
- const severity = String(violation.severity ?? 'unknown');
197
- bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
198
- }
199
228
  // The counts first, on both answers: the lists run to thousands of
200
229
  // tokens, and "how bad is it" is the first question.
201
- const summary = {
202
- controls: listed.length,
203
- violations: violations.length,
204
- bySeverity,
205
- resolvedViolations: resolved,
206
- };
230
+ const summary = standingSummary(standing);
207
231
  if (params.full) return toolResult({ summary, ...standing });
208
232
  return toolResult({
209
233
  summary,
@@ -268,7 +292,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
268
292
  ...(params.expiresAt !== undefined ? { ignoreExpiresAt: params.expiresAt } : {}),
269
293
  }
270
294
  : { status: 'open' as const };
271
- const finding = (await client.updateFinding(params.findingId, body)).data as Row;
295
+ const finding = (await client.updateFinding(params.findingId, body)).data;
272
296
  return toolResult(pickFields([finding], [...FINDING_ROW_FIELDS, 'ignoreNote'])[0]);
273
297
  })
274
298
  );
@@ -299,10 +323,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
299
323
  (params) =>
300
324
  runTool(async () => {
301
325
  if (!params.wholeGroup) {
302
- const fixed = (await client.remediateFinding(params.productId, params.findingId)).data as {
303
- finding?: Row;
304
- backlogItemId?: unknown;
305
- };
326
+ const fixed = (await client.remediateFinding(params.productId, params.findingId)).data;
306
327
  return toolResult({
307
328
  backlogItemId: fixed.backlogItemId,
308
329
  finding: fixed.finding ? pickFields([fixed.finding], FINDING_ROW_FIELDS)[0] : null,
@@ -311,7 +332,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
311
332
  // The group is the finding's own: the route matches the package (or
312
333
  // rule), the installed version and the repository exactly, so they are
313
334
  // taken from the finding rather than asked of the caller.
314
- const finding = (await client.getFinding(params.findingId)).data as Row;
335
+ const finding = (await client.getFinding(params.findingId)).data;
315
336
  // The finding is read by its own id, so it is held to the product the
316
337
  // group fix is sent to, as the single-finding route holds it.
317
338
  if (finding.productId !== params.productId) {
@@ -1,7 +1,13 @@
1
1
  import { fromMarkdown } from 'mdast-util-from-markdown';
2
2
  import { toMarkdown } from 'mdast-util-to-markdown';
3
3
  import type { Heading, Node, Parent, PhrasingContent } from 'mdast';
4
- import { isRecord, sectionContent, sectionsOf } from './sections.js';
4
+ import { isRecord, isSectionObject, sectionContent, sectionsOf, type StoredDocument } from './sections.js';
5
+
6
+ /** A document as rendered: its data, and the facts its title and header line name. */
7
+ interface RenderedDocument extends StoredDocument {
8
+ name?: unknown;
9
+ type?: unknown;
10
+ }
5
11
 
6
12
  /**
7
13
  * A document as text an IDE can attach: the name as the title, one line of
@@ -11,7 +17,7 @@ import { isRecord, sectionContent, sectionsOf } from './sections.js';
11
17
  * described, and a document that is no section map at all is handed over as
12
18
  * JSON.
13
19
  */
14
- export function renderDocument(doc: Record<string, unknown>): { mimeType: string; text: string } {
20
+ export function renderDocument(doc: RenderedDocument): { mimeType: string; text: string } {
15
21
  const data = isRecord(doc.data) ? doc.data : null;
16
22
  const sections = data ? sectionsOf(data) : null;
17
23
  if (!data || !sections) return { mimeType: 'application/json', text: JSON.stringify(doc, null, 2) };
@@ -25,7 +31,7 @@ export function renderDocument(doc: Record<string, unknown>): { mimeType: string
25
31
  // the headings: a heading is text a section could forge, this line is not.
26
32
  const core = Object.keys(sections).filter((id) => {
27
33
  const section = sections[id];
28
- return isRecord(section) && section.priority === 'core';
34
+ return isSectionObject(section) && section.priority === 'core';
29
35
  });
30
36
  if (core.length > 0) facts.push(`core sections: ${core.join(', ')}`);
31
37
  if (facts.length > 0) lines.push('', facts.join(' | '));
@@ -120,11 +126,13 @@ function atxOf(depth: Heading['depth'], children: PhrasingContent[]): string {
120
126
 
121
127
  /** The section's title when it carries one, else its id. */
122
128
  function sectionTitle(section: unknown, id: string): string {
123
- return isRecord(section) && typeof section.title === 'string' && section.title.trim() ? section.title.trim() : id;
129
+ return isSectionObject(section) && typeof section.title === 'string' && section.title.trim()
130
+ ? section.title.trim()
131
+ : id;
124
132
  }
125
133
 
126
134
  /** The mime type a read will answer with, for a list entry that must not disagree with it. */
127
- export function mimeTypeOf(doc: Record<string, unknown>): string {
135
+ export function mimeTypeOf(doc: StoredDocument): string {
128
136
  const data = isRecord(doc.data) ? doc.data : null;
129
137
  return data && sectionsOf(data) ? 'text/markdown' : 'application/json';
130
138
  }
@@ -1,7 +1,8 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { ENVIRONMENT_TYPES } from '@taiga/types';
3
3
  import { z } from 'zod';
4
- import { TaigaApiError, type TaigaClient } from '../client.js';
4
+ import type { Deployment, Environment } from '@taiga/types';
5
+ import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
5
6
  import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
6
7
  import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
7
8
  import { withNotFoundHint } from './hints.js';
@@ -16,8 +17,6 @@ import { page, pickFields } from './summarize.js';
16
17
  * provider and is an administrator's action in the web.
17
18
  */
18
19
 
19
- type Row = Record<string, unknown>;
20
-
21
20
  /** A deployment as a line of history: what shipped where, how it ended, and who shipped it. */
22
21
  const DEPLOYMENT_FIELDS = [
23
22
  'id',
@@ -39,6 +38,22 @@ const DEPLOYMENT_FIELDS = [
39
38
 
40
39
  const ENVIRONMENT_FIELDS = ['id', 'name', 'slug', 'type', 'ordinal', 'status', 'isProtected', 'url'] as const;
41
40
 
41
+ /**
42
+ * Deployments as lines, each naming the repository that deployed it: every
43
+ * repository runs its own deployment in an environment, so two running in
44
+ * production are two repositories, not a contradiction (PRO-2528).
45
+ */
46
+ function deploymentLines(rows: WithoutTenant<Deployment>[]) {
47
+ return rows.map((row) => {
48
+ // `metadata` is an untyped jsonb column until PRO-2515 types it.
49
+ const repository = row.metadata?.['repositoryFullName'];
50
+ return {
51
+ ...pickFields([row], DEPLOYMENT_FIELDS)[0],
52
+ repository: typeof repository === 'string' ? repository : null,
53
+ };
54
+ });
55
+ }
56
+
42
57
  const environmentId = idInput('environment', 'taiga_list_environments lists them; a product ID is not one');
43
58
  /** The windows the pages offer, as the schema's own values rather than a number checked afterwards. */
44
59
  const windowDays = z.literal([30, 60, 90]).optional().describe('Days to look back: 30, 60 or 90 (default 30)');
@@ -62,12 +77,15 @@ async function part<T>(read: () => Promise<T>): Promise<{ value: T } | { unavail
62
77
  const ENVIRONMENT_PAGES = 5;
63
78
 
64
79
  /** Every environment of a product, page by page, and whether any were left past the guard. */
65
- async function allEnvironments(client: TaigaClient, product: string): Promise<{ rows: Row[]; more: boolean }> {
66
- const rows: Row[] = [];
80
+ async function allEnvironments(
81
+ client: TaigaClient,
82
+ product: string
83
+ ): Promise<{ rows: WithoutTenant<Environment>[]; more: boolean }> {
84
+ const rows: WithoutTenant<Environment>[] = [];
67
85
  let cursor: string | undefined;
68
86
  for (let pageNo = 0; pageNo < ENVIRONMENT_PAGES; pageNo++) {
69
87
  const result = await client.listEnvironments(product, { limit: 100, cursor });
70
- rows.push(...(result.data as Row[]));
88
+ rows.push(...result.data);
71
89
  if (!result.nextCursor) return { rows, more: false };
72
90
  cursor = result.nextCursor;
73
91
  }
@@ -104,23 +122,23 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
104
122
  }),
105
123
  part(() => client.getDeploymentSyncStatus(params.productId)),
106
124
  ]);
107
- const now = current.data as { current?: Row[]; deploying?: Row[] };
125
+ const now = current.data;
108
126
  // An empty answer says why, as the maintenance read does: no
109
127
  // environment, or none deployed to yet, is not "nothing is wrong".
110
128
  const note =
111
129
  environments.rows.length === 0
112
130
  ? 'This product has no environments yet: an environment is defined with taiga_define_environment, ' +
113
131
  'and its deployments are mirrored from the provider once it is.'
114
- : (history.data as Row[]).length === 0 && !params.cursor
132
+ : history.data.length === 0 && !params.cursor
115
133
  ? 'No deployments recorded yet: none was mirrored from the provider (syncStatus says when it last ' +
116
134
  'synced), or nothing has been deployed.'
117
135
  : null;
118
136
  return toolResult({
119
137
  environments: pickFields(environments.rows, ENVIRONMENT_FIELDS),
120
138
  ...(environments.more ? { moreEnvironments: true } : {}),
121
- running: pickFields(now.current ?? [], DEPLOYMENT_FIELDS),
122
- deploying: pickFields(now.deploying ?? [], DEPLOYMENT_FIELDS),
123
- history: page(pickFields(history.data as Row[], DEPLOYMENT_FIELDS), history.nextCursor),
139
+ running: deploymentLines(now.current ?? []),
140
+ deploying: deploymentLines(now.deploying ?? []),
141
+ history: page(deploymentLines(history.data), history.nextCursor),
124
142
  ...('value' in sync
125
143
  ? { syncStatus: sync.value.data }
126
144
  : { syncStatus: null, syncUnavailable: sync.unavailable }),
@@ -226,7 +244,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
226
244
  }
227
245
  const value = (p: { value: { data: unknown } } | { unavailable: string }) =>
228
246
  'value' in p ? p.value.data : { unavailable: p.unavailable };
229
- const envHealth = (health.data as Row[]).filter((row) => row.environmentId === env);
247
+ const envHealth = health.data.filter((row) => row.environmentId === env);
230
248
  return toolResult({
231
249
  health: envHealth,
232
250
  ...(envHealth.length === 0
@@ -267,7 +285,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
267
285
  (params) =>
268
286
  runTool(async () => {
269
287
  const created = (await client.createEnvironment(params.productId, { name: params.name, type: params.type }))
270
- .data as Row;
288
+ .data;
271
289
  return toolResult(pickFields([created], ENVIRONMENT_FIELDS)[0]);
272
290
  })
273
291
  );
@@ -295,7 +313,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
295
313
  if (Object.values(changes).every((value) => value === undefined)) {
296
314
  return toolError('VALIDATION_ERROR', 'Change at least one of name, type or url.');
297
315
  }
298
- const updated = (await client.updateEnvironment(id, changes)).data as Row;
316
+ const updated = (await client.updateEnvironment(id, changes)).data;
299
317
  return toolResult(pickFields([updated], ENVIRONMENT_FIELDS)[0]);
300
318
  })
301
319
  );
@@ -8,7 +8,7 @@ import {
8
8
  SETUP_REQUIRED_DOCUMENT_TYPES,
9
9
  } from '@taiga/types';
10
10
  import { z } from 'zod';
11
- import type { TaigaClient, TaigaDocument } from '../client.js';
11
+ import type { TaigaClient } from '../client.js';
12
12
  import { OPERATE, WRITE } from './annotations.js';
13
13
  import { type DocumentScope, publishDraft, sectionContentInput, sectionIdFrom, updateSection } from './documents.js';
14
14
  import { runTool, toolError, toolResult } from './errors.js';
@@ -25,8 +25,6 @@ import { jobReport } from './jobs.js';
25
25
  * code and is not edited.
26
26
  */
27
27
 
28
- type Row = Record<string, unknown>;
29
-
30
28
  /** The plan-side documents people edit; the codebase snapshot is derived from the code, never written. */
31
29
  type EditableType = Exclude<ProductDocumentType, 'codebase_snapshot'>;
32
30
  const EDITABLE_TYPES = PRODUCT_DOCUMENT_TYPES.filter((type): type is EditableType => type !== 'codebase_snapshot') as [
@@ -56,7 +54,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
56
54
 
57
55
  const productScope = (product: string, type: string): DocumentScope => ({
58
56
  getDraft: () => client.getProductDocumentDraft(product, type),
59
- getPublished: async () => (await client.getProductDocument(product, type, 'plan')) as { data: TaigaDocument },
57
+ getPublished: () => client.getProductDocument(product, type, 'plan'),
60
58
  saveDraft: (data) => client.saveProductDocumentDraft(product, type, { data }),
61
59
  publish: () => client.publishProductDocument(product, type),
62
60
  });
@@ -95,7 +93,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
95
93
  "Publish a product document's draft as its new version, the one agents work from. Documents generated " +
96
94
  'from it are not rewritten: each is marked outdated, and is regenerated when someone chooses to ' +
97
95
  '(taiga_regenerate_product_document, or taiga_generate_product_documents for every outdated and missing ' +
98
- 'one). Ask the person before publishing.',
96
+ 'one).',
99
97
  inputSchema: z.object({ productId, type: documentType }),
100
98
  annotations: OPERATE,
101
99
  },
@@ -155,7 +153,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
155
153
  targetId: params.productId,
156
154
  documentType: params.type,
157
155
  })
158
- ).data as Row;
156
+ ).data;
159
157
  return toolResult(jobReport(job));
160
158
  })
161
159
  );
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import { TaigaApiError, type TaigaClient } from '../client.js';
3
+ import type { Product, ProductDocumentListItem } from '@taiga/types';
4
+ import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
4
5
  import { READ_ONLY } from './annotations.js';
5
6
  import { idInput, pagination, productId, remoteUrl, slug } from './inputs.js';
6
7
  import {
@@ -14,14 +15,17 @@ import {
14
15
  isImportedProduct,
15
16
  productDocumentName,
16
17
  type ProductDocumentOrigin,
17
- setupNextStep,
18
18
  type ProductSettingsView,
19
19
  } from './catalogue.js';
20
20
  import { apiFailureCode, apiFailureText, isMissOrRefusal, runTool, toolError, toolResult } from './errors.js';
21
21
  import { parseRemote } from './remote.js';
22
22
  import { sectionReadResult, sectionsInput, stripProvenance } from './sections.js';
23
+ import { setupReadiness } from './readiness.js';
23
24
  import { SEARCH_NOTE, SEARCH_PAGE_SIZE, SEARCH_WITH_CURSOR, page, pickFields, searchPages } from './summarize.js';
24
25
 
26
+ type ProductRow = WithoutTenant<Product>;
27
+ type DocumentRow = WithoutTenant<ProductDocumentListItem>;
28
+
25
29
  /** What a product row needs to say for the model to pick one and read it in full. */
26
30
  // Not `kind`: it is parked behind a flag that is on nowhere, so every row said null.
27
31
  export const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'discoveryStep'] as const;
@@ -72,11 +76,11 @@ const originInput = z
72
76
  * than being folded into a truncated list that would read as a complete one.
73
77
  */
74
78
  async function linkedProducts(client: TaigaClient, repository: string, repositoryHost: string) {
75
- const products: Record<string, unknown>[] = [];
79
+ const products: ProductRow[] = [];
76
80
  let cursor: string | undefined;
77
81
  for (let page = 0; page < RESOLVE_PAGE_LIMIT; page += 1) {
78
82
  const result = await client.listProducts({ repository, repositoryHost, limit: PAGE_SIZE, cursor });
79
- products.push(...(result.data as Record<string, unknown>[]));
83
+ products.push(...result.data);
80
84
  if (!result.nextCursor) return { products, truncated: false };
81
85
  cursor = result.nextCursor;
82
86
  }
@@ -84,15 +88,14 @@ async function linkedProducts(client: TaigaClient, repository: string, repositor
84
88
  }
85
89
 
86
90
  /** A document list row as the model sees it: the summary fields plus a display name and the staleness fact. */
87
- function documentSummaries(rows: unknown[]): Record<string, unknown>[] {
88
- const typed = rows as Record<string, unknown>[];
89
- return pickFields(typed, DOCUMENT_SUMMARY_FIELDS).map((picked, index) => {
91
+ function documentSummaries(rows: DocumentRow[]) {
92
+ return pickFields(rows, DOCUMENT_SUMMARY_FIELDS).map((picked, index) => {
90
93
  // `outdated` on the wire is the list of sources that moved on; the model
91
94
  // needs the fact, not the tree. Published plan rows carry it: null when
92
95
  // current, a source list when not. A draft has nothing published to be
93
96
  // outdated (the API stamps it null too) and reality rows carry no such
94
97
  // field: no fact for either.
95
- const outdated = typed[index]!.outdated as { sources?: unknown[] } | null | undefined;
98
+ const outdated = rows[index]!.outdated;
96
99
  const fact =
97
100
  picked.isDraft !== false
98
101
  ? undefined
@@ -110,12 +113,14 @@ function documentSummaries(rows: unknown[]): Record<string, unknown>[] {
110
113
  });
111
114
  }
112
115
 
116
+ type DocumentSummary = ReturnType<typeof documentSummaries>[number];
117
+
113
118
  interface Timelines {
114
119
  projectType: 'greenfield' | 'brownfield';
115
120
  defaultOrigin: ProductDocumentOrigin;
116
121
  /** The side's summaries, or null when the API could not list it (see `unlistable`). */
117
- plan: Record<string, unknown>[] | null;
118
- reality: Record<string, unknown>[] | null;
122
+ plan: DocumentSummary[] | null;
123
+ reality: DocumentSummary[] | null;
119
124
  /** Why a null side is null, in the words the notes and a miss message use. */
120
125
  unlistable: Partial<Record<ProductDocumentOrigin, string>>;
121
126
  notes: string[];
@@ -134,7 +139,7 @@ function unlistableNote(origin: ProductDocumentOrigin, reason: unknown): string
134
139
  * so a caller that already holds it passes it in.
135
140
  */
136
141
  async function documentTimelines(client: TaigaClient, id: string, product?: ProductSettingsView): Promise<Timelines> {
137
- const loaded = product ?? ((await client.getProduct(id)).data as ProductSettingsView);
142
+ const loaded = product ?? (await client.getProduct(id)).data;
138
143
  // Each side on its own: one side the API cannot list is reported as
139
144
  // unreadable, not as empty, and never hides the side that did answer.
140
145
  // Both failing is the API failing, and that is an error like any other.
@@ -142,7 +147,7 @@ async function documentTimelines(client: TaigaClient, id: string, product?: Prod
142
147
  PRODUCT_DOCUMENT_ORIGINS.map((o) => client.listProductDocuments(id, { origin: o, limit: PAGE_SIZE }))
143
148
  );
144
149
  if (settled.every((result) => result.status === 'rejected')) throw settled[0]!.reason;
145
- const sides: Partial<Record<ProductDocumentOrigin, Record<string, unknown>[] | null>> = {};
150
+ const sides: Partial<Record<ProductDocumentOrigin, DocumentSummary[] | null>> = {};
146
151
  const unlistable: Partial<Record<ProductDocumentOrigin, string>> = {};
147
152
  PRODUCT_DOCUMENT_ORIGINS.forEach((o, index) => {
148
153
  const result = settled[index]!;
@@ -167,10 +172,10 @@ async function documentTimelines(client: TaigaClient, id: string, product?: Prod
167
172
  };
168
173
  }
169
174
 
170
- const isPublished = (row: Record<string, unknown>) => row.isDraft === false;
175
+ const isPublished = (row: { isDraft?: boolean }) => row.isDraft === false;
171
176
 
172
177
  /** `specification (v2), architecture (draft)` for one side. */
173
- function sideList(rows: Record<string, unknown>[]): string {
178
+ function sideList(rows: DocumentSummary[]): string {
174
179
  return rows
175
180
  .map((row) => `${String(row.type)} (${isPublished(row) ? `v${String(row.version)}` : 'draft'})`)
176
181
  .join(', ');
@@ -218,7 +223,7 @@ export async function resolveProductRef(
218
223
  accepted: readonly string[],
219
224
  /** How the caller's arguments are passed, for the message when none was given (a slash command's usage). */
220
225
  usage?: string
221
- ): Promise<{ product: Record<string, unknown> } | { refusal: { code: string; message: string } }> {
226
+ ): Promise<{ product: ProductRow } | { refusal: { code: string; message: string } }> {
222
227
  const refusal = (code: string, message: string) => ({ refusal: { code, message } });
223
228
  const given = [ref.productId, ref.slug, ref.remoteUrl].filter((v) => v !== undefined).length;
224
229
  if (given !== 1) {
@@ -228,7 +233,7 @@ export async function resolveProductRef(
228
233
  }
229
234
  if (ref.productId) {
230
235
  try {
231
- return { product: (await client.getProduct(ref.productId)).data as Record<string, unknown> };
236
+ return { product: (await client.getProduct(ref.productId)).data };
232
237
  } catch (error) {
233
238
  if (!isMissOrRefusal(error)) throw error;
234
239
  return refusal(
@@ -238,7 +243,7 @@ export async function resolveProductRef(
238
243
  }
239
244
  }
240
245
  if (ref.slug) {
241
- const [product] = (await client.listProducts({ slug: ref.slug, limit: 1 })).data as Record<string, unknown>[];
246
+ const [product] = (await client.listProducts({ slug: ref.slug, limit: 1 })).data;
242
247
  if (!product) {
243
248
  return refusal(
244
249
  'NOT_FOUND',
@@ -341,9 +346,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
341
346
  const { query: text, ...filters } = params;
342
347
  if (text === undefined) {
343
348
  const result = await client.listProducts(filters);
344
- return toolResult(
345
- page(pickFields(result.data as Record<string, unknown>[], PRODUCT_SUMMARY_FIELDS), result.nextCursor)
346
- );
349
+ return toolResult(page(pickFields(result.data, PRODUCT_SUMMARY_FIELDS), result.nextCursor));
347
350
  }
348
351
  if (params.cursor !== undefined) return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
349
352
  const found = await searchPages(
@@ -374,8 +377,10 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
374
377
  '(application, infrastructure, deployment) and its settings; the list and resolve tools carry ' +
375
378
  'summaries; taiga_get_product_context carries this summary with the rest a session starts from. Use ' +
376
379
  'taiga_list_product_documents for its specification and design, ' +
377
- 'taiga_list_backlog_items for its work and taiga_list_builds for what the coding agent did. A product ' +
378
- 'still in setup carries nextStep: what it waits for and the call that moves it on.',
380
+ 'taiga_list_initiatives for its work and taiga_list_runs for what the coding agent did. A product ' +
381
+ 'still in setup carries readiness: whether it can finish setup now, its repository and import, the ' +
382
+ 'required documents not yet published, the checks not yet stamped, and nextStep, the one move that ' +
383
+ 'gets it closer.',
379
384
  inputSchema: z.object({
380
385
  productId: productId.optional(),
381
386
  slug: slug.optional(),
@@ -386,8 +391,8 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
386
391
  runTool(async () => {
387
392
  const resolved = await resolveProductRef(client, params, ['productId', 'slug']);
388
393
  if ('refusal' in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
389
- const nextStep = setupNextStep(resolved.product);
390
- return toolResult(nextStep ? { ...resolved.product, nextStep } : resolved.product);
394
+ const readiness = await setupReadiness(client, resolved.product);
395
+ return toolResult(readiness ? { ...resolved.product, readiness } : resolved.product);
391
396
  })
392
397
  );
393
398
 
@@ -483,11 +488,11 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
483
488
  // The product's own timeline when none is asked for; the API's
484
489
  // default is plan, which is the wrong side of an imported product.
485
490
  let product: ProductSettingsView | undefined;
486
- if (!params.origin) product = (await client.getProduct(params.productId)).data as ProductSettingsView;
491
+ if (!params.origin) product = (await client.getProduct(params.productId)).data;
487
492
  const origin = params.origin ?? defaultOriginFor(product, params.type);
488
493
  try {
489
494
  const result = await client.getProductDocument(params.productId, params.type, origin);
490
- const doc = stripProvenance(result.data as Record<string, unknown>);
495
+ const doc = stripProvenance(result.data);
491
496
  if (!params.sections) return toolResult(doc);
492
497
  return sectionReadResult(doc, params.sections, `${origin} ${params.type}`);
493
498
  } catch (error) {
@@ -496,7 +501,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
496
501
  // miss, so the product read propagates. The lists after it are
497
502
  // best effort per side (documentTimelines); only both failing
498
503
  // propagates, as the API failing.
499
- product ??= (await client.getProduct(params.productId)).data as ProductSettingsView;
504
+ product ??= (await client.getProduct(params.productId)).data;
500
505
  const timelines = await documentTimelines(client, params.productId, product);
501
506
  return toolError('NOT_FOUND', describeMiss(params.type, origin, timelines, product));
502
507
  }