@taiga-ai/mcp-server 0.25.0-rc.472 → 0.26.0-dev.479
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/README.md +111 -0
- package/dist/index.js +322 -122
- package/package.json +15 -2
- package/scripts/build.mjs +0 -37
- package/scripts/prepare-publish.cjs +0 -49
- package/src/client.test.ts +0 -424
- package/src/client.ts +0 -976
- package/src/config.test.ts +0 -24
- package/src/config.ts +0 -28
- package/src/index.ts +0 -45
- package/src/modern.integration.test.ts +0 -153
- package/src/server.integration.test.ts +0 -9075
- package/src/server.ts +0 -18
- package/src/tools/annotations.ts +0 -38
- package/src/tools/autonomy.ts +0 -86
- package/src/tools/backlogs.ts +0 -766
- package/src/tools/builds.ts +0 -668
- package/src/tools/catalogue.test.ts +0 -170
- package/src/tools/catalogue.ts +0 -223
- package/src/tools/context.ts +0 -513
- package/src/tools/documents.ts +0 -375
- package/src/tools/errors.ts +0 -172
- package/src/tools/hints.ts +0 -99
- package/src/tools/inbox.ts +0 -546
- package/src/tools/index.ts +0 -239
- package/src/tools/inputs.ts +0 -47
- package/src/tools/instructions.ts +0 -41
- package/src/tools/jobs.ts +0 -90
- package/src/tools/knowledge.ts +0 -249
- package/src/tools/maintenance.ts +0 -540
- package/src/tools/markdown.test.ts +0 -249
- package/src/tools/markdown.ts +0 -151
- package/src/tools/observe.ts +0 -311
- package/src/tools/product-documents.ts +0 -184
- package/src/tools/products.ts +0 -529
- package/src/tools/prompts.ts +0 -265
- package/src/tools/queue.ts +0 -250
- package/src/tools/readiness.ts +0 -177
- package/src/tools/remote.test.ts +0 -44
- package/src/tools/remote.ts +0 -107
- package/src/tools/repositories.ts +0 -360
- package/src/tools/request-prompt.test.ts +0 -32
- package/src/tools/request-prompt.ts +0 -28
- package/src/tools/resources.ts +0 -217
- package/src/tools/sections.test.ts +0 -143
- package/src/tools/sections.ts +0 -287
- package/src/tools/setup.ts +0 -455
- package/src/tools/summarize.test.ts +0 -20
- package/src/tools/summarize.ts +0 -96
- package/src/tools/wait.ts +0 -490
- package/src/version.test.ts +0 -10
- package/src/version.ts +0 -14
- package/taiga-ai-mcp-server-0.25.0-rc.472.tgz +0 -0
- package/tsconfig.json +0 -21
- package/vitest.config.ts +0 -9
package/src/tools/products.ts
DELETED
|
@@ -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
|
-
}
|