@taiga-ai/mcp-server 0.16.0-rc.431 → 0.17.0-dev.439

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.
@@ -2,26 +2,33 @@ import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { TaigaApiError, type TaigaClient } from '../client.js';
4
4
  import { READ_ONLY } from './annotations.js';
5
- import { pagination, productId, remoteUrl, slug } from './inputs.js';
5
+ import { idInput, pagination, productId, remoteUrl, slug } from './inputs.js';
6
6
  import {
7
7
  ORIGIN_MEANING,
8
8
  PRODUCT_DOCUMENT_ORIGINS,
9
9
  PRODUCT_DOCUMENT_TYPES,
10
10
  PRODUCT_STATUSES,
11
+ DISCOVERY_STEP_MEANING,
11
12
  defaultOriginFor,
12
13
  emptyOriginNote,
13
14
  isImportedProduct,
14
15
  productDocumentName,
15
16
  type ProductDocumentOrigin,
17
+ setupNextStep,
16
18
  type ProductSettingsView,
17
19
  } from './catalogue.js';
18
20
  import { apiFailureCode, apiFailureText, isMissOrRefusal, runTool, toolError, toolResult } from './errors.js';
19
21
  import { parseRemote } from './remote.js';
20
- import { pickSections, stripProvenance } from './sections.js';
21
- import { page, pickFields } from './summarize.js';
22
+ import { sectionReadResult, stripProvenance } from './sections.js';
23
+ import { SEARCH_NOTE, SEARCH_PAGE_SIZE, SEARCH_WITH_CURSOR, page, pickFields, searchPages } from './summarize.js';
22
24
 
23
25
  /** What a product row needs to say for the model to pick one and read it in full. */
24
- export const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'kind', 'discoveryStep'] as const;
26
+ // Not `kind`: it is parked behind a flag that is on nowhere, so every row said null.
27
+ export const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'discoveryStep'] as const;
28
+
29
+ /** A list's name search: part of a name, any case. */
30
+ const query = (what: string) =>
31
+ z.string().trim().min(1).max(200).optional().describe(`Part of the ${what}, any case: only the rows that contain it`);
25
32
 
