@taiga-ai/mcp-server 0.18.0-dev.446 → 0.18.0-dev.448

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.
package/src/tools/jobs.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { createdInitiativeIdsOf, createdInitiativesOf, isBacklogGenerationEmptyResult } from '@taiga/types';
3
3
  import { z } from 'zod';
4
+ import type { AgentJob } from '@taiga/types';
4
5
  import { idInput } from './inputs.js';
5
- import type { TaigaClient } from '../client.js';
6
+ import type { TaigaClient, WithoutTenant } from '../client.js';
6
7
  import { READ_ONLY } from './annotations.js';
7
8
  import { runTool, toolResult } from './errors.js';
8
9
  import { pickFields } from './summarize.js';
@@ -13,7 +14,7 @@ import { pickFields } from './summarize.js';
13
14
  * it is, and what came of it.
14
15
  */
15
16
 
16
- type Row = Record<string, unknown>;
17
+ type Job = WithoutTenant<AgentJob>;
17
18
 
18
19
  /** What a job says about itself: its kind, how far it got, and why it failed. */
19
20
  const JOB_FIELDS = [
@@ -43,7 +44,7 @@ export const SETTLED_JOB_STATUSES: ReadonlySet<string> = new Set(['completed', '
43
44
  * own words and the way to decide otherwise). Null while the job runs, and
44
45
  * for a job that is not an initiative request.
45
46
  */
46
- function requestOutcome(job: Row): Row | null {
47
+ function requestOutcome(job: Job) {
47
48
  if (job.type !== 'generate_backlog' || job.status !== 'completed') return null;
48
49
  const created = createdInitiativesOf(job.result);
49
50
  if (created) return { outcome: 'created', initiatives: created };
@@ -63,7 +64,7 @@ function requestOutcome(job: Row): Row | null {
63
64
  }
64
65
 
65
66
  /** A job as the tools hand it over: its own fields, and for an initiative request, what came of it. */
66
- export function jobReport(job: Row): Row {
67
+ export function jobReport(job: Job) {
67
68
  const outcome = requestOutcome(job);
68
69
  return { ...pickFields([job], JOB_FIELDS)[0], ...(outcome ? { outcome } : {}) };
69
70
  }
@@ -85,6 +86,6 @@ export function registerJobTools(server: McpServer, client: TaigaClient) {
85
86
  inputSchema: z.object({ jobId: idInput('job', 'the tool that started the job returned it') }),
86
87
  annotations: READ_ONLY,
87
88
  },
88
- (params) => runTool(async () => toolResult(jobReport((await client.getAgentJob(params.jobId)).data as Row)))
89
+ (params) => runTool(async () => toolResult(jobReport((await client.getAgentJob(params.jobId)).data)))
89
90
  );
90
91
  }
@@ -15,8 +15,6 @@ import { page, pickFields } from './summarize.js';
15
15
  * admin, which the API enforces.
16
16
  */
17
17
 
18
- type Row = Record<string, unknown>;
19
-
20
18
  const KNOWLEDGE_FIELDS = [
21
19
  'id',
22
20
  'factoryId',
@@ -123,7 +121,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
123
121
  ? { factoryId: params.factoryId, productId: null }
124
122
  : { factoryId: null, productId: null };
125
123
  const result = await client.listKnowledge({ ...scope, limit: params.limit, cursor: params.cursor });
126
- return toolResult(page(pickFields(result.data as Row[], KNOWLEDGE_FIELDS), result.nextCursor));
124
+ return toolResult(page(pickFields(result.data, KNOWLEDGE_FIELDS), result.nextCursor));
127
125
  })
128
126
  );
129
127
 
@@ -143,7 +141,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
143
141
  (params) =>
144
142
  runTool(async () => {
145
143
  const file = await client.getKnowledge(params.knowledgeId);
146
- const row = pickFields([file.data as Row], KNOWLEDGE_FIELDS)[0]!;
144
+ const row = pickFields([file.data], KNOWLEDGE_FIELDS)[0]!;
147
145
  if (row.status !== 'ready') {
148
146
  return toolResult({
149
147
  ...row,
@@ -213,7 +211,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
213
211
  // there: the flag says so, so that file is not taken for a new one
214
212
  // (and deleted as one in a clean-up).
215
213
  return toolResult({
216
- ...pickFields([result.data as Row], KNOWLEDGE_FIELDS)[0],
214
+ ...pickFields([result.data], KNOWLEDGE_FIELDS)[0],
217
215
  deduplicated: result.deduplicated === true,
218
216
  });
219
217
  })
@@ -231,7 +229,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
231
229
  },
232
230
  (params) =>
233
231
  runTool(async () =>
234
- toolResult(pickFields([(await client.retryKnowledge(params.knowledgeId)).data as Row], KNOWLEDGE_FIELDS)[0])
232
+ toolResult(pickFields([(await client.retryKnowledge(params.knowledgeId)).data], KNOWLEDGE_FIELDS)[0])
235
233
  )
236
234
  );
237
235
 
@@ -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',
@@ -99,11 +97,11 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
99
97
  client.getMaintenanceSummary(params.productId),
100
98
  client.listMaintenanceSweeps(params.productId, { limit: 3 }),
101
99
  ]);
102
- const latest = pickFields(sweeps.data as Row[], SWEEP_FIELDS);
100
+ const latest = pickFields(sweeps.data, SWEEP_FIELDS);
103
101
  // The sweeps are reported as `latestSweeps`, narrowed to SWEEP_FIELDS;
104
102
  // the summary's own run rows (PRO-2527: one per repository) would
105
103
  // repeat them with every column.
106
- const { lastRun: _lastRun, lastRuns: _lastRuns, ...standing } = summary.data as Row;
104
+ const { lastRun: _lastRun, lastRuns: _lastRuns, ...standing } = summary.data;
107
105
  return toolResult({
108
106
  ...standing,
109
107
  latestSweeps: latest,
@@ -141,7 +139,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
141
139
  const { productId: id, ...filters } = params;
142
140
  const result = await client.listFindings(id, filters);
143
141
  return toolResult({
144
- ...page(pickFields(result.data as Row[], FINDING_ROW_FIELDS), result.nextCursor),
142
+ ...page(pickFields(result.data, FINDING_ROW_FIELDS), result.nextCursor),
145
143
  ...(typeof result.totalCount === 'number' ? { totalCount: result.totalCount } : {}),
146
144
  });
147
145
  })
@@ -186,9 +184,9 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
186
184
  },
187
185
  (params) =>
188
186
  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[]) : [];
187
+ const standing = (await client.getPolicyStanding(params.productId)).data;
188
+ const controls = Array.isArray(standing.controls) ? standing.controls : [];
189
+ const violations = Array.isArray(standing.divergences) ? standing.divergences : [];
192
190
  const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
193
191
  const listed = controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0);
194
192
  const bySeverity: Record<string, number> = {};
@@ -268,7 +266,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
268
266
  ...(params.expiresAt !== undefined ? { ignoreExpiresAt: params.expiresAt } : {}),
269
267
  }
270
268
  : { status: 'open' as const };
271
- const finding = (await client.updateFinding(params.findingId, body)).data as Row;
269
+ const finding = (await client.updateFinding(params.findingId, body)).data;
272
270
  return toolResult(pickFields([finding], [...FINDING_ROW_FIELDS, 'ignoreNote'])[0]);
273
271
  })
274
272
  );
@@ -299,10 +297,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
299
297
  (params) =>
300
298
  runTool(async () => {
301
299
  if (!params.wholeGroup) {
302
- const fixed = (await client.remediateFinding(params.productId, params.findingId)).data as {
303
- finding?: Row;
304
- backlogItemId?: unknown;
305
- };
300
+ const fixed = (await client.remediateFinding(params.productId, params.findingId)).data;
306
301
  return toolResult({
307
302
  backlogItemId: fixed.backlogItemId,
308
303
  finding: fixed.finding ? pickFields([fixed.finding], FINDING_ROW_FIELDS)[0] : null,
@@ -311,7 +306,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
311
306
  // The group is the finding's own: the route matches the package (or
312
307
  // rule), the installed version and the repository exactly, so they are
313
308
  // taken from the finding rather than asked of the caller.
314
- const finding = (await client.getFinding(params.findingId)).data as Row;
309
+ const finding = (await client.getFinding(params.findingId)).data;
315
310
  // The finding is read by its own id, so it is held to the product the
316
311
  // group fix is sent to, as the single-finding route holds it.
317
312
  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
  });
@@ -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 {
@@ -22,6 +23,9 @@ import { parseRemote } from './remote.js';
22
23
  import { sectionReadResult, sectionsInput, stripProvenance } from './sections.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(
@@ -483,11 +486,11 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
483
486
  // The product's own timeline when none is asked for; the API's
484
487
  // default is plan, which is the wrong side of an imported product.
485
488
  let product: ProductSettingsView | undefined;
486
- if (!params.origin) product = (await client.getProduct(params.productId)).data as ProductSettingsView;
489
+ if (!params.origin) product = (await client.getProduct(params.productId)).data;
487
490
  const origin = params.origin ?? defaultOriginFor(product, params.type);
488
491
  try {
489
492
  const result = await client.getProductDocument(params.productId, params.type, origin);
490
- const doc = stripProvenance(result.data as Record<string, unknown>);
493
+ const doc = stripProvenance(result.data);
491
494
  if (!params.sections) return toolResult(doc);
492
495
  return sectionReadResult(doc, params.sections, `${origin} ${params.type}`);
493
496
  } catch (error) {
@@ -496,7 +499,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
496
499
  // miss, so the product read propagates. The lists after it are
497
500
  // best effort per side (documentTimelines); only both failing
498
501
  // propagates, as the API failing.
499
- product ??= (await client.getProduct(params.productId)).data as ProductSettingsView;
502
+ product ??= (await client.getProduct(params.productId)).data;
500
503
  const timelines = await documentTimelines(client, params.productId, product);
501
504
  return toolError('NOT_FOUND', describeMiss(params.type, origin, timelines, product));
502
505
  }
@@ -1,7 +1,7 @@
1
1
  import { type GetPromptResult, type McpServer, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { TaigaApiError, type TaigaClient } from '../client.js';
4
- import { POLICY_TYPES, defaultOriginFor, type ProductSettingsView } from './catalogue.js';
4
+ import { POLICY_TYPES, defaultOriginFor } from './catalogue.js';
5
5
  import { productContext } from './context.js';
6
6
  import { apiFailureCode, apiFailureText, isMissOrRefusal, isNetworkFailure, networkFailureDetail } from './errors.js';
7
7
  import { idInput, productId, remoteUrl, slug } from './inputs.js';
@@ -126,12 +126,12 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
126
126
  client.getBacklogItem(args.itemId),
127
127
  'No backlog item with that ID the key can see; taiga_list_backlog_items shows what exists'
128
128
  )
129
- ).data as Record<string, unknown>;
129
+ ).data;
130
130
  const productId = String(item.productId);
131
131
  const product = (
132
132
  await served(client.getProduct(productId), 'The product of that item is not visible to the key')
133
- ).data as Record<string, unknown>;
134
- const origin = defaultOriginFor(product as ProductSettingsView, 'architecture');
133
+ ).data;
134
+ const origin = defaultOriginFor(product, 'architecture');
135
135
 
136
136
  // The three reads are independent; each answers for itself.
137
137
  const [architecture, instructions, policies] = await Promise.allSettled([
@@ -146,10 +146,7 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
146
146
 
147
147
  const architectureText =
148
148
  architecture.status === 'fulfilled'
149
- ? fenced(
150
- 'markdown',
151
- renderDocument(stripProvenance(architecture.value.data as Record<string, unknown>)).text
152
- )
149
+ ? fenced('markdown', renderDocument(stripProvenance(architecture.value.data)).text)
153
150
  : isNotFound(architecture.reason)
154
151
  ? `(No published ${origin} architecture; taiga_list_product_documents shows what exists.)`
155
152
  : unreadable('The architecture', architecture.reason);
@@ -170,14 +167,11 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
170
167
  policies.status === 'fulfilled'
171
168
  ? fenced(
172
169
  'text',
173
- (policies.value.data as Record<string, unknown>[])
170
+ policies.value.data
174
171
  .filter(
175
172
  (row) => (POLICY_TYPES as readonly string[]).includes(String(row.type)) && row.isDraft === false
176
173
  )
177
- .map(
178
- (row) =>
179
- `- ${String(row.type)}: ${Object.keys(sectionsOf((row.data as Record<string, unknown>) ?? {}) ?? {}).join(', ')}`
180
- )
174
+ .map((row) => `- ${String(row.type)}: ${Object.keys(sectionsOf(row.data ?? {}) ?? {}).join(', ')}`)
181
175
  .join('\n') || '(No policy is published.)'
182
176
  )
183
177
  : policies.reason instanceof NotGranted