@taiga-ai/mcp-server 0.25.0-rc.474 → 0.26.0-dev.481

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.
Files changed (55) hide show
  1. package/README.md +111 -0
  2. package/dist/index.js +292 -115
  3. package/package.json +15 -2
  4. package/scripts/build.mjs +0 -37
  5. package/scripts/prepare-publish.cjs +0 -49
  6. package/src/client.test.ts +0 -424
  7. package/src/client.ts +0 -977
  8. package/src/config.test.ts +0 -24
  9. package/src/config.ts +0 -28
  10. package/src/index.ts +0 -45
  11. package/src/modern.integration.test.ts +0 -153
  12. package/src/server.integration.test.ts +0 -9214
  13. package/src/server.ts +0 -18
  14. package/src/tools/annotations.ts +0 -38
  15. package/src/tools/autonomy.ts +0 -86
  16. package/src/tools/backlogs.ts +0 -802
  17. package/src/tools/builds.ts +0 -668
  18. package/src/tools/catalogue.test.ts +0 -170
  19. package/src/tools/catalogue.ts +0 -223
  20. package/src/tools/context.ts +0 -513
  21. package/src/tools/documents.ts +0 -375
  22. package/src/tools/errors.ts +0 -172
  23. package/src/tools/hints.ts +0 -99
  24. package/src/tools/inbox.ts +0 -546
  25. package/src/tools/index.ts +0 -239
  26. package/src/tools/inputs.ts +0 -47
  27. package/src/tools/instructions.ts +0 -41
  28. package/src/tools/jobs.ts +0 -90
  29. package/src/tools/knowledge.ts +0 -249
  30. package/src/tools/maintenance.ts +0 -547
  31. package/src/tools/markdown.test.ts +0 -249
  32. package/src/tools/markdown.ts +0 -151
  33. package/src/tools/observe.ts +0 -311
  34. package/src/tools/product-documents.ts +0 -184
  35. package/src/tools/products.ts +0 -529
  36. package/src/tools/prompts.ts +0 -265
  37. package/src/tools/queue.ts +0 -250
  38. package/src/tools/readiness.ts +0 -177
  39. package/src/tools/remote.test.ts +0 -44
  40. package/src/tools/remote.ts +0 -107
  41. package/src/tools/repositories.ts +0 -360
  42. package/src/tools/request-prompt.test.ts +0 -32
  43. package/src/tools/request-prompt.ts +0 -28
  44. package/src/tools/resources.ts +0 -217
  45. package/src/tools/sections.test.ts +0 -143
  46. package/src/tools/sections.ts +0 -287
  47. package/src/tools/setup.ts +0 -455
  48. package/src/tools/summarize.test.ts +0 -20
  49. package/src/tools/summarize.ts +0 -96
  50. package/src/tools/wait.ts +0 -490
  51. package/src/version.test.ts +0 -10
  52. package/src/version.ts +0 -14
  53. package/taiga-ai-mcp-server-0.25.0-rc.474.tgz +0 -0
  54. package/tsconfig.json +0 -21
  55. package/vitest.config.ts +0 -9