26
33
  /**
27
34
  * What a document row needs to say for the model to pick one and read it in
@@ -217,7 +224,7 @@ export async function resolveProductRef(
217
224
  if (given !== 1) {
218
225
  const how =
219
226
  given === 0 ? (usage ?? 'taiga_list_products shows what exists.') : 'Two names could mean two products.';
220
- return refusal('VALIDATION_FAILED', `Name the product exactly once: ${accepted.join(' or ')}. ${how}`);
227
+ return refusal('VALIDATION_ERROR', `Name the product exactly once: ${accepted.join(' or ')}. ${how}`);
221
228
  }
222
229
  if (ref.productId) {
223
230
  try {
@@ -277,18 +284,29 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
277
284
  title: 'List factories',
278
285
  description:
279
286
  'List all factories in the organization. A factory is the group that owns a set of products. ' +
280
- 'Use this to find factory IDs for reading factory instructions. ' +
287
+ 'Use this to find factory IDs for reading factory instructions; query finds one by name. ' +
281
288
  "Factory instructions carry the factory's standing guidance: domain context, tech preferences, " +
282
289
  'and the conventions its products follow.',
283
290
  inputSchema: z.object({
291
+ query: query('factory name'),
284
292
  ...pagination,
285
293
  }),
286
294
  annotations: READ_ONLY,
287
295
  },
288
296
  (params) =>
289
297
  runTool(async () => {
290
- const result = await client.listFactories(params);
291
- return toolResult(page(result.data, result.nextCursor));
298
+ if (params.query === undefined) {
299
+ const result = await client.listFactories(params);
300
+ return toolResult(page(result.data, result.nextCursor));
301
+ }
302
+ if (params.cursor !== undefined) return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
303
+ const found = await searchPages(
304
+ (cursor) => client.listFactories({ limit: SEARCH_PAGE_SIZE, cursor }),
305
+ params.query,
306
+ (row) => [row.name, row.slug],
307
+ params.limit ?? 25
308
+ );
309
+ return toolResult({ ...page(found.rows, null), ...(found.more ? { more: SEARCH_NOTE } : {}) });
292
310
  })
293
311
  );
294
312
  }
@@ -299,15 +317,14 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
299
317
  {
300
318
  title: 'List products',
301
319
  description:
302
- 'List products in the organization, as summaries (id, name, slug, status, factory, kind). ' +
303
- 'Use this to find a product by name; inside a git checkout, taiga_resolve_product finds it from ' +
304
- 'the remote URL instead. taiga_get_product reads one in full.',
320
+ 'List products in the organization, as summaries (id, name, slug, status, factory, discoveryStep). ' +
321
+ 'Use this to find a product by name (query); inside a git checkout, taiga_resolve_product finds it ' +
322
+ `from the remote URL instead. taiga_get_product reads one in full. ${DISCOVERY_STEP_MEANING}`,
305
323
  inputSchema: z.object({
306
- factoryId: z
307
- .string()
308
- .uuid()
324
+ query: query('product name or slug'),
325
+ factoryId: idInput('factory', 'taiga_list_factories (query finds one by name) gives it')
309
326
  .optional()
310
- .describe('Filter by factory ID -- get factory IDs from taiga_list_factories'),
327
+ .describe('Only the products of this factory'),
311
328
  status: z
312
329
  .enum(PRODUCT_STATUSES)
313
330
  .optional()
@@ -321,10 +338,30 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
321
338
  },
322
339
  (params) =>
323
340
  runTool(async () => {
324
- const result = await client.listProducts(params);
325
- return toolResult(
326
- page(pickFields(result.data as Record<string, unknown>[], PRODUCT_SUMMARY_FIELDS), result.nextCursor)
341
+ const { query: text, ...filters } = params;
342
+ if (text === undefined) {
343
+ const result = await client.listProducts(filters);
344
+ return toolResult(
345
+ page(pickFields(result.data as Record<string, unknown>[], PRODUCT_SUMMARY_FIELDS), result.nextCursor)
346
+ );
347
+ }
348
+ if (params.cursor !== undefined) return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
349
+ const found = await searchPages(
350
+ (cursor) =>
351
+ client.listProducts({
352
+ factoryId: filters.factoryId,
353
+ status: filters.status,
354
+ limit: SEARCH_PAGE_SIZE,
355
+ cursor,
356
+ }),
357
+ text,
358
+ (row) => [row.name, row.slug],
359
+ params.limit ?? 25
327
360
  );
361
+ return toolResult({
362
+ ...page(pickFields(found.rows, PRODUCT_SUMMARY_FIELDS), null),
363
+ ...(found.more ? { more: SEARCH_NOTE } : {}),
364
+ });
328
365
  })
329
366
  );
330
367
 
@@ -335,8 +372,10 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
335
372
  description:
336
373
  'Get one product in full, by ID or by slug: name, slug, factory, what Taiga owns for it ' +
337
374
  '(application, infrastructure, deployment) and its settings; the list and resolve tools carry ' +
338
- 'summaries. Use taiga_list_product_documents for its specification and design, ' +
339
- 'taiga_list_backlog_items for its work and taiga_list_builds for what the coding agent did.',
375
+ 'summaries; taiga_get_product_context carries this summary with the rest a session starts from. Use ' +
376
+ '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.',
340
379
  inputSchema: z.object({
341
380
  productId: productId.optional(),
342
381
  slug: slug.optional(),
@@ -346,9 +385,9 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
346
385
  (params) =>
347
386
  runTool(async () => {
348
387
  const resolved = await resolveProductRef(client, params, ['productId', 'slug']);
349
- return 'refusal' in resolved
350
- ? toolError(resolved.refusal.code, resolved.refusal.message)
351
- : toolResult(resolved.product);
388
+ 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);
352
391
  })
353
392
  );
354
393
 
@@ -425,7 +464,8 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
425
464
  'threat_model (security risks), technology_decisions. Read reality, when the product has it, to ' +
426
465
  "learn what the code actually does before changing it. Omit origin for the product's own timeline. " +
427
466
  'A large document runs to tens of thousands of characters: name the sections you need (the ids ' +
428
- 'taiga_get_product_context lists) and the read carries only those, listing the rest with their sizes. ' +
467
+ 'taiga_get_product_context lists) and the read carries only those, listing the rest with their sizes ' +
468
+ 'and any id the document lacks (unknownSections). ' +
429
469
  'A read that finds nothing says which documents and timelines the product does have.',
430
470
  inputSchema: z.object({
431
471
  productId,
@@ -453,22 +493,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
453
493
  const result = await client.getProductDocument(params.productId, params.type, origin);
454
494
  const doc = stripProvenance(result.data as Record<string, unknown>);
455
495
  if (!params.sections) return toolResult(doc);
456
- const cut = pickSections(doc, new Set(params.sections));
457
- if (!cut) {
458
- return toolError(
459
- 'VALIDATION_FAILED',
460
- `The ${origin} ${params.type} is not made of sections; read it without sections.`
461
- );
462
- }
463
- const all = Object.keys((doc.data as { sections: Record<string, unknown> }).sections);
464
- const unknown = params.sections.filter((id) => !all.includes(id));
465
- if (unknown.length > 0) {
466
- return toolError(
467
- 'NOT_FOUND',
468
- `The ${origin} ${params.type} has no section ${unknown.join(', ')}; its sections are ${all.join(', ')}.`
469
- );
470
- }
471
- return toolResult({ ...cut.doc, omittedSections: cut.omittedSections });
496
+ return sectionReadResult(doc, params.sections, `${origin} ${params.type}`);
472
497
  } catch (error) {
473
498
  if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
474
499
  // A product the key cannot see is a product miss, not a document
@@ -4,7 +4,7 @@ import { TaigaApiError, type TaigaClient } from '../client.js';
4
4
  import { POLICY_TYPES, defaultOriginFor, type ProductSettingsView } from './catalogue.js';
5
5
  import { productContext } from './context.js';
6
6
  import { apiFailureCode, apiFailureText, isMissOrRefusal, isNetworkFailure, networkFailureDetail } from './errors.js';
7
- import { productId as idInput, remoteUrl, slug } from './inputs.js';
7
+ import { idInput, productId, remoteUrl, slug } from './inputs.js';
8
8
  import { fence as fenced, renderDocument } from './markdown.js';
9
9
  import { resolveProductRef } from './products.js';
10
10
  import { instructionsText, sectionsOf, stripProvenance } from './sections.js';
@@ -72,7 +72,7 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
72
72
  // first one, and the slug is the name a person knows from the URL.
73
73
  argsSchema: z.object({
74
74
  slug: slug.optional(),
75
- productId: idInput.optional(),
75
+ productId: productId.optional(),
76
76
  remoteUrl: remoteUrl.optional(),
77
77
  }),
78
78
  },
@@ -116,7 +116,9 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
116
116
  'Implement one initiative: its description and acceptance criteria, the architecture of the product ' +
117
117
  "from its own timeline, the factory's instructions and the policy sections to check against. " +
118
118
  'taiga_list_backlog_items gives the item ID.',
119
- argsSchema: z.object({ itemId: idInput.describe('The backlog item ID') }),
119
+ argsSchema: z.object({
120
+ itemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it'),
121
+ }),
120
122
  },
121
123
  async (args) => {
122
124
  const item = (
@@ -3,8 +3,8 @@ import { z } from 'zod';
3
3
  import type { TaigaClient } from '../client.js';
4
4
  import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
5
5
  import { runTool, toolError, toolResult } from './errors.js';
6
- import { pagination, productId } from './inputs.js';
7
- import { page, pickFields } from './summarize.js';
6
+ import { idInput, pagination, productId } from './inputs.js';
7
+ import { SEARCH_WITH_CURSOR, page, pickFields } from './summarize.js';
8
8
 
9
9
  /**
10
10
  * A product's repository (docs.tai.ga, Administration: Integrations and
@@ -49,10 +49,10 @@ const GITHUB_REPOSITORY_FIELDS = [
49
49
  'language',
50
50
  ] as const;
51
51
 
52
- const repositoryId = z
53
- .string()
54
- .uuid()
55
- .describe("The product's repository link ID -- taiga_list_product_repositories gives it");
52
+ const repositoryId = idInput(
53
+ 'repository link',
54
+ 'taiga_list_product_repositories gives it; a GitHub repository id is not one'
55
+ );
56
56
 
57
57
  export function registerRepositoryTools(server: McpServer, client: TaigaClient) {
58
58
  if (client.hasCapability('products:read')) {
@@ -104,7 +104,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
104
104
  'in the web, by someone who can install the app.',
105
105
  inputSchema: z.object({
106
106
  query: z.string().trim().min(1).max(256).optional().describe('Part of the repository name'),
107
- installationId: z.string().uuid().optional().describe('One GitHub account -- this tool lists them'),
107
+ installationId: idInput('GitHub account', 'this tool lists them').optional(),
108
108
  ...pagination,
109
109
  }),
110
110
  annotations: READ_ONLY,
@@ -128,10 +128,10 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
128
128
  // A cursor belongs to one account's pages, and a search answers one
129
129
  // ranked page: either way a cursor would quietly start over.
130
130
  if (params.cursor !== undefined && params.query !== undefined) {
131
- return toolError('VALIDATION_FAILED', 'A search answers one page; narrow the query instead of paging it.');
131
+ return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
132
132
  }
133
133
  if (chosen.length > 1 && params.cursor !== undefined) {
134
- return toolError('VALIDATION_FAILED', 'A cursor pages one account: name its installationId.', { accounts });
134
+ return toolError('VALIDATION_ERROR', 'A cursor pages one account: name its installationId.', { accounts });
135
135
  }
136
136
  // `limit` is the most rows the answer carries, however many accounts
137
137
  // are read: a search answers its ranked matches whatever the page
@@ -185,10 +185,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
185
185
  'sweeps scan it, and its deployment history is imported in the background. Ask the person first.',
186
186
  inputSchema: z.object({
187
187
  productId,
188
- githubRepositoryId: z
189
- .string()
190
- .uuid()
191
- .describe('The repository id -- taiga_list_linkable_repositories gives it'),
188
+ githubRepositoryId: idInput('GitHub repository', 'taiga_list_linkable_repositories gives it'),
192
189
  replacesRepositoryId: repositoryId.optional().describe('The link this one replaces'),
193
190
  }),
194
191
  annotations: OPERATE,
@@ -230,7 +227,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
230
227
  (params) =>
231
228
  runTool(async () => {
232
229
  if (params.baseBranch === undefined && params.label === undefined) {
233
- return toolError('VALIDATION_FAILED', 'Set baseBranch, label or both.');
230
+ return toolError('VALIDATION_ERROR', 'Set baseBranch, label or both.');
234
231
  }
235
232
  const updated = (
236
233
  await client.updateRepository(params.productId, params.repositoryId, {
@@ -11,6 +11,9 @@
11
11
  * surface loses what the other preserved.
12
12
  */
13
13
 
14
+ import type { CallToolResult } from '@modelcontextprotocol/server';
15
+ import { toolError, toolResult } from './errors.js';
16
+
14
17
  /** A section as stored: an object with `content` and metadata, or (older rows) a bare string. */
15
18
  export type Sections = Record<string, unknown>;
16
19
 
@@ -125,3 +128,24 @@ export function budgetSections(doc: Record<string, unknown>, budget: number): Se
125
128
  }
126
129
  return cutTo(doc, sections, Object.fromEntries(kept));
127
130
  }
131
+
132
+ /**
133
+ * The answer to a read that names sections: the ones the document has, the
134
+ * rest listed with their sizes, and the asked-for ids it lacks under
135
+ * unknownSections. One wrong id does not throw away the right ones (the
136
+ * model retries only the miss); none right is a miss that lists the real ids.
137
+ */
138
+ export function sectionReadResult(doc: Record<string, unknown>, ids: readonly string[], label: string): CallToolResult {
139
+ const cut = pickSections(doc, new Set(ids));
140
+ if (!cut) return toolError('VALIDATION_ERROR', `The ${label} is not made of sections; read it without sections.`);
141
+ const all = Object.keys(sectionsOf(doc.data as Record<string, unknown>) ?? {});
142
+ const unknownSections = ids.filter((id) => !all.includes(id));
143
+ if (unknownSections.length === ids.length) {
144
+ return toolError('NOT_FOUND', `The ${label} has no section ${ids.join(', ')}; its sections are ${all.join(', ')}.`);
145
+ }
146
+ return toolResult({
147
+ ...cut.doc,
148
+ omittedSections: cut.omittedSections,
149
+ ...(unknownSections.length > 0 ? { unknownSections } : {}),
150
+ });
151
+ }
@@ -1,11 +1,12 @@
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 type { TaigaClient } from '../client.js';
4
+ import { TaigaApiError, type TaigaClient } from '../client.js';
5
5
  import { OPERATE, OPERATE_SETTING, READ_ONLY, WRITE } from './annotations.js';
6
- import { isImportedProduct, type ProductSettingsView } from './catalogue.js';
7
- import { runTool, toolError, toolResult } from './errors.js';
8
- import { productId } from './inputs.js';
6
+ import { isImportedProduct, setupNextStep, type ProductSettingsView } from './catalogue.js';
7
+ import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
8
+ import { withStop } from './hints.js';
9
+ import { idInput, productId } from './inputs.js';
9
10
  import { pickFields } from './summarize.js';
10
11
 
11
12
  /**
@@ -34,7 +35,7 @@ const PRODUCT_FIELDS = [
34
35
 
35
36
  const FACTORY_FIELDS = ['id', 'name', 'slug', 'isDefault'] as const;
36
37
 
37
- const factoryId = z.string().uuid().describe('Factory ID -- taiga_list_factories gives it');
38
+ const factoryId = idInput('factory', 'taiga_list_factories (query finds one by name) gives it');
38
39
 
39
40
  const buildScope = {
40
41
  ownsInfrastructure: z
@@ -56,10 +57,56 @@ const deploymentNotes = z
56
57
  .max(DEPLOYMENT_NOTES_MAX_LENGTH)
57
58
  .describe('When a half is off: a sentence on how it is done today, which Taiga designs and plans against');
58
59
 
60
+ /** How a missing required document comes to exist, said with a documents_incomplete refusal. */
61
+ const MISSING_DOCUMENTS_HOW =
62
+ 'An imported product gets them from its import (taiga_start_import); a product started from scratch gets the ' +
63
+ 'specification from the Discovery conversation in the web and the rest from taiga_generate_product_documents. ' +
64
+ 'A draft is published with taiga_publish_product_document.';
65
+
59
66
  /** A product row the model can act on, saying how it started as the other product tools say it. */
60
- function productRow(product: Row): Row {
67
+ async function productRow(client: TaigaClient, product: Row): Promise<Row> {
61
68
  const { settings: _settings, ...rest } = pickFields([product], PRODUCT_FIELDS)[0]!;
62
- return { ...rest, projectType: isImportedProduct(product as ProductSettingsView) ? 'brownfield' : 'greenfield' };
69
+ const nextStep = setupNextStep(await withDiscoveryStep(client, product));
70
+ return {
71
+ ...rest,
72
+ projectType: isImportedProduct(product as ProductSettingsView) ? 'brownfield' : 'greenfield',
73
+ ...(nextStep ? { nextStep } : {}),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * The product with its discoveryStep. The API derives it on reads only, not
79
+ * on the answer to a write (products.ts), and a draft started from scratch
80
+ * needs it to say what comes next; an imported one reads its import status
81
+ * instead. Best effort: a failed read leaves the product without a nextStep
82
+ * rather than failing the write that already happened.
83
+ */
84
+ async function withDiscoveryStep(client: TaigaClient, product: Row): Promise<Row> {
85
+ if (product.status !== 'draft' || 'discoveryStep' in product || isImportedProduct(product as ProductSettingsView)) {
86
+ return product;
87
+ }
88
+ try {
89
+ return (await client.getProduct(String(product.id))).data as Row;
90
+ } catch (error) {
91
+ if (error instanceof TaigaApiError || isNetworkFailure(error)) return product;
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * A half turned off with nothing said about how it is done today. Allowed (the
98
+ * web asks for the sentence but does not insist), so a warning rather than a
99
+ * refusal: plans then design against a gap.
100
+ */
101
+ function missingNotesWarning(product: Row): string[] {
102
+ const off = product.ownsInfrastructure === false || product.ownsDeployment === false;
103
+ const notes = typeof product.deploymentNotes === 'string' ? product.deploymentNotes.trim() : '';
104
+ return off && !notes
105
+ ? [
106
+ 'A half Taiga does not build has no deploymentNotes: plans cannot design against how it is done today. ' +
107
+ 'Ask the person for a sentence and set it.',
108
+ ]
109
+ : [];
63
110
  }
64
111
 
65
112
  /**
@@ -75,7 +122,7 @@ function notesWithNothingToDescribe(params: {
75
122
  params.ownsInfrastructure === true &&
76
123
  params.ownsDeployment === true
77
124
  ? toolError(
78
- 'VALIDATION_FAILED',
125
+ 'VALIDATION_ERROR',
79
126
  'deploymentNotes describes a half Taiga does not build; both are on, so leave it out.'
80
127
  )
81
128
  : null;
@@ -165,7 +212,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
165
212
  ...(params.stopLine ? { stopLine: true } : {}),
166
213
  });
167
214
  return toolResult({
168
- product: productRow(moved.data as Row),
215
+ product: await productRow(client, moved.data as Row),
169
216
  changed: moved.changed,
170
217
  previousFactoryId: moved.previousFactoryId,
171
218
  lineStopped: moved.lineStopped,
@@ -214,7 +261,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
214
261
  ...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
215
262
  })
216
263
  ).data as Row;
217
- return toolResult(productRow(product));
264
+ return toolResult(await productRow(client, product));
218
265
  })
219
266
  );
220
267
 
@@ -246,11 +293,13 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
246
293
  runTool(async () => {
247
294
  const { productId: id, ...changes } = params;
248
295
  if (Object.values(changes).every((value) => value === undefined)) {
249
- return toolError('VALIDATION_FAILED', 'Change at least one setting.');
296
+ return toolError('VALIDATION_ERROR', 'Change at least one setting.');
250
297
  }
251
298
  const contradiction = notesWithNothingToDescribe(params);
252
299
  if (contradiction) return contradiction;
253
- return toolResult(productRow((await client.updateProduct(id, changes)).data as Row));
300
+ const updated = (await client.updateProduct(id, changes)).data as Row;
301
+ const warnings = missingNotesWarning(updated);
302
+ return toolResult({ ...(await productRow(client, updated)), ...(warnings.length > 0 ? { warnings } : {}) });
254
303
  })
255
304
  );
256
305
 
@@ -285,7 +334,8 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
285
334
  title: 'Finish Discovery',
286
335
  description:
287
336
  "Finish a product's Discovery. Refused (CONFLICT) with details.reason: documents_incomplete names the " +
288
- 'required documents still unpublished (details.missingDocuments); not_in_setup means it is already ' +
337
+ 'required documents still unpublished (details.missingDocuments) and how they are made; not_in_setup ' +
338
+ 'means it is already ' +
289
339
  'finished; a required check left unstamped (stamped in the web) is refused with its details too. It ' +
290
340
  'locks the document set ' +
291
341
  'and opens the rest of the product; for an ' +
@@ -294,7 +344,21 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
294
344
  inputSchema: z.object({ productId }),
295
345
  annotations: OPERATE,
296
346
  },
297
- (params) => runTool(async () => toolResult(productRow((await client.completeSetup(params.productId)).data as Row)))
347
+ (params) =>
348
+ runTool(async () => {
349
+ try {
350
+ return toolResult(await productRow(client, (await client.completeSetup(params.productId)).data as Row));
351
+ } catch (error) {
352
+ // The refusal names what is missing; it does not say how a missing
353
+ // document comes to exist, which is what the agent asks next.
354
+ if (!(error instanceof TaigaApiError) || error.details?.reason !== 'documents_incomplete') throw error;
355
+ return toolError(
356
+ error.code ?? 'CONFLICT',
357
+ `${withStop(error.apiMessage ?? 'Required documents are not published yet')} ${MISSING_DOCUMENTS_HOW}`,
358
+ error.details
359
+ );
360
+ }
361
+ })
298
362
  );
299
363
 
300
364
  server.registerTool(
@@ -307,7 +371,10 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
307
371
  inputSchema: z.object({ productId }),
308
372
  annotations: OPERATE,
309
373
  },
310
- (params) => runTool(async () => toolResult(productRow((await client.reopenSetup(params.productId)).data as Row)))
374
+ (params) =>
375
+ runTool(async () =>
376
+ toolResult(await productRow(client, (await client.reopenSetup(params.productId)).data as Row))
377
+ )
311
378
  );
312
379
 
313
380
  if (!client.hasCapability('products:read')) return;
@@ -43,3 +43,47 @@ export function itemRows(items: Record<string, unknown>[], fields: readonly stri
43
43
  'acceptanceCriteria' in items[index]! ? { ...row, criteria: criteriaCounts(items[index]!) } : row
44
44
  );
45
45
  }
46
+
47
+ /** Pages a name search reads before it stops and says so: 1,000 rows at the API's page ceiling. */
48
+ const SEARCH_PAGE_LIMIT = 10;
49
+
50
+ /**
51
+ * Rows whose text contains `query`, ignoring case, read page by page until
52
+ * `limit` match or the rows run out. The API has no name search, and a model
53
+ * told the person's word for a thing ("the Sandbox factory") otherwise pages
54
+ * the whole list to find its ID. `more` is true when it stopped before the end.
55
+ */
56
+ export async function searchPages(
57
+ fetchPage: (cursor: string | undefined) => Promise<{ data: unknown[]; nextCursor: string | null }>,
58
+ query: string,
59
+ text: (row: Record<string, unknown>) => unknown[],
60
+ limit: number
61
+ ): Promise<{ rows: Record<string, unknown>[]; more: boolean }> {
62
+ const needle = query.toLowerCase();
63
+ const rows: Record<string, unknown>[] = [];
64
+ let cursor: string | undefined;
65
+ for (let index = 0; index < SEARCH_PAGE_LIMIT; index += 1) {
66
+ const result = await fetchPage(cursor);
67
+ const page = result.data as Record<string, unknown>[];
68
+ for (const [at, row] of page.entries()) {
69
+ if (!text(row).some((value) => typeof value === 'string' && value.toLowerCase().includes(needle))) continue;
70
+ rows.push(row);
71
+ // Full: stop reading. \`more\` then means "there may be more", not
72
+ // that a further match was seen; reading on to find out cost up to ten
73
+ // sequential pages for one bit.
74
+ if (rows.length === limit) return { rows, more: at < page.length - 1 || result.nextCursor !== null };
75
+ }
76
+ if (!result.nextCursor) return { rows, more: false };
77
+ cursor = result.nextCursor;
78
+ }
79
+ return { rows, more: true };
80
+ }
81
+
82
+ /** The page size a search reads at: the API's ceiling. */
83
+ export const SEARCH_PAGE_SIZE = 100;
84
+
85
+ /** A search reads its own pages, so a cursor from a plain page has nothing to continue. */
86
+ export const SEARCH_WITH_CURSOR = 'A search answers one page; narrow the query instead of paging it.';
87
+
88
+ /** What a search says when it stopped before reading every row. */
89
+ export const SEARCH_NOTE = 'There may be more matches than the limit carries; narrow the query or raise the limit.';
package/src/tools/wait.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { BUILD_RUN_TERMINAL_STATUSES, resolveAutoBuild } from '@taiga/types';
3
3
  import { z } from 'zod';
4
+ import { idInput } from './inputs.js';
4
5
  import { TaigaApiError, type TaigaClient } from '../client.js';
5
6
  import { READ_ONLY } from './annotations.js';
6
7
  import { isNetworkFailure, runTool, toolResult } from './errors.js';
@@ -356,7 +357,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
356
357
  'settled answers run_settled at once; item is null when not even the first read answered in time. ' +
357
358
  'Planning takes minutes and a build up to an hour.',
358
359
  inputSchema: z.object({
359
- itemId: z.string().uuid().describe('Backlog item ID -- get from taiga_list_backlog_items'),
360
+ itemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it'),
360
361
  until: z.enum(ITEM_CONDITIONS).describe('What to wait for'),
361
362
  timeoutSeconds,
362
363
  }),
@@ -417,7 +418,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
417
418
  'call again to keep waiting. Conditions are states: a run already settled answers settled at once; run ' +
418
419
  'is null when not even the first read answered in time.',
419
420
  inputSchema: z.object({
420
- buildId: z.string().uuid().describe('Build run ID -- get from taiga_list_builds'),
421
+ buildId: idInput('build run', 'taiga_list_builds gives it'),
421
422
  until: z.enum(BUILD_CONDITIONS).describe('What to wait for'),
422
423
  timeoutSeconds,
423
424
  }),
@@ -457,7 +458,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
457
458
  'as it settles (reached: true), or at the timeout with reached: false: call again to keep waiting. Most ' +
458
459
  'jobs take a few minutes.',
459
460
  inputSchema: z.object({
460
- jobId: z.string().uuid().describe('Agent job ID -- a request tool returns it'),
461
+ jobId: idInput('job', 'the tool that started the job returned it'),
461
462
  timeoutSeconds,
462
463
  }),
463
464
  annotations: READ_ONLY,