@taiga-ai/mcp-server 0.16.0-dev.437 → 0.17.0-dev.438
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/dist/index.js +451 -146
- package/package.json +1 -1
- package/src/client.ts +1 -0
- package/src/server.integration.test.ts +374 -17
- package/src/tools/autonomy.ts +3 -3
- package/src/tools/backlogs.ts +68 -23
- package/src/tools/builds.ts +44 -10
- package/src/tools/catalogue.test.ts +11 -1
- package/src/tools/catalogue.ts +64 -3
- package/src/tools/context.ts +12 -2
- package/src/tools/documents.ts +17 -8
- package/src/tools/errors.ts +18 -9
- package/src/tools/hints.ts +71 -0
- package/src/tools/inbox.ts +16 -7
- package/src/tools/index.ts +8 -2
- package/src/tools/inputs.ts +16 -4
- package/src/tools/instructions.ts +40 -14
- package/src/tools/jobs.ts +2 -1
- package/src/tools/knowledge.ts +6 -6
- package/src/tools/maintenance.ts +32 -12
- package/src/tools/observe.ts +48 -7
- package/src/tools/products.ts +65 -40
- package/src/tools/prompts.ts +5 -3
- package/src/tools/repositories.ts +11 -14
- package/src/tools/sections.ts +24 -0
- package/src/tools/setup.ts +82 -15
- package/src/tools/summarize.ts +44 -0
- package/src/tools/wait.ts +4 -3
- package/taiga-ai-mcp-server-0.17.0-dev.438.tgz +0 -0
- package/taiga-ai-mcp-server-0.16.0-dev.437.tgz +0 -0
package/src/tools/products.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
291
|
-
|
|
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,
|
|
303
|
-
'Use this to find a product by name; inside a git checkout, taiga_resolve_product finds it
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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('
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
339
|
-
'
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
package/src/tools/prompts.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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({
|
|
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 =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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:
|
|
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('
|
|
131
|
+
return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
|
|
132
132
|
}
|
|
133
133
|
if (chosen.length > 1 && params.cursor !== undefined) {
|
|
134
|
-
return toolError('
|
|
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:
|
|
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('
|
|
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, {
|
package/src/tools/sections.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/setup.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
'
|
|
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('
|
|
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
|
-
|
|
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)
|
|
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) =>
|
|
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) =>
|
|
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;
|
package/src/tools/summarize.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
461
|
+
jobId: idInput('job', 'the tool that started the job returned it'),
|
|
461
462
|
timeoutSeconds,
|
|
462
463
|
}),
|
|
463
464
|
annotations: READ_ONLY,
|
|
Binary file
|
|
Binary file
|