@@ -1,529 +0,0 @@
1
- import type { McpServer } from '@modelcontextprotocol/server';
2
- import { z } from 'zod';
3
- import type { Product, ProductDocumentListItem } from '@taiga/types';
4
- import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
5
- import { READ_ONLY } from './annotations.js';
6
- import { idInput, pagination, productId, remoteUrl, slug } from './inputs.js';
7
- import {
8
- ORIGIN_MEANING,
9
- PRODUCT_DOCUMENT_ORIGINS,
10
- PRODUCT_DOCUMENT_TYPES,
11
- PRODUCT_STATUSES,
12
- DISCOVERY_STEP_MEANING,
13
- defaultOriginFor,
14
- emptyOriginNote,
15
- isImportedProduct,
16
- productDocumentName,
17
- type ProductDocumentOrigin,
18
- type ProductSettingsView,
19
- } from './catalogue.js';
20
- import {
21
- apiFailureCode,
22
- apiFailureText,
23
- isMissOrRefusal,
24
- isNetworkFailure,
25
- runTool,
26
- toolError,
27
- toolResult,
28
- } from './errors.js';
29
- import { parseRemote } from './remote.js';
30
- import { sectionReadResult, sectionsInput, stripProvenance } from './sections.js';
31
- import { setupReadiness } from './readiness.js';
32
- import { SEARCH_NOTE, SEARCH_PAGE_SIZE, SEARCH_WITH_CURSOR, page, pickFields, searchPages } from './summarize.js';
33
-
34
- type ProductRow = WithoutTenant<Product>;
35
- type DocumentRow = WithoutTenant<ProductDocumentListItem>;
36
-
37
- /** What a product row needs to say for the model to pick one and read it in full. */
38
- // Not `kind`: it is parked behind a flag that is on nowhere, so every row said null.
39
- export const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'discoveryStep'] as const;
40
-
41
- /**
42
- * Product rows with their factory's name beside its id (PRO-2800): "what
43
- * products does the organization have" needed a second list to name the
44
- * factories. One factories read; without factories:read, or when it fails,
45
- * the rows keep the id alone.
46
- */
47
- async function withFactoryNames<T extends { factoryId?: unknown }>(client: TaigaClient, rows: T[]) {
48
- if (!client.hasCapability('factories:read') || rows.length === 0) return rows;
49
- try {
50
- // Every page until the rows' factories are named, up to a bound.
51
- const wanted = new Set(rows.map((row) => String(row.factoryId)));
52
- const names = new Map<string, string>();
53
- let cursor: string | undefined;
54
- for (let pageIndex = 0; pageIndex < FACTORY_PAGES; pageIndex += 1) {
55
- const result = await client.listFactories({ limit: 100, cursor });
56
- for (const factory of result.data) names.set(factory.id, factory.name);
57
- if (!result.nextCursor || [...wanted].every((id) => names.has(id))) break;
58
- cursor = result.nextCursor;
59
- }
60
- return rows.map((row) => {
61
- const name = names.get(String(row.factoryId));
62
- return name === undefined ? row : { ...row, factoryName: name };
63
- });
64
- } catch (error) {
65
- if (error instanceof TaigaApiError || isNetworkFailure(error)) return rows;
66
- throw error;
67
- }
68
- }
69
-
70
- /** Factory pages read to name products' factories. */
71
- const FACTORY_PAGES = 10;
72
-
73
- /** A list's name search: part of a name, any case. */
74
- const query = (what: string) =>
75
- z.string().trim().min(1).max(200).optional().describe(`Part of the ${what}, any case: only the rows that contain it`);
76
-
77
- /**
78
- * What a document row needs to say for the model to pick one and read it in
79
- * full: which document, on which timeline, how current. Not the derived-from
80
- * tree, the data key count or the reviewer's name.
81
- */
82
- const DOCUMENT_SUMMARY_FIELDS = [
83
- 'type',
84
- 'origin',
85
- 'version',
86
- 'isDraft',
87
- 'hasDraft',
88
- 'stage',
89
- 'reviewedAt',
90
- 'updatedAt',
91
- ] as const;
92
-
93
- /** The API's page ceiling (paginationSchema in apps/api). */
94
- const PAGE_SIZE = 100;
95
-
96
- /**
97
- * Pages the resolver follows before it stops and says so. A repository is
98
- * linked to one product, or a handful where sharing is allowed; a thousand
99
- * matches is not a resolution the model can act on anyway.
100
- */
101
- const RESOLVE_PAGE_LIMIT = 10;
102
-
103
- const originInput = z
104
- .enum(PRODUCT_DOCUMENT_ORIGINS)
105
- .optional()
106
- .describe(
107
- `Which timeline. ${ORIGIN_MEANING.plan} ${ORIGIN_MEANING.reality} ` +
108
- "Omit for the product's own timeline: reality if imported, else plan; always plan for the documents a " +
109
- 'person writes (Look & Feel, Product Instructions).'
110
- );
111
-
112
- /**
113
- * Every product linked to the repository, page by page, until the API runs
114
- * out or the cap is hit, in which case the list says it is truncated. A page
115
- * that FAILS is different: it fails the call, like every other tool, rather
116
- * than being folded into a truncated list that would read as a complete one.
117
- */
118
- async function linkedProducts(client: TaigaClient, repository: string, repositoryHost: string) {
119
- const products: ProductRow[] = [];
120
- let cursor: string | undefined;
121
- for (let page = 0; page < RESOLVE_PAGE_LIMIT; page += 1) {
122
- const result = await client.listProducts({ repository, repositoryHost, limit: PAGE_SIZE, cursor });
123
- products.push(...result.data);
124
- if (!result.nextCursor) return { products, truncated: false };
125
- cursor = result.nextCursor;
126
- }
127
- return { products, truncated: true };
128
- }
129
-
130
- /** A document list row as the model sees it: the summary fields plus a display name and the staleness fact. */
131
- function documentSummaries(rows: DocumentRow[]) {
132
- return pickFields(rows, DOCUMENT_SUMMARY_FIELDS).map((picked, index) => {
133
- // `outdated` on the wire is the list of sources that moved on; the model
134
- // needs the fact, not the tree. Published plan rows carry it: null when
135
- // current, a source list when not. A draft has nothing published to be
136
- // outdated (the API stamps it null too) and reality rows carry no such
137
- // field: no fact for either.
138
- const outdated = rows[index]!.outdated;
139
- const fact =
140
- picked.isDraft !== false
141
- ? undefined
142
- : outdated === null
143
- ? false
144
- : Array.isArray(outdated?.sources)
145
- ? outdated.sources.length > 0
146
- : undefined;
147
- return {
148
- type: picked.type,
149
- name: productDocumentName(String(picked.type)),
150
- ...picked,
151
- ...(fact === undefined ? {} : { outdated: fact }),
152
- };
153
- });
154
- }
155
-
156
- type DocumentSummary = ReturnType<typeof documentSummaries>[number];
157
-
158
- interface Timelines {
159
- projectType: 'greenfield' | 'brownfield';
160
- defaultOrigin: ProductDocumentOrigin;
161
- /** The side's summaries, or null when the API could not list it (see `unlistable`). */
162
- plan: DocumentSummary[] | null;
163
- reality: DocumentSummary[] | null;
164
- /** Why a null side is null, in the words the notes and a miss message use. */
165
- unlistable: Partial<Record<ProductDocumentOrigin, string>>;
166
- notes: string[];
167
- }
168
-
169
- /** The one sentence for a side the API would not list, with what it said. */
170
- function unlistableNote(origin: ProductDocumentOrigin, reason: unknown): string {
171
- const said = reason instanceof TaigaApiError ? `${reason.status} ${reason.code ?? ''}`.trim() : 'the request failed';
172
- return `The ${origin} side could not be listed (${said}).`;
173
- }
174
-
175
- /**
176
- * Both timelines of a product's documents, each a list of summaries, with
177
- * which one is the product's own and a note for an empty side. The product
178
- * decides what an empty side means (imported or not, how far its import got),
179
- * so a caller that already holds it passes it in.
180
- */
181
- async function documentTimelines(client: TaigaClient, id: string, product?: ProductSettingsView): Promise<Timelines> {
182
- const loaded = product ?? (await client.getProduct(id)).data;
183
- // Each side on its own: one side the API cannot list is reported as
184
- // unreadable, not as empty, and never hides the side that did answer.
185
- // Both failing is the API failing, and that is an error like any other.
186
- const settled = await Promise.allSettled(
187
- PRODUCT_DOCUMENT_ORIGINS.map((o) => client.listProductDocuments(id, { origin: o, limit: PAGE_SIZE }))
188
- );
189
- if (settled.every((result) => result.status === 'rejected')) throw settled[0]!.reason;
190
- const sides: Partial<Record<ProductDocumentOrigin, DocumentSummary[] | null>> = {};
191
- const unlistable: Partial<Record<ProductDocumentOrigin, string>> = {};
192
- PRODUCT_DOCUMENT_ORIGINS.forEach((o, index) => {
193
- const result = settled[index]!;
194
- if (result.status === 'fulfilled') sides[o] = documentSummaries(result.value.data);
195
- else {
196
- sides[o] = null;
197
- unlistable[o] = unlistableNote(o, result.reason);
198
- }
199
- });
200
- const notes = PRODUCT_DOCUMENT_ORIGINS.flatMap((o) => {
201
- const side = sides[o];
202
- if (!side) return [unlistable[o]!];
203
- return side.length === 0 ? [emptyOriginNote(o, loaded)] : [];
204
- });
205
- return {
206
- projectType: isImportedProduct(loaded) ? 'brownfield' : 'greenfield',
207
- defaultOrigin: defaultOriginFor(loaded),
208
- plan: sides.plan ?? null,
209
- reality: sides.reality ?? null,
210
- unlistable,
211
- notes,
212
- };
213
- }
214
-
215
- const isPublished = (row: { isDraft?: boolean }) => row.isDraft === false;
216
-
217
- /** `specification (v2), architecture (draft)` for one side. */
218
- function sideList(rows: DocumentSummary[]): string {
219
- return rows
220
- .map((row) => `${String(row.type)} (${isPublished(row) ? `v${String(row.version)}` : 'draft'})`)
221
- .join(', ');
222
- }
223
-
224
- /** What a missed document read should say: where the type does live, and what each side holds. */
225
- function describeMiss(
226
- type: string,
227
- wanted: ProductDocumentOrigin,
228
- timelines: Timelines,
229
- product: ProductSettingsView
230
- ): string {
231
- const name = productDocumentName(type);
232
- const other: ProductDocumentOrigin = wanted === 'plan' ? 'reality' : 'plan';
233
- const parts = [`No published ${wanted} ${name} (${type}) for this product.`];
234
- const rows = (side: ProductDocumentOrigin) => timelines[side] ?? [];
235
- if (rows(other).some((row) => row.type === type && isPublished(row))) {
236
- parts.push(`It is published on the ${other} side: call again with origin '${other}'.`);
237
- } else {
238
- for (const side of [wanted, other]) {
239
- if (rows(side).some((row) => row.type === type && !isPublished(row))) {
240
- parts.push(`A draft of it exists on the ${side} side, unpublished.`);
241
- }
242
- }
243
- }
244
- for (const o of PRODUCT_DOCUMENT_ORIGINS) {
245
- const side = timelines[o];
246
- const label = `${o[0]!.toUpperCase()}${o.slice(1)} documents`;
247
- if (side === null) parts.push(timelines.unlistable[o]!);
248
- else parts.push(side.length > 0 ? `${label}: ${sideList(side)}.` : emptyOriginNote(o, product));
249
- }
250
- return parts.join(' ');
251
- }
252
-
253
- /**
254
- * One product from whichever name the caller has: its ID, its slug (through
255
- * the list's slug filter) or the git remote of a checkout (through the
256
- * repository filter). Exactly one of the three; a remote linked to several
257
- * products is ambiguous and says so.
258
- */
259
- export async function resolveProductRef(
260
- client: TaigaClient,
261
- ref: { productId?: string | undefined; slug?: string | undefined; remoteUrl?: string | undefined },
262
- /** The names the calling tool accepts, for the message when none or several were given. */
263
- accepted: readonly string[],
264
- /** How the caller's arguments are passed, for the message when none was given (a slash command's usage). */
265
- usage?: string
266
- ): Promise<{ product: ProductRow } | { refusal: { code: string; message: string } }> {
267
- const refusal = (code: string, message: string) => ({ refusal: { code, message } });
268
- const given = [ref.productId, ref.slug, ref.remoteUrl].filter((v) => v !== undefined).length;
269
- if (given !== 1) {
270
- const how =
271
- given === 0 ? (usage ?? 'taiga_list_products shows what exists.') : 'Two names could mean two products.';
272
- return refusal('VALIDATION_ERROR', `Name the product exactly once: ${accepted.join(' or ')}. ${how}`);
273
- }
274
- if (ref.productId) {
275
- try {
276
- return { product: (await client.getProduct(ref.productId)).data };
277
- } catch (error) {
278
- if (!isMissOrRefusal(error)) throw error;
279
- return refusal(
280
- apiFailureCode(error),
281
- error instanceof TaigaApiError && error.status === 404
282
- ? 'No product with that ID the key can see. taiga_list_products (query finds one by name) shows the ones it can; a slug or git remote URL names one too.'
283
- : `No product with that ID the key can see (${apiFailureText(error)}); taiga_list_products shows the ones it can.`
284
- );
285
- }
286
- }
287
- if (ref.slug) {
288
- const [product] = (await client.listProducts({ slug: ref.slug, limit: 1 })).data;
289
- if (!product) {
290
- return refusal(
291
- 'NOT_FOUND',
292
- `No product you can see has the slug "${ref.slug}". taiga_list_products shows the ones you can.`
293
- );
294
- }
295
- return { product };
296
- }
297
- const remote = parseRemote(ref.remoteUrl!);
298
- if (!remote) {
299
- return refusal(
300
- 'INVALID_REMOTE',
301
- `"${ref.remoteUrl}" is not a remote of the form host/owner/repo. Pass the URL git remote get-url origin prints.`
302
- );
303
- }
304
- // `truncated` is dropped on purpose: past the cap the answer is AMBIGUOUS anyway.
305
- const { products } = await linkedProducts(client, remote.repository, remote.host);
306
- if (products.length === 0) {
307
- return refusal(
308
- 'NOT_FOUND',
309
- `No product you can see is linked to ${remote.repository} on ${remote.host}. ` +
310
- 'Link the repository to its product in Taiga, or find the product with taiga_list_products.'
311
- );
312
- }
313
- if (products.length > 1) {
314
- const names = pickFields(products, PRODUCT_SUMMARY_FIELDS)
315
- .map((p) => `${String(p.name)} (${String(p.id)})`)
316
- .join(', ');
317
- return refusal(
318
- 'AMBIGUOUS',
319
- `${remote.repository} is linked to several products: ${names}. Call again with the productId of the one you mean.`
320
- );
321
- }
322
- // The list route returns full product rows, the same shape the single read does.
323
- return { product: products[0]! };
324
- }
325
-
326
- /**
327
- * NOT_FOUND for a factory ID the key cannot see; null when it exists or the read failed. A key that cannot read
328
- * factories cannot tell an empty factory from a wrong ID, and the answer says so.
329
- */
330
- async function factoryMissing(client: TaigaClient, factoryId: string) {
331
- if (!client.hasCapability('factories:read')) {
332
- return toolResult({
333
- data: [],
334
- nextCursor: null,
335
- note: 'No products here. This key cannot read factories, so whether that factoryId exists was not checked.',
336
- });
337
- }
338
- try {
339
- await client.getFactory(factoryId);
340
- return null;
341
- } catch (error) {
342
- if (error instanceof TaigaApiError && error.status === 404) {
343
- return toolError('NOT_FOUND', 'Factory not found. taiga_list_factories lists the factories the key can see.');
344
- }
345
- if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
346
- throw error;
347
- }
348
- }
349
-
350
- export function registerProductTools(server: McpServer, client: TaigaClient) {
351
- if (client.hasCapability('factories:read')) {
352
- server.registerTool(
353
- 'taiga_list_factories',
354
- {
355
- title: 'List factories',
356
- description:
357
- "List the organization's factories, the groups that own products, with their IDs; query finds one by " +
358
- "name. Read a factory's instructions (domain context, tech preferences, conventions) with " +
359
- 'taiga_get_factory_document.',
360
- inputSchema: z.object({
361
- query: query('factory name'),
362
- ...pagination,
363
- }),
364
- annotations: READ_ONLY,
365
- },
366
- (params) =>
367
- runTool(async () => {
368
- if (params.query === undefined) {
369
- const result = await client.listFactories(params);
370
- return toolResult(page(result.data, result.nextCursor));
371
- }
372
- if (params.cursor !== undefined) return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
373
- const found = await searchPages(
374
- (cursor) => client.listFactories({ limit: SEARCH_PAGE_SIZE, cursor }),
375
- params.query,
376
- (row) => [row.name, row.slug],
377
- params.limit ?? 25
378
- );
379
- return toolResult({ ...page(found.rows, null), ...(found.more ? { more: SEARCH_NOTE } : {}) });
380
- })
381
- );
382
- }
383
-
384
- if (client.hasCapability('products:read')) {
385
- server.registerTool(
386
- 'taiga_list_products',
387
- {
388
- title: 'List products',
389
- description:
390
- "List the organization's products as summaries (id, name, slug, status, factoryId, factoryName, " +
391
- 'discoveryStep); query finds one by name. Inside a git checkout, taiga_get_product_context takes the ' +
392
- `remote URL instead. ${DISCOVERY_STEP_MEANING}`,
393
- inputSchema: z.object({
394
- query: query('product name or slug'),
395
- factoryId: idInput('factory', 'taiga_list_factories (query finds one by name) gives it')
396
- .optional()
397
- .describe('Only the products of this factory'),
398
- status: z
399
- .enum(PRODUCT_STATUSES)
400
- .optional()
401
- .describe(
402
- 'Filter by status: draft until the product finishes setup in Taiga, active after that, ' +
403
- 'archived when retired.'
404
- ),
405
- ...pagination,
406
- }),
407
- annotations: READ_ONLY,
408
- },
409
- (params) =>
410
- runTool(async () => {
411
- const { query: text, ...filters } = params;
412
- if (text === undefined) {
413
- const result = await client.listProducts(filters);
414
- // A factory that does not exist is a miss, as everywhere else, not an empty factory (PRO-2800).
415
- if (result.data.length === 0 && filters.factoryId && !filters.cursor) {
416
- const missing = await factoryMissing(client, filters.factoryId);
417
- if (missing) return missing;
418
- }
419
- return toolResult(
420
- page(await withFactoryNames(client, pickFields(result.data, PRODUCT_SUMMARY_FIELDS)), result.nextCursor)
421
- );
422
- }
423
- if (params.cursor !== undefined) return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
424
- const found = await searchPages(
425
- (cursor) =>
426
- client.listProducts({
427
- factoryId: filters.factoryId,
428
- status: filters.status,
429
- limit: SEARCH_PAGE_SIZE,
430
- cursor,
431
- }),
432
- text,
433
- (row) => [row.name, row.slug],
434
- params.limit ?? 25
435
- );
436
- if (found.rows.length === 0 && filters.factoryId) {
437
- const missing = await factoryMissing(client, filters.factoryId);
438
- if (missing) return missing;
439
- }
440
- return toolResult({
441
- ...page(await withFactoryNames(client, pickFields(found.rows, PRODUCT_SUMMARY_FIELDS)), null),
442
- ...(found.more ? { more: SEARCH_NOTE } : {}),
443
- });
444
- })
445
- );
446
-
447
- server.registerTool(
448
- 'taiga_get_product',
449
- {
450
- title: 'Get product',
451
- description:
452
- 'Read one product and its settings by ID, slug or git remote URL (git remote get-url origin; a ' +
453
- 'repository linked to several products is refused with their IDs): name, factory, what Taiga owns ' +
454
- '(application, infrastructure, deployment). In setup, readiness says whether it can finish now and what ' +
455
- 'is missing (repository, import, unpublished documents, unstamped checks), plus nextStep. For ' +
456
- 'initiatives, runs and findings, use taiga_get_product_context.',
457
- inputSchema: z.object({
458
- productId: productId.optional(),
459
- slug: slug.optional(),
460
- remoteUrl: remoteUrl.optional(),
461
- }),
462
- annotations: READ_ONLY,
463
- },
464
- (params) =>
465
- runTool(async () => {
466
- const resolved = await resolveProductRef(client, params, ['productId', 'slug', 'remoteUrl']);
467
- if ('refusal' in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
468
- const readiness = await setupReadiness(client, resolved.product);
469
- return toolResult(readiness ? { ...resolved.product, readiness } : resolved.product);
470
- })
471
- );
472
-
473
- server.registerTool(
474
- 'taiga_get_product_document',
475
- {
476
- title: 'Get product document',
477
- description:
478
- 'Read one product document from one timeline, or without type list both timelines (type, version, ' +
479
- "draft and review state; defaultOrigin is the product's own; an empty side says why). plan " +
480
- '(specification, architecture, threat_model, technology_decisions) says what to build; reality says ' +
481
- "what the code does, so read it before changing code. Omit origin for the product's own timeline. " +
482
- 'sections: [] returns the outline (ids, sizes); then name the sections you need (unknownSections names ' +
483
- 'missing ids). A miss says which documents exist.',
484
- inputSchema: z.object({
485
- productId,
486
- type: z
487
- .enum(PRODUCT_DOCUMENT_TYPES)
488
- .optional()
489
- .describe('Document type; omit to list what the product has on both timelines'),
490
- origin: originInput,
491
- sections: sectionsInput,
492
- }),
493
- annotations: READ_ONLY,
494
- },
495
- (params) =>
496
- runTool(async () => {
497
- // Without a type, the list of both timelines (PRO-2800): one tool where a list tool stood beside it.
498
- if (params.type === undefined) {
499
- if (params.sections || params.origin) {
500
- return toolError('VALIDATION_ERROR', 'Name the document (type) to read one timeline or its sections.');
501
- }
502
- const { unlistable: _unlistable, ...timelines } = await documentTimelines(client, params.productId);
503
- return toolResult(timelines);
504
- }
505
- const type = params.type;
506
- // The product's own timeline when none is asked for; the API's
507
- // default is plan, which is the wrong side of an imported product.
508
- let product: ProductSettingsView | undefined;
509
- if (!params.origin) product = (await client.getProduct(params.productId)).data;
510
- const origin = params.origin ?? defaultOriginFor(product, type);
511
- try {
512
- const result = await client.getProductDocument(params.productId, type, origin);
513
- const doc = stripProvenance(result.data);
514
- if (!params.sections) return toolResult(doc);
515
- return sectionReadResult(doc, params.sections, `${origin} ${type}`);
516
- } catch (error) {
517
- if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
518
- // A product the key cannot see is a product miss, not a document
519
- // miss, so the product read propagates. The lists after it are
520
- // best effort per side (documentTimelines); only both failing
521
- // propagates, as the API failing.
522
- product ??= (await client.getProduct(params.productId)).data;
523
- const timelines = await documentTimelines(client, params.productId, product);
524
- return toolError('NOT_FOUND', describeMiss(type, origin, timelines, product));
525
- }
526
- })
527
- );
528
- }
529
- }