@taiga-ai/mcp-server 0.18.0-dev.447 → 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.
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import type { TaigaClient } from '../client.js';
3
+ import type { GithubRepository } from '@taiga/types';
4
+ import type { TaigaClient, WithoutTenant } from '../client.js';
4
5
  import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
5
6
  import { runTool, toolError, toolResult } from './errors.js';
6
7
  import { idInput, pagination, productId } from './inputs.js';
@@ -15,8 +16,6 @@ import { SEARCH_WITH_CURSOR, page, pickFields } from './summarize.js';
15
16
  * repositories it can reach to a product is a separate act, per product.
16
17
  */
17
18
 
18
- type Row = Record<string, unknown>;
19
-
20
19
  const LINK_FIELDS = [
21
20
  'id',
22
21
  'provider',
@@ -81,7 +80,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
81
80
  // The route answers one page of 100 and takes no cursor: past that, the list says it is cut.
82
81
  const result = await client.listProductRepositories(params.productId);
83
82
  return toolResult({
84
- data: pickFields(result.data as Row[], LINK_FIELDS),
83
+ data: pickFields(result.data, LINK_FIELDS),
85
84
  truncated: result.nextCursor !== null,
86
85
  });
87
86
  })
@@ -135,7 +134,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
135
134
  },
136
135
  (params) =>
137
136
  runTool(async () => {
138
- const installations = (await client.listGithubInstallations()).data as Row[];
137
+ const installations = (await client.listGithubInstallations()).data;
139
138
  const accounts = pickFields(installations, INSTALLATION_FIELDS);
140
139
  if (installations.length === 0) {
141
140
  return toolError(
@@ -167,7 +166,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
167
166
  // are read: a search answers its ranked matches whatever the page
168
167
  // size, and several accounts add up.
169
168
  const max = params.limit ?? 25;
170
- const rows = (data: unknown[]) => pickFields(data as Row[], GITHUB_REPOSITORY_FIELDS);
169
+ const rows = (data: WithoutTenant<GithubRepository>[]) => pickFields(data, GITHUB_REPOSITORY_FIELDS);
171
170
  if (chosen.length === 1) {
172
171
  const result = await client.listInstallationRepositories(String(chosen[0]!.id), {
173
172
  q: params.query,
@@ -227,7 +226,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
227
226
  githubRepositoryId: params.githubRepositoryId,
228
227
  ...(params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}),
229
228
  });
230
- return toolResult(pickFields([linked.data as Row], LINK_FIELDS)[0]);
229
+ return toolResult(pickFields([linked.data], LINK_FIELDS)[0]);
231
230
  })
232
231
  );
233
232
 
@@ -265,7 +264,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
265
264
  ...(params.baseBranch !== undefined ? { baseBranch: params.baseBranch } : {}),
266
265
  ...(params.label !== undefined ? { label: params.label } : {}),
267
266
  })
268
- ).data as Row;
267
+ ).data;
269
268
  return toolResult({
270
269
  ...pickFields([updated], LINK_FIELDS)[0],
271
270
  baseBranchProtection: updated.baseBranchProtection ?? null,
@@ -16,7 +16,6 @@ import {
16
16
  productDocumentName,
17
17
  type ProductDocumentOrigin,
18
18
  type ProductDocumentType,
19
- type ProductSettingsView,
20
19
  } from './catalogue.js';
21
20
  import { productId as productIdInput } from './inputs.js';
22
21
  import { mimeTypeOf, renderDocument } from './markdown.js';
@@ -69,9 +68,7 @@ async function served<T>(read: Promise<T>, miss: string): Promise<T> {
69
68
  export function registerResources(server: McpServer, client: TaigaClient) {
70
69
  if (client.hasCapability('tenant:documents:read')) {
71
70
  const published = async () =>
72
- ((await client.listTenantDocuments({ limit: 100 })).data as Record<string, unknown>[]).filter(
73
- (row) => row.isDraft === false
74
- );
71
+ (await client.listTenantDocuments({ limit: 100 })).data.filter((row) => row.isDraft === false);
75
72
 
76
73
  server.registerResource(
77
74
  'organization-document',
@@ -95,7 +92,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
95
92
  'tone of voice or the standing instructions. Sections rendered as headings.',
96
93
  },
97
94
  async (uri, variables) => {
98
- const type = requireOneOf('Document type', String(variables.type), TENANT_DOCUMENT_TYPES);
95
+ const type = requireOneOf('Document type', String(variables['type']), TENANT_DOCUMENT_TYPES);
99
96
  try {
100
97
  return contents(uri.href, (await client.getTenantDocument(type)).data);
101
98
  } catch (error) {
@@ -127,7 +124,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
127
124
  'section map, JSON otherwise (the read says which).',
128
125
  },
129
126
  async (uri, variables) => {
130
- const factoryId = requireId('Factory ID', String(variables.factoryId));
127
+ const factoryId = requireId('Factory ID', String(variables['factoryId']));
131
128
  const doc = await served(
132
129
  client.getFactoryDocument(factoryId, FACTORY_DOCUMENT_TYPES[0]),
133
130
  'This factory has not published instructions, or the key cannot see the factory'
@@ -147,7 +144,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
147
144
  client.getProductDocument(productId, type, origin),
148
145
  `No published ${origin} ${type} for this product, or the key cannot see it; taiga_list_product_documents shows what exists`
149
146
  );
150
- return contents(uri.href, { name: productDocumentName(type), ...(result.data as Record<string, unknown>) });
147
+ return contents(uri.href, { name: productDocumentName(type), ...result.data });
151
148
  };
152
149
 
153
150
  // The timeline template first: the SDK tries templates in registration
@@ -162,9 +159,9 @@ export function registerResources(server: McpServer, client: TaigaClient) {
162
159
  'Markdown for a section map, JSON otherwise (the read says which).',
163
160
  },
164
161
  async (uri, variables) => {
165
- const productId = requireId('Product ID', String(variables.productId));
166
- const type = documentType(String(variables.type));
167
- const origin = requireOneOf('Origin', String(variables.origin), PRODUCT_DOCUMENT_ORIGINS);
162
+ const productId = requireId('Product ID', String(variables['productId']));
163
+ const type = documentType(String(variables['type']));
164
+ const origin = requireOneOf('Origin', String(variables['origin']), PRODUCT_DOCUMENT_ORIGINS);
168
165
  return read(uri, productId, type, origin);
169
166
  }
170
167
  );
@@ -181,14 +178,14 @@ export function registerResources(server: McpServer, client: TaigaClient) {
181
178
  'otherwise (the read says which).',
182
179
  },
183
180
  async (uri, variables) => {
184
- const productId = requireId('Product ID', String(variables.productId));
185
- const type = documentType(String(variables.type));
181
+ const productId = requireId('Product ID', String(variables['productId']));
182
+ const type = documentType(String(variables['type']));
186
183
  const product = (
187
184
  await served(
188
185
  client.getProduct(productId),
189
186
  'No product with that ID the key can see; taiga_list_products shows them'
190
187
  )
191
- ).data as ProductSettingsView;
188
+ ).data;
192
189
  return read(uri, productId, type, defaultOriginFor(product, type));
193
190
  }
194
191
  );
@@ -206,7 +203,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
206
203
  mimeType: 'application/json',
207
204
  },
208
205
  async (uri, variables) => {
209
- const itemId = requireId('Backlog item ID', String(variables.itemId));
206
+ const itemId = requireId('Backlog item ID', String(variables['itemId']));
210
207
  const item = (
211
208
  await served(
212
209
  client.getBacklogItem(itemId),
@@ -50,12 +50,12 @@ describe('applySectionEdit', () => {
50
50
  });
51
51
 
52
52
  it('turns a bare-string or array-valued section into an object with the new text', () => {
53
- expect(applySectionEdit(sections, 'scope', 'new').scope).toEqual({ content: 'new' });
54
- expect(applySectionEdit(sections, 'list', 'new').list).toEqual({ content: 'new' });
53
+ expect(applySectionEdit(sections, 'scope', 'new')['scope']).toEqual({ content: 'new' });
54
+ expect(applySectionEdit(sections, 'list', 'new')['list']).toEqual({ content: 'new' });
55
55
  });
56
56
 
57
57
  it('adds a section that did not exist', () => {
58
- expect(applySectionEdit(sections, 'retention', 'keep 90 days').retention).toEqual({ content: 'keep 90 days' });
58
+ expect(applySectionEdit(sections, 'retention', 'keep 90 days')['retention']).toEqual({ content: 'keep 90 days' });
59
59
  });
60
60
 
61
61
  it('does not mutate its input', () => {
@@ -15,15 +15,42 @@ import type { CallToolResult } from '@modelcontextprotocol/server';
15
15
  import { z } from 'zod';
16
16
  import { toolError, toolResult } from './errors.js';
17
17
 
18
- /** A section as stored: an object with `content` and metadata, or (older rows) a bare string. */
18
+ /** A document's section map, keyed by section id: each value a `StoredSection` or (older rows) a bare string. */
19
19
  export type Sections = Record<string, unknown>;
20
20
 
21
+ /** A section stored as an object: its text and the metadata an edit keeps. */
22
+ export interface StoredSection {
23
+ content?: unknown;
24
+ title?: unknown;
25
+ priority?: unknown;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ /** A document's `data`: a section-shaped document keeps its content under `sections`. */
30
+ export interface DocumentData {
31
+ sections?: unknown;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ /** A document as these helpers read it: its `data` and the provenance a reader does not need. */
36
+ export interface StoredDocument {
37
+ data?: unknown;
38
+ derivedFrom?: unknown;
39
+ outdated?: unknown;
40
+ [key: string]: unknown;
41
+ }
42
+
21
43
  export function isRecord(value: unknown): value is Record<string, unknown> {
22
44
  return typeof value === 'object' && value !== null && !Array.isArray(value);
23
45
  }
24
46
 
47
+ /** A section stored as an object rather than a bare string. */
48
+ export function isSectionObject(value: unknown): value is StoredSection {
49
+ return isRecord(value);
50
+ }
51
+
25
52
  /** The `sections` map of a document, or null when the document is not section-shaped. */
26
- export function sectionsOf(data: Record<string, unknown>): Sections | null {
53
+ export function sectionsOf(data: DocumentData): Sections | null {
27
54
  return isRecord(data.sections) ? data.sections : null;
28
55
  }
29
56
 
@@ -32,7 +59,7 @@ export function sectionContent(sections: Sections, sectionId: string): string |
32
59
  const section = sections[sectionId];
33
60
  if (section === undefined || section === null) return null;
34
61
  if (typeof section === 'string') return section;
35
- return isRecord(section) && typeof section.content === 'string' ? section.content : '';
62
+ return isSectionObject(section) && typeof section.content === 'string' ? section.content : '';
36
63
  }
37
64
 
38
65
  /** The map with one section's text replaced (or the section added); every other section untouched. */
@@ -48,10 +75,12 @@ export function applySectionEdit(sections: Sections, sectionId: string, content:
48
75
  * the editor needs and a reader does not; `source` and `priority` stay, the
49
76
  * server instructions explain them.
50
77
  */
51
- export function stripProvenance(doc: Record<string, unknown>): Record<string, unknown> {
78
+ export function stripProvenance<T extends StoredDocument>(doc: T): Omit<T, 'derivedFrom' | 'outdated'> {
52
79
  const { derivedFrom: _derivedFrom, outdated: _outdated, ...rest } = doc;
53
- if (!isRecord(rest.data)) return rest;
54
- const sections = sectionsOf(rest.data);
80
+ // `rest` holds the same `data`; read through `doc`, whose type still names it.
81
+ const { data } = doc;
82
+ if (!isRecord(data)) return rest;
83
+ const sections = sectionsOf(data);
55
84
  if (!sections) return rest;
56
85
  const stripped = Object.fromEntries(
57
86
  Object.entries(sections).map(([id, section]) => {
@@ -60,7 +89,7 @@ export function stripProvenance(doc: Record<string, unknown>): Record<string, un
60
89
  return [id, keep];
61
90
  })
62
91
  );
63
- return { ...rest, data: { ...rest.data, sections: stripped } };
92
+ return { ...rest, data: { ...data, sections: stripped } };
64
93
  }
65
94
 
66
95
  /**
@@ -73,7 +102,7 @@ export function instructionsText(sections: Sections): string | null {
73
102
  for (const section of Object.values(sections)) {
74
103
  // Object sections only, as the API's extractor reads them: a bare-string
75
104
  // row the agents never receive is not handed to the model here either.
76
- const content = isRecord(section) ? section.content : undefined;
105
+ const content = isSectionObject(section) ? section.content : undefined;
77
106
  if (typeof content === 'string' && content.trim()) parts.push(content.trim());
78
107
  }
79
108
  const text = parts.join('\n\n').trim();
@@ -82,15 +111,15 @@ export function instructionsText(sections: Sections): string | null {
82
111
 
83
112
  /** A document cut to some of its sections, and the ones left out with their sizes. */
84
113
  export interface SectionCut {
85
- doc: Record<string, unknown>;
114
+ doc: StoredDocument;
86
115
  omittedSections: { id: string; characters: number }[];
87
116
  }
88
117
 
89
- function cutTo(doc: Record<string, unknown>, sections: Sections, kept: Sections): SectionCut {
118
+ function cutTo(doc: StoredDocument, sections: Sections, kept: Sections): SectionCut {
90
119
  const omittedSections = Object.keys(sections)
91
120
  .filter((id) => !Object.hasOwn(kept, id))
92
121
  .map((id) => ({ id, characters: sectionContent(sections, id)?.length ?? 0 }));
93
- return { doc: { ...doc, data: { ...(doc.data as Record<string, unknown>), sections: kept } }, omittedSections };
122
+ return { doc: { ...doc, data: { ...(doc.data as DocumentData), sections: kept } }, omittedSections };
94
123
  }
95
124
 
96
125
  /**
@@ -100,7 +129,7 @@ function cutTo(doc: Record<string, unknown>, sections: Sections, kept: Sections)
100
129
  * every other section is listed with its size so the reader knows what else
101
130
  * there is. Null when the document is not made of sections.
102
131
  */
103
- export function pickSections(doc: Record<string, unknown>, ids: ReadonlySet<string>): SectionCut | null {
132
+ export function pickSections(doc: StoredDocument, ids: ReadonlySet<string>): SectionCut | null {
104
133
  const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
105
134
  if (!sections) return null;
106
135
  return cutTo(doc, sections, Object.fromEntries(Object.entries(sections).filter(([id]) => ids.has(id))));
@@ -115,7 +144,7 @@ export function pickSections(doc: Record<string, unknown>, ids: ReadonlySet<stri
115
144
  * and a later, smaller one may still be carried. Null when the document is
116
145
  * not made of sections.
117
146
  */
118
- export function budgetSections(doc: Record<string, unknown>, budget: number): SectionCut | null {
147
+ export function budgetSections(doc: StoredDocument, budget: number): SectionCut | null {
119
148
  const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
120
149
  if (!sections) return null;
121
150
  // Entries, not assignment: a section id is text, and `kept["__proto__"] = x` would set a prototype.
@@ -150,10 +179,10 @@ export const sectionsInput = z
150
179
  * real id with its size, so the retry needs nothing else. No ids is the
151
180
  * outline.
152
181
  */
153
- export function sectionReadResult(doc: Record<string, unknown>, ids: readonly string[], label: string): CallToolResult {
182
+ export function sectionReadResult(doc: StoredDocument, ids: readonly string[], label: string): CallToolResult {
154
183
  const cut = pickSections(doc, new Set(ids));
155
184
  if (!cut) return toolError('VALIDATION_ERROR', `The ${label} is not made of sections; read it without sections.`);
156
- const all = Object.keys(sectionsOf(doc.data as Record<string, unknown>) ?? {});
185
+ const all = Object.keys(sectionsOf(doc.data as DocumentData) ?? {});
157
186
  const unknownSections = ids.filter((id) => !all.includes(id));
158
187
  return toolResult({
159
188
  ...cut.doc,
@@ -1,9 +1,10 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { DEPLOYMENT_NOTES_MAX_LENGTH, REALITY_DERIVED_DOCUMENT_TYPES } from '@taiga/types';
3
3
  import { z } from 'zod';
4
- import { TaigaApiError, type TaigaClient } from '../client.js';
4
+ import type { Product } from '@taiga/types';
5
+ import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
5
6
  import { OPERATE, OPERATE_SETTING, READ_ONLY, WRITE } from './annotations.js';
6
- import { isImportedProduct, setupNextStep, type ProductSettingsView } from './catalogue.js';
7
+ import { isImportedProduct, setupNextStep } from './catalogue.js';
7
8
  import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
8
9
  import { withStop } from './hints.js';
9
10
  import { idInput, productId } from './inputs.js';
@@ -18,8 +19,6 @@ import { pickFields } from './summarize.js';
18
19
  * a product stay in the web.
19
20
  */
20
21
 
21
- type Row = Record<string, unknown>;
22
-
23
22
  const PRODUCT_FIELDS = [
24
23
  'id',
25
24
  'name',
@@ -63,13 +62,15 @@ const MISSING_DOCUMENTS_HOW =
63
62
  'specification from the Discovery conversation in the web and the rest from taiga_generate_product_documents. ' +
64
63
  'A draft is published with taiga_publish_product_document.';
65
64
 
65
+ type ProductRow = WithoutTenant<Product>;
66
+
66
67
  /** A product row the model can act on, saying how it started as the other product tools say it. */
67
- async function productRow(client: TaigaClient, product: Row): Promise<Row> {
68
+ async function productRow(client: TaigaClient, product: ProductRow) {
68
69
  const { settings: _settings, ...rest } = pickFields([product], PRODUCT_FIELDS)[0]!;
69
70
  const nextStep = setupNextStep(await withDiscoveryStep(client, product));
70
71
  return {
71
72
  ...rest,
72
- projectType: isImportedProduct(product as ProductSettingsView) ? 'brownfield' : 'greenfield',
73
+ projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
73
74
  ...(nextStep ? { nextStep } : {}),
74
75
  };
75
76
  }
@@ -81,12 +82,12 @@ async function productRow(client: TaigaClient, product: Row): Promise<Row> {
81
82
  * instead. Best effort: a failed read leaves the product without a nextStep
82
83
  * rather than failing the write that already happened.
83
84
  */
84
- async function withDiscoveryStep(client: TaigaClient, product: Row): Promise<Row> {
85
- if (product.status !== 'draft' || 'discoveryStep' in product || isImportedProduct(product as ProductSettingsView)) {
85
+ async function withDiscoveryStep(client: TaigaClient, product: ProductRow): Promise<ProductRow> {
86
+ if (product.status !== 'draft' || 'discoveryStep' in product || isImportedProduct(product)) {
86
87
  return product;
87
88
  }
88
89
  try {
89
- return (await client.getProduct(String(product.id))).data as Row;
90
+ return (await client.getProduct(String(product.id))).data;
90
91
  } catch (error) {
91
92
  if (error instanceof TaigaApiError || isNetworkFailure(error)) return product;
92
93
  throw error;
@@ -98,7 +99,7 @@ async function withDiscoveryStep(client: TaigaClient, product: Row): Promise<Row
98
99
  * web asks for the sentence but does not insist), so a warning rather than a
99
100
  * refusal: plans then design against a gap.
100
101
  */
101
- function missingNotesWarning(product: Row): string[] {
102
+ function missingNotesWarning(product: ProductRow): string[] {
102
103
  const off = product.ownsInfrastructure === false || product.ownsDeployment === false;
103
104
  const notes = typeof product.deploymentNotes === 'string' ? product.deploymentNotes.trim() : '';
104
105
  return off && !notes
@@ -144,7 +145,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
144
145
  },
145
146
  (params) =>
146
147
  runTool(async () =>
147
- toolResult(pickFields([(await client.createFactory({ name: params.name })).data as Row], FACTORY_FIELDS)[0])
148
+ toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0])
148
149
  )
149
150
  );
150
151
 
@@ -161,10 +162,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
161
162
  (params) =>
162
163
  runTool(async () =>
163
164
  toolResult(
164
- pickFields(
165
- [(await client.updateFactory(params.factoryId, { name: params.name })).data as Row],
166
- FACTORY_FIELDS
167
- )[0]
165
+ pickFields([(await client.updateFactory(params.factoryId, { name: params.name })).data], FACTORY_FIELDS)[0]
168
166
  )
169
167
  )
170
168
  );
@@ -212,7 +210,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
212
210
  ...(params.stopLine ? { stopLine: true } : {}),
213
211
  });
214
212
  return toolResult({
215
- product: await productRow(client, moved.data as Row),
213
+ product: await productRow(client, moved.data),
216
214
  changed: moved.changed,
217
215
  previousFactoryId: moved.previousFactoryId,
218
216
  lineStopped: moved.lineStopped,
@@ -260,7 +258,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
260
258
  ...(params.ownsDeployment !== undefined ? { ownsDeployment: params.ownsDeployment } : {}),
261
259
  ...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
262
260
  })
263
- ).data as Row;
261
+ ).data;
264
262
  return toolResult(await productRow(client, product));
265
263
  })
266
264
  );
@@ -297,7 +295,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
297
295
  }
298
296
  const contradiction = notesWithNothingToDescribe(params);
299
297
  if (contradiction) return contradiction;
300
- const updated = (await client.updateProduct(id, changes)).data as Row;
298
+ const updated = (await client.updateProduct(id, changes)).data;
301
299
  const warnings = missingNotesWarning(updated);
302
300
  return toolResult({ ...(await productRow(client, updated)), ...(warnings.length > 0 ? { warnings } : {}) });
303
301
  })
@@ -347,11 +345,11 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
347
345
  (params) =>
348
346
  runTool(async () => {
349
347
  try {
350
- return toolResult(await productRow(client, (await client.completeSetup(params.productId)).data as Row));
348
+ return toolResult(await productRow(client, (await client.completeSetup(params.productId)).data));
351
349
  } catch (error) {
352
350
  // The refusal names what is missing; it does not say how a missing
353
351
  // document comes to exist, which is what the agent asks next.
354
- if (!(error instanceof TaigaApiError) || error.details?.reason !== 'documents_incomplete') throw error;
352
+ if (!(error instanceof TaigaApiError) || error.details?.['reason'] !== 'documents_incomplete') throw error;
355
353
  return toolError(
356
354
  error.code ?? 'CONFLICT',
357
355
  `${withStop(error.apiMessage ?? 'Required documents are not published yet')} ${MISSING_DOCUMENTS_HOW}`,
@@ -372,9 +370,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
372
370
  annotations: OPERATE,
373
371
  },
374
372
  (params) =>
375
- runTool(async () =>
376
- toolResult(await productRow(client, (await client.reopenSetup(params.productId)).data as Row))
377
- )
373
+ runTool(async () => toolResult(await productRow(client, (await client.reopenSetup(params.productId)).data)))
378
374
  );
379
375
 
380
376
  if (!client.hasCapability('products:read')) return;
@@ -396,8 +392,8 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
396
392
  },
397
393
  (params) =>
398
394
  runTool(async () => {
399
- const product = (await client.getProduct(params.productId)).data as Row;
400
- if (!isImportedProduct(product as ProductSettingsView)) {
395
+ const product = (await client.getProduct(params.productId)).data;
396
+ if (!isImportedProduct(product)) {
401
397
  return toolError(
402
398
  'CONFLICT',
403
399
  'This product was started from scratch, so it has no import: the choice is made when a product is created.'
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { pickFields } from './summarize.js';
3
+
4
+ describe('pickFields', () => {
5
+ const rows: { id: string; name?: string; content?: string }[] = [
6
+ { id: 'a', name: 'A', content: 'the prose a list leaves out' },
7
+ { id: 'b' },
8
+ ];
9
+
10
+ it('keeps the named fields and leaves out one an item does not carry', () => {
11
+ expect(pickFields(rows, ['id', 'name'])).toEqual([{ id: 'a', name: 'A' }, { id: 'b' }]);
12
+ });
13
+
14
+ it('refuses a field the row type does not have', () => {
15
+ // The *_FIELDS lists are checked against their rows, so a misspelled field
16
+ // fails to compile instead of silently picking nothing (tsc covers tests).
17
+ // @ts-expect-error -- 'nmae' is not a field of the row.
18
+ expect(pickFields([{ id: 'a', name: 'A' }], ['id', 'nmae'])).toEqual([{ id: 'a' }]);
19
+ });
20
+ });
@@ -3,12 +3,13 @@
3
3
  * fields a model needs to pick an item) and get tools return everything;
4
4
  * this is how a list drops the prose and the provenance trees.
5
5
  */
6
- export function pickFields<T extends Record<string, unknown>>(
7
- items: T[],
8
- fields: readonly string[]
9
- ): Record<string, unknown>[] {
6
+ export function pickFields<T extends object, K extends keyof T>(
7
+ items: readonly T[],
8
+ fields: readonly K[]
9
+ ): Partial<Pick<T, K>>[] {
10
10
  return items.map((item) => {
11
- const picked: Record<string, unknown> = {};
11
+ // Partial: a field the item does not carry is left out, not set to undefined.
12
+ const picked: Partial<Pick<T, K>> = {};
12
13
  for (const field of fields) {
13
14
  if (field in item) picked[field] = item[field];
14
15
  }
@@ -26,7 +27,7 @@ export function page<T>(data: T[], nextCursor: string | null | undefined): { dat
26
27
  * carry, so "done with two unmet" shows in a list without reading the item.
27
28
  * Null while it has none (an unplanned initiative).
28
29
  */
29
- export function criteriaCounts(item: Record<string, unknown>): { met: number; total: number } | null {
30
+ export function criteriaCounts(item: { acceptanceCriteria?: unknown }): { met: number; total: number } | null {
30
31
  const criteria = item.acceptanceCriteria;
31
32
  if (!Array.isArray(criteria) || criteria.length === 0) return null;
32
33
  const met = criteria.filter((c) => typeof c === 'object' && c !== null && (c as { met?: unknown }).met === true);
@@ -38,7 +39,10 @@ export function criteriaCounts(item: Record<string, unknown>): { met: number; to
38
39
  * does not carry `acceptanceCriteria` at all gets no count rather than a
39
40
  * null that would read as "has none".
40
41
  */
41
- export function itemRows(items: Record<string, unknown>[], fields: readonly string[]): Record<string, unknown>[] {
42
+ export function itemRows<T extends { acceptanceCriteria?: unknown }, K extends keyof T>(
43
+ items: readonly T[],
44
+ fields: readonly K[]
45
+ ): (Partial<Pick<T, K>> & { criteria?: { met: number; total: number } | null })[] {
42
46
  return pickFields(items, fields).map((row, index) =>
43
47
  'acceptanceCriteria' in items[index]! ? { ...row, criteria: criteriaCounts(items[index]!) } : row
44
48
  );
@@ -53,18 +57,18 @@ const SEARCH_PAGE_LIMIT = 10;
53
57
  * told the person's word for a thing ("the Sandbox factory") otherwise pages
54
58
  * the whole list to find its ID. `more` is true when it stopped before the end.
55
59
  */
56
- export async function searchPages(
57
- fetchPage: (cursor: string | undefined) => Promise<{ data: unknown[]; nextCursor: string | null }>,
60
+ export async function searchPages<T>(
61
+ fetchPage: (cursor: string | undefined) => Promise<{ data: T[]; nextCursor: string | null }>,
58
62
  query: string,
59
- text: (row: Record<string, unknown>) => unknown[],
63
+ text: (row: T) => unknown[],
60
64
  limit: number
61
- ): Promise<{ rows: Record<string, unknown>[]; more: boolean }> {
65
+ ): Promise<{ rows: T[]; more: boolean }> {
62
66
  const needle = query.toLowerCase();
63
- const rows: Record<string, unknown>[] = [];
67
+ const rows: T[] = [];
64
68
  let cursor: string | undefined;
65
69
  for (let index = 0; index < SEARCH_PAGE_LIMIT; index += 1) {
66
70
  const result = await fetchPage(cursor);
67
- const page = result.data as Record<string, unknown>[];
71
+ const page = result.data;
68
72
  for (const [at, row] of page.entries()) {
69
73
  if (!text(row).some((value) => typeof value === 'string' && value.toLowerCase().includes(needle))) continue;
70
74
  rows.push(row);