@taiga-ai/mcp-server 0.18.0-rc.445 → 0.19.0-dev.451
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 +1128 -650
- package/package.json +1 -1
- package/src/client.test.ts +2 -2
- package/src/client.ts +201 -145
- package/src/modern.integration.test.ts +1 -1
- package/src/server.integration.test.ts +782 -243
- package/src/tools/autonomy.ts +3 -5
- package/src/tools/backlogs.ts +182 -72
- package/src/tools/builds.ts +142 -53
- package/src/tools/catalogue.test.ts +28 -22
- package/src/tools/context.ts +126 -56
- package/src/tools/documents.ts +12 -5
- package/src/tools/errors.ts +40 -2
- package/src/tools/hints.ts +37 -9
- package/src/tools/inbox.ts +149 -28
- package/src/tools/index.ts +15 -1
- package/src/tools/instructions.ts +32 -154
- package/src/tools/jobs.ts +6 -5
- package/src/tools/knowledge.ts +4 -6
- package/src/tools/maintenance.ts +53 -32
- package/src/tools/markdown.ts +13 -5
- package/src/tools/observe.ts +32 -14
- package/src/tools/product-documents.ts +4 -6
- package/src/tools/products.ts +32 -27
- package/src/tools/prompts.ts +14 -20
- package/src/tools/queue.ts +47 -26
- package/src/tools/readiness.ts +172 -0
- package/src/tools/repositories.ts +11 -9
- package/src/tools/resources.ts +16 -19
- package/src/tools/sections.test.ts +3 -3
- package/src/tools/sections.ts +57 -16
- package/src/tools/setup.ts +47 -51
- package/src/tools/summarize.test.ts +20 -0
- package/src/tools/summarize.ts +17 -13
- package/src/tools/wait.ts +39 -26
- package/taiga-ai-mcp-server-0.19.0-dev.451.tgz +0 -0
- package/tsconfig.json +1 -5
- package/taiga-ai-mcp-server-0.18.0-rc.445.tgz +0 -0
package/src/tools/context.ts
CHANGED
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
import type { McpServer, CallToolResult } from '@modelcontextprotocol/server';
|
|
2
2
|
import { isSettledOrArchived } from '@taiga/types';
|
|
3
|
+
import type { BacklogItem, Product } from '@taiga/types';
|
|
3
4
|
import { z } from 'zod';
|
|
4
|
-
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
5
|
+
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
5
6
|
import { READ_ONLY } from './annotations.js';
|
|
6
|
-
import {
|
|
7
|
+
import { situatedRows } from './backlogs.js';
|
|
8
|
+
import { POLICY_TYPES, defaultOriginFor, isImportedProduct } from './catalogue.js';
|
|
7
9
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
apiFailureText,
|
|
11
|
+
inProductWords,
|
|
12
|
+
isNetworkFailure,
|
|
13
|
+
networkFailureDetail,
|
|
14
|
+
runTool,
|
|
15
|
+
toolError,
|
|
16
|
+
toolResult,
|
|
17
|
+
} from './errors.js';
|
|
15
18
|
import { productId, remoteUrl, slug } from './inputs.js';
|
|
16
19
|
import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
|
|
17
|
-
import {
|
|
18
|
-
|
|
20
|
+
import {
|
|
21
|
+
budgetSections,
|
|
22
|
+
instructionsText,
|
|
23
|
+
isRecord,
|
|
24
|
+
sectionContent,
|
|
25
|
+
sectionsOf,
|
|
26
|
+
stripProvenance,
|
|
27
|
+
type DocumentData,
|
|
28
|
+
type StoredDocument,
|
|
29
|
+
} from './sections.js';
|
|
30
|
+
import { pickFields } from './summarize.js';
|
|
31
|
+
import { readableRun } from './builds.js';
|
|
32
|
+
import { setupReadiness } from './readiness.js';
|
|
33
|
+
import { standingSummary } from './maintenance.js';
|
|
19
34
|
|
|
20
35
|
/**
|
|
21
36
|
* One call at the start of a coding session: everything the model would
|
|
@@ -39,10 +54,10 @@ const REPOSITORY_SUMMARY_FIELDS = [
|
|
|
39
54
|
'isManaged',
|
|
40
55
|
] as const;
|
|
41
56
|
|
|
42
|
-
/** How many of the latest runs to carry;
|
|
57
|
+
/** How many of the latest runs to carry; taiga_list_runs pages the rest. */
|
|
43
58
|
const BUILDS_IN_CONTEXT = 10;
|
|
44
59
|
|
|
45
|
-
/** A run as the bundle carries it: enough to say what was built and where;
|
|
60
|
+
/** A run as the bundle carries it: enough to say what was built and where; taiga_get_run has the rest. */
|
|
46
61
|
const CONTEXT_BUILD_FIELDS = [
|
|
47
62
|
'id',
|
|
48
63
|
'runNumber',
|
|
@@ -52,6 +67,8 @@ const CONTEXT_BUILD_FIELDS = [
|
|
|
52
67
|
'failedReason',
|
|
53
68
|
'completedSteps',
|
|
54
69
|
'totalSteps',
|
|
70
|
+
'baseBranch',
|
|
71
|
+
'prNumber',
|
|
55
72
|
'prUrl',
|
|
56
73
|
'prState',
|
|
57
74
|
'prMerged',
|
|
@@ -72,7 +89,7 @@ export const DOCUMENT_BUDGET = 12_000;
|
|
|
72
89
|
* which a session's opening read does not need. The full read keeps them.
|
|
73
90
|
* Says whether any were dropped, so the bundle can tell the reader.
|
|
74
91
|
*/
|
|
75
|
-
function withoutCitations(doc:
|
|
92
|
+
function withoutCitations(doc: StoredDocument): { doc: StoredDocument; dropped: boolean } {
|
|
76
93
|
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
77
94
|
if (!sections) return { doc, dropped: false };
|
|
78
95
|
let dropped = false;
|
|
@@ -84,7 +101,7 @@ function withoutCitations(doc: Record<string, unknown>): { doc: Record<string, u
|
|
|
84
101
|
return [id, rest];
|
|
85
102
|
})
|
|
86
103
|
);
|
|
87
|
-
return { doc: { ...doc, data: { ...(doc.data as
|
|
104
|
+
return { doc: { ...doc, data: { ...(doc.data as DocumentData), sections: kept } }, dropped };
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
/**
|
|
@@ -101,7 +118,7 @@ const BACKLOG_PAGE_LIMIT = 10;
|
|
|
101
118
|
|
|
102
119
|
/**
|
|
103
120
|
* Open items the bundle carries, the newest of those read; past this it says truncated and
|
|
104
|
-
*
|
|
121
|
+
* taiga_list_initiatives pages the rest. Keeps a product with hundreds of
|
|
105
122
|
* open initiatives inside the tool result the document budget is sized for.
|
|
106
123
|
*/
|
|
107
124
|
const BACKLOG_IN_CONTEXT = 50;
|
|
@@ -113,6 +130,9 @@ const PARTS = {
|
|
|
113
130
|
backlog: 'backlog:read',
|
|
114
131
|
repositories: 'products:read',
|
|
115
132
|
builds: 'products:read',
|
|
133
|
+
attention: 'products:read',
|
|
134
|
+
maintenance: 'products:read',
|
|
135
|
+
standing: 'products:read',
|
|
116
136
|
} as const;
|
|
117
137
|
type Part = keyof typeof PARTS;
|
|
118
138
|
|
|
@@ -133,7 +153,7 @@ function isNotFound(reason: unknown): boolean {
|
|
|
133
153
|
|
|
134
154
|
/** The factory's standing guidance in full; null, with a note, when none is published or it is not section-shaped. */
|
|
135
155
|
async function factoryPart(client: TaigaClient, factoryId: string) {
|
|
136
|
-
const factory = (await client.getFactory(factoryId)).data
|
|
156
|
+
const factory = (await client.getFactory(factoryId)).data;
|
|
137
157
|
const base = { id: factory.id, name: factory.name, slug: factory.slug };
|
|
138
158
|
try {
|
|
139
159
|
const doc = (await client.getFactoryDocument(factoryId, 'factory_instructions')).data;
|
|
@@ -165,9 +185,9 @@ async function factoryPart(client: TaigaClient, factoryId: string) {
|
|
|
165
185
|
* outline by default, the whole sections up to a budget or in full when
|
|
166
186
|
* asked. Null, with a note, where unpublished.
|
|
167
187
|
*/
|
|
168
|
-
async function documentsPart(client: TaigaClient, product:
|
|
169
|
-
const productId =
|
|
170
|
-
const origin = defaultOriginFor(product
|
|
188
|
+
async function documentsPart(client: TaigaClient, product: WithoutTenant<Product>, depth: DocumentDepth) {
|
|
189
|
+
const productId = product.id;
|
|
190
|
+
const origin = defaultOriginFor(product);
|
|
171
191
|
const reads = await Promise.allSettled(
|
|
172
192
|
CONTEXT_DOCUMENT_TYPES.map((type) => client.getProductDocument(productId, type, origin))
|
|
173
193
|
);
|
|
@@ -176,7 +196,7 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
|
|
|
176
196
|
CONTEXT_DOCUMENT_TYPES.forEach((type, index) => {
|
|
177
197
|
const read = reads[index]!;
|
|
178
198
|
if (read.status === 'fulfilled') {
|
|
179
|
-
const doc = stripProvenance(read.value.data
|
|
199
|
+
const doc = stripProvenance(read.value.data);
|
|
180
200
|
if (depth === 'full') out[type] = doc;
|
|
181
201
|
else if (depth === 'budget') {
|
|
182
202
|
const lean = withoutCitations(doc);
|
|
@@ -200,7 +220,7 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
|
|
|
200
220
|
);
|
|
201
221
|
} else out[type] = cut.doc;
|
|
202
222
|
} else {
|
|
203
|
-
const sections = sectionsOf(
|
|
223
|
+
const sections = sectionsOf(doc.data ?? {});
|
|
204
224
|
if (sections) {
|
|
205
225
|
// Each id with the length of its text, so the next read is sized before it is
|
|
206
226
|
// made (a reality section's citations come on top).
|
|
@@ -224,14 +244,14 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
|
|
|
224
244
|
|
|
225
245
|
/** Every published policy as its section ids, so the model knows which one to read with taiga_get_document. */
|
|
226
246
|
async function policiesPart(client: TaigaClient) {
|
|
227
|
-
const rows = (await client.listTenantDocuments({ limit: 100 })).data
|
|
247
|
+
const rows = (await client.listTenantDocuments({ limit: 100 })).data;
|
|
228
248
|
return rows
|
|
229
249
|
.filter((row) => (POLICY_TYPES as readonly string[]).includes(String(row.type)) && row.isDraft === false)
|
|
230
250
|
.map((row) => ({
|
|
231
251
|
type: row.type,
|
|
232
252
|
name: row.name,
|
|
233
253
|
version: row.version,
|
|
234
|
-
sections: Object.keys(sectionsOf(
|
|
254
|
+
sections: Object.keys(sectionsOf(row.data ?? {}) ?? {}),
|
|
235
255
|
}));
|
|
236
256
|
}
|
|
237
257
|
|
|
@@ -249,7 +269,7 @@ const OPEN_BACKLOG_STATUSES = ['backlog', 'in_progress'] as const;
|
|
|
249
269
|
async function backlogPart(client: TaigaClient, productId: string) {
|
|
250
270
|
// Keyed by id: an item that moves from backlog to in_progress between the
|
|
251
271
|
// two reads would otherwise appear twice.
|
|
252
|
-
const open = new Map<string,
|
|
272
|
+
const open = new Map<string, WithoutTenant<BacklogItem>>();
|
|
253
273
|
let truncated = false;
|
|
254
274
|
let pageCapped = false;
|
|
255
275
|
for (const status of OPEN_BACKLOG_STATUSES) {
|
|
@@ -261,10 +281,8 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
261
281
|
break;
|
|
262
282
|
}
|
|
263
283
|
const result = await client.listBacklogItems(productId, { status, limit: 100, cursor });
|
|
264
|
-
for (const row of result.data
|
|
265
|
-
if (
|
|
266
|
-
!isSettledOrArchived({ status: String(row.status), archivedAt: (row.archivedAt as string | null) ?? null })
|
|
267
|
-
) {
|
|
284
|
+
for (const row of result.data) {
|
|
285
|
+
if (!isSettledOrArchived({ status: String(row.status), archivedAt: row.archivedAt ?? null })) {
|
|
268
286
|
open.set(String(row.id), row);
|
|
269
287
|
}
|
|
270
288
|
}
|
|
@@ -279,7 +297,7 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
279
297
|
);
|
|
280
298
|
if (rows.length > BACKLOG_IN_CONTEXT) truncated = true;
|
|
281
299
|
return {
|
|
282
|
-
items:
|
|
300
|
+
items: situatedRows(rows.slice(-BACKLOG_IN_CONTEXT)),
|
|
283
301
|
truncated,
|
|
284
302
|
// The list pages oldest first and takes no order, so a status past the
|
|
285
303
|
// page cap was read only up to its oldest items: these are the newest of
|
|
@@ -288,7 +306,7 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
288
306
|
? {
|
|
289
307
|
note:
|
|
290
308
|
`More than ${BACKLOG_PAGE_LIMIT * 100} open items in one status: only the oldest of them were read, so ` +
|
|
291
|
-
'the newest may be missing here;
|
|
309
|
+
'the newest may be missing here; taiga_list_initiatives pages the rest.',
|
|
292
310
|
}
|
|
293
311
|
: {}),
|
|
294
312
|
};
|
|
@@ -301,22 +319,67 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
301
319
|
async function repositoriesPart(client: TaigaClient, productId: string) {
|
|
302
320
|
const result = await client.listProductRepositories(productId);
|
|
303
321
|
return {
|
|
304
|
-
items: pickFields(result.data
|
|
322
|
+
items: pickFields(result.data, REPOSITORY_SUMMARY_FIELDS),
|
|
305
323
|
truncated: result.nextCursor !== null,
|
|
306
324
|
};
|
|
307
325
|
}
|
|
308
326
|
|
|
309
327
|
async function buildsPart(client: TaigaClient, productId: string) {
|
|
310
328
|
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
|
|
311
|
-
return pickFields(rows, CONTEXT_BUILD_FIELDS);
|
|
329
|
+
return pickFields(rows, CONTEXT_BUILD_FIELDS).map(readableRun);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* What waits on a person for this product, counted by kind (PRO-2800): the
|
|
334
|
+
* reviewing agent called four tools to answer "how does it stand"; the
|
|
335
|
+
* counts say whether taiga_list_action_required is worth a call.
|
|
336
|
+
*/
|
|
337
|
+
async function attentionPart(client: TaigaClient, productId: string) {
|
|
338
|
+
const result = await client.listInbox({ productId, limit: 100, withTotal: true });
|
|
339
|
+
const now = Date.now();
|
|
340
|
+
const rows = result.data as Record<string, unknown>[];
|
|
341
|
+
const byKind: Record<string, number> = {};
|
|
342
|
+
for (const row of rows) byKind[String(row['kind'])] = (byKind[String(row['kind'])] ?? 0) + 1;
|
|
343
|
+
// Snoozed items are open too, so they count; the number says how many of
|
|
344
|
+
// them the team has put off for now.
|
|
345
|
+
const snoozed = rows.filter(
|
|
346
|
+
(row) =>
|
|
347
|
+
row['status'] === 'snoozed' && typeof row['snoozedUntil'] === 'string' && Date.parse(row['snoozedUntil']) > now
|
|
348
|
+
).length;
|
|
349
|
+
return {
|
|
350
|
+
open: typeof result.totalCount === 'number' ? result.totalCount : rows.length,
|
|
351
|
+
byKind,
|
|
352
|
+
...(snoozed > 0 ? { snoozed } : {}),
|
|
353
|
+
...(result.nextCursor
|
|
354
|
+
? { note: 'byKind and snoozed count the first 100; taiga_list_action_required pages the rest.' }
|
|
355
|
+
: {}),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The latest sweep and what it left open, as counts; taiga_list_findings lists them. */
|
|
360
|
+
async function maintenancePart(client: TaigaClient, productId: string) {
|
|
361
|
+
const summary = (await client.getMaintenanceSummary(productId)).data as Record<string, unknown>;
|
|
362
|
+
const last = summary['lastRun'] as Record<string, unknown> | null | undefined;
|
|
363
|
+
return {
|
|
364
|
+
openFindings: summary['openFindings'],
|
|
365
|
+
bySeverity: summary['severityCounts'],
|
|
366
|
+
lastSweep: last ? { status: last['status'], completedAt: last['completedAt'] } : null,
|
|
367
|
+
...(last ? {} : { note: 'Never swept: no findings is not an all-clear.' }),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Where the product stands against the policies, as counts; taiga_get_policy_standing has each violation. */
|
|
372
|
+
async function standingPart(client: TaigaClient, productId: string) {
|
|
373
|
+
const summary = standingSummary((await client.getPolicyStanding(productId)).data);
|
|
374
|
+
return { openViolations: summary.violations, bySeverity: summary.bySeverity, controlsViolated: summary.controls };
|
|
312
375
|
}
|
|
313
376
|
|
|
314
377
|
export async function productContext(
|
|
315
378
|
client: TaigaClient,
|
|
316
|
-
product:
|
|
379
|
+
product: WithoutTenant<Product>,
|
|
317
380
|
options: { documents: DocumentDepth }
|
|
318
381
|
): Promise<CallToolResult> {
|
|
319
|
-
const productId =
|
|
382
|
+
const productId = product.id;
|
|
320
383
|
const granted = (part: Part) => client.hasCapability(PARTS[part]);
|
|
321
384
|
const notGranted = (Object.keys(PARTS) as Part[]).filter((part) => !granted(part));
|
|
322
385
|
|
|
@@ -327,18 +390,23 @@ export async function productContext(
|
|
|
327
390
|
backlog: () => backlogPart(client, productId),
|
|
328
391
|
repositories: () => repositoriesPart(client, productId),
|
|
329
392
|
builds: () => buildsPart(client, productId),
|
|
393
|
+
attention: () => attentionPart(client, productId),
|
|
394
|
+
maintenance: () => maintenancePart(client, productId),
|
|
395
|
+
standing: () => standingPart(client, productId),
|
|
330
396
|
};
|
|
331
397
|
const parts = (Object.keys(fetchers) as Part[]).filter(granted);
|
|
332
|
-
const settled = await Promise.
|
|
398
|
+
const [settled, readiness] = await Promise.all([
|
|
399
|
+
Promise.allSettled(parts.map((part) => fetchers[part]())),
|
|
400
|
+
setupReadiness(client, product),
|
|
401
|
+
]);
|
|
333
402
|
|
|
334
|
-
const nextStep = setupNextStep(product);
|
|
335
403
|
const bundle: Record<string, unknown> = {
|
|
336
404
|
product: {
|
|
337
405
|
...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
|
|
338
|
-
projectType: isImportedProduct(product
|
|
339
|
-
defaultOrigin: defaultOriginFor(product
|
|
340
|
-
// A product in setup says what it
|
|
341
|
-
...(
|
|
406
|
+
projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
|
|
407
|
+
defaultOrigin: defaultOriginFor(product),
|
|
408
|
+
// A product in setup says what it still needs: nothing else in the bundle does.
|
|
409
|
+
...(readiness ? { readiness } : {}),
|
|
342
410
|
},
|
|
343
411
|
};
|
|
344
412
|
const unavailable: Record<string, string> = {};
|
|
@@ -355,15 +423,16 @@ export async function productContext(
|
|
|
355
423
|
readNext:
|
|
356
424
|
'Read a policy with taiga_get_document (sections: the ids listed here, for only those), the ' +
|
|
357
425
|
"specification's or architecture's sections or another product document with taiga_get_product_document, " +
|
|
358
|
-
'
|
|
359
|
-
'with
|
|
426
|
+
'an initiative with taiga_get_initiative (taiga_list_initiatives pages a truncated backlog) and a run ' +
|
|
427
|
+
'with taiga_get_run.',
|
|
360
428
|
};
|
|
361
429
|
// Its own size, so a model can judge what one more read would cost: counted
|
|
362
430
|
// with the field in place, until the count no longer changes its own length.
|
|
363
|
-
|
|
431
|
+
// Measured as sent: toolResult renames keys into the product's words.
|
|
432
|
+
let characters = JSON.stringify(inProductWords(result), null, 2).length;
|
|
364
433
|
for (let measured = -1; measured !== characters;) {
|
|
365
434
|
measured = characters;
|
|
366
|
-
characters = JSON.stringify({ ...result, characters: measured }, null, 2).length;
|
|
435
|
+
characters = JSON.stringify(inProductWords({ ...result, characters: measured }), null, 2).length;
|
|
367
436
|
}
|
|
368
437
|
return toolResult({ ...result, characters });
|
|
369
438
|
}
|
|
@@ -376,17 +445,18 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
|
|
|
376
445
|
{
|
|
377
446
|
title: 'Get product context',
|
|
378
447
|
description:
|
|
379
|
-
'
|
|
380
|
-
'
|
|
381
|
-
'
|
|
382
|
-
'
|
|
383
|
-
'
|
|
384
|
-
'
|
|
385
|
-
'
|
|
386
|
-
|
|
387
|
-
'
|
|
388
|
-
|
|
389
|
-
'
|
|
448
|
+
'Where a product stands and everything a coding session starts from, in one call. Name the product by ' +
|
|
449
|
+
'ID, slug or the git remote URL of a checkout. It carries: the product (and, while in setup, readiness: ' +
|
|
450
|
+
'what it still needs and the next move); its factory with the instructions in full; the specification ' +
|
|
451
|
+
'and architecture as outlines (section ids and sizes); every published policy as its section ids; the ' +
|
|
452
|
+
'open initiatives (not the done or canceled ones: taiga_list_initiatives has those); the linked ' +
|
|
453
|
+
'repositories; the latest runs, each with its outcome in a sentence; attention, what waits on a person ' +
|
|
454
|
+
'for it, counted by kind; maintenance, open findings by severity and the last sweep; standing, open ' +
|
|
455
|
+
'policy violations by severity. A part the API refused or failed is named under unavailable; a part the ' +
|
|
456
|
+
'key cannot read under notGranted; neither is shown as empty. Before writing code, documents: budget ' +
|
|
457
|
+
`brings the whole sections that fit, about ${DOCUMENT_BUDGET / 1000}k characters each, and full brings ` +
|
|
458
|
+
'them whole (tens of thousands of tokens on a large product). Unlike taiga_get_product, which is the ' +
|
|
459
|
+
'product record and its settings alone. Call this first, then read what you need.',
|
|
390
460
|
inputSchema: z.object({
|
|
391
461
|
productId: productId.optional(),
|
|
392
462
|
slug: slug.optional(),
|
package/src/tools/documents.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import type { FactoryDocument, TenantDocument } from '@taiga/types';
|
|
2
3
|
import { z } from 'zod';
|
|
3
|
-
import { TaigaApiError, type TaigaClient, type TaigaDocument } from '../client.js';
|
|
4
|
+
import { TaigaApiError, type TaigaClient, type TaigaDocument, type WithoutTenant } from '../client.js';
|
|
4
5
|
import { READ_ONLY } from './annotations.js';
|
|
5
6
|
import { idInput, pagination } from './inputs.js';
|
|
6
7
|
import {
|
|
@@ -27,7 +28,10 @@ import { page, pickFields } from './summarize.js';
|
|
|
27
28
|
*/
|
|
28
29
|
const DOCUMENT_SUMMARY_FIELDS = ['type', 'name', 'description', 'version', 'isDraft', 'hasDraft', 'updatedAt'] as const;
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
/** A row of the company or factory document list; product documents list through their own tools. */
|
|
32
|
+
type ListedDocument = WithoutTenant<TenantDocument | FactoryDocument>;
|
|
33
|
+
|
|
34
|
+
const summaries = (rows: ListedDocument[]) => pickFields(rows, DOCUMENT_SUMMARY_FIELDS);
|
|
31
35
|
|
|
32
36
|
/** What a lookup behind a miss said when it failed, so an unknown stays unknown. */
|
|
33
37
|
function failedLookup(what: string, reason: unknown): string {
|
|
@@ -36,10 +40,10 @@ function failedLookup(what: string, reason: unknown): string {
|
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/** `a (v3), b (v1)` for a not-found message. */
|
|
39
|
-
function publishedList(rows:
|
|
43
|
+
function publishedList(rows: ListedDocument[]): string {
|
|
40
44
|
// The list routes return the latest draft rows beside the published ones,
|
|
41
45
|
// and a draft named as published sends the model into a second miss.
|
|
42
|
-
return
|
|
46
|
+
return rows
|
|
43
47
|
.filter((row) => row.isDraft === false)
|
|
44
48
|
.map((row) => `${row.type} (v${String(row.version)})`)
|
|
45
49
|
.join(', ');
|
|
@@ -180,7 +184,10 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
180
184
|
'ids taiga_get_product_context lists for every policy, or sections: [] for the outline) and the read ' +
|
|
181
185
|
'carries only those, listing the rest with their sizes and any id the document lacks ' +
|
|
182
186
|
'(unknownSections). A read that finds no document says which documents are published and whether a ' +
|
|
183
|
-
|
|
187
|
+
"draft exists. Each section's source says where its text came from (template: Taiga's standard text; " +
|
|
188
|
+
'overlay-<id>: a jurisdiction or industry overlay; cloud-preset:<providers>: rendered for the ' +
|
|
189
|
+
"organization's clouds) and priority: core marks the sections every organization keeps; neither changes " +
|
|
190
|
+
'what the section requires.',
|
|
184
191
|
inputSchema: z.object({
|
|
185
192
|
type: tenantDocumentType,
|
|
186
193
|
sections: sectionsInput,
|
package/src/tools/errors.ts
CHANGED
|
@@ -2,9 +2,41 @@ import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
|
2
2
|
import { TaigaApiError } from '../client.js';
|
|
3
3
|
import { withNotFoundHint } from './hints.js';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The API's names for the two IDs an agent chains most, in the product's
|
|
7
|
+
* words the tools take them in (PRO-2800): a row says initiativeId and runId,
|
|
8
|
+
* as taiga_get_initiative and taiga_get_run ask for them, rather than the
|
|
9
|
+
* storage names backlogItemId and buildRunId.
|
|
10
|
+
*/
|
|
11
|
+
const PRODUCT_WORDS: Record<string, string> = { backlogItemId: 'initiativeId', buildRunId: 'runId' };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Maps whose keys are content, not API fields: a document's sections are keyed
|
|
15
|
+
* by ids a person or agent chose (a section may be called backlogItemId), and
|
|
16
|
+
* metadata and settings hold whatever their writer put there. Copied as they
|
|
17
|
+
* are, keys and all.
|
|
18
|
+
*/
|
|
19
|
+
const OPAQUE_MAPS = new Set(['sections', 'metadata', 'settings']);
|
|
20
|
+
|
|
21
|
+
/** A copy of an API value with its keys in the product's words, values untouched. */
|
|
22
|
+
export function inProductWords(value: unknown): unknown {
|
|
23
|
+
if (Array.isArray(value)) return value.map(inProductWords);
|
|
24
|
+
if (value === null || typeof value !== 'object') return value;
|
|
25
|
+
const out: Record<string, unknown> = {};
|
|
26
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
27
|
+
Object.defineProperty(out, PRODUCT_WORDS[key] ?? key, {
|
|
28
|
+
value: OPAQUE_MAPS.has(key) ? inner : inProductWords(inner),
|
|
29
|
+
enumerable: true,
|
|
30
|
+
writable: true,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
5
37
|
/** A successful tool result: one JSON text block the model reads. */
|
|
6
38
|
export function toolResult(value: unknown): CallToolResult {
|
|
7
|
-
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
39
|
+
return { content: [{ type: 'text', text: JSON.stringify(inProductWords(value), null, 2) }] };
|
|
8
40
|
}
|
|
9
41
|
|
|
10
42
|
/**
|
|
@@ -14,7 +46,13 @@ export function toolResult(value: unknown): CallToolResult {
|
|
|
14
46
|
*/
|
|
15
47
|
export function toolError(error: string, message: string, details?: Record<string, unknown>): CallToolResult {
|
|
16
48
|
return {
|
|
17
|
-
|
|
49
|
+
// Details in the product's words too: a conflict naming the run in its way says runId, as the inputs do.
|
|
50
|
+
content: [
|
|
51
|
+
{
|
|
52
|
+
type: 'text',
|
|
53
|
+
text: JSON.stringify(details ? { error, message, details: inProductWords(details) } : { error, message }),
|
|
54
|
+
},
|
|
55
|
+
],
|
|
18
56
|
isError: true,
|
|
19
57
|
};
|
|
20
58
|
}
|
package/src/tools/hints.ts
CHANGED
|
@@ -2,14 +2,16 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The next step after the API answers 404. The API's own word is bare
|
|
5
|
-
* ("
|
|
5
|
+
* ("Initiative not found", "Repository link not found"), and an agent that
|
|
6
6
|
* hears only that guesses another ID, or a name. Each ID a tool takes names
|
|
7
7
|
* the tool that lists the right ones; the tool's arguments say which apply.
|
|
8
8
|
*/
|
|
9
|
+
const ITEM_ID_HINT =
|
|
10
|
+
"taiga_list_initiatives lists a product's initiatives with their IDs; a run or plan ID is not one.";
|
|
11
|
+
|
|
9
12
|
const BY_ARGUMENT: Record<string, string> = {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
buildId: 'taiga_list_builds lists the runs, of one product or of every product the key can see.',
|
|
13
|
+
initiativeId: ITEM_ID_HINT,
|
|
14
|
+
runId: 'taiga_list_runs lists the runs, of one product or of every product the key can see.',
|
|
13
15
|
planId: "taiga_get_plan gives the initiative's active plan and its ID.",
|
|
14
16
|
repositoryId:
|
|
15
17
|
"taiga_list_product_repositories lists the product's repository links; a GitHub repository id is not a link ID.",
|
|
@@ -38,12 +40,35 @@ const BY_TOOL: Record<string, Hint> = {
|
|
|
38
40
|
// paid planning to fix a typo.
|
|
39
41
|
taiga_get_plan: (said) =>
|
|
40
42
|
/\bplan\b/i.test(said)
|
|
41
|
-
? '
|
|
43
|
+
? 'taiga_get_initiative says whether one is being generated (planningStatus planning) or failed, and ' +
|
|
42
44
|
'taiga_plan_again asks for a new one.'
|
|
43
|
-
:
|
|
45
|
+
: ITEM_ID_HINT,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What the API calls the thing each ID names, as its 404 says it ("Environment
|
|
50
|
+
* not found"). A tool given a right product and a wrong environment hinted
|
|
51
|
+
* at both, and the product's hint told the agent its correct product ID was
|
|
52
|
+
* the mistake (PRO-2800): a 404 that names its thing gets that ID's hint only.
|
|
53
|
+
*/
|
|
54
|
+
const MISSING_THING: Record<string, RegExp> = {
|
|
55
|
+
initiativeId: /backlog item|initiative/i,
|
|
56
|
+
runId: /\b(build )?run\b/i,
|
|
57
|
+
planId: /\bplan\b/i,
|
|
58
|
+
repositoryId: /repository link|\blink\b/i,
|
|
59
|
+
replacesRepositoryId: /repository link|\blink\b/i,
|
|
60
|
+
githubRepositoryId: /github repository/i,
|
|
61
|
+
installationId: /installation|account/i,
|
|
62
|
+
jobId: /\bjob\b/i,
|
|
63
|
+
factoryId: /factory|team/i,
|
|
64
|
+
findingId: /finding/i,
|
|
65
|
+
violationId: /violation/i,
|
|
66
|
+
environmentId: /environment/i,
|
|
67
|
+
knowledgeId: /knowledge|document/i,
|
|
68
|
+
productId: /\b(product|project) not found/i,
|
|
44
69
|
};
|
|
45
70
|
|
|
46
|
-
/** The hint for a 404 from one tool call: the tool's own, else one
|
|
71
|
+
/** The hint for a 404 from one tool call: the tool's own, else the one for the ID the API says is missing. */
|
|
47
72
|
export function notFoundHint(tool: string, args: unknown): Hint | undefined {
|
|
48
73
|
if (BY_TOOL[tool]) return BY_TOOL[tool];
|
|
49
74
|
if (typeof args !== 'object' || args === null) return undefined;
|
|
@@ -52,8 +77,11 @@ export function notFoundHint(tool: string, args: unknown): Hint | undefined {
|
|
|
52
77
|
// found", which already says which ID it was; the others are named first.
|
|
53
78
|
given.sort((a, b) => Number(a === 'productId') - Number(b === 'productId'));
|
|
54
79
|
if (given.length === 0) return undefined;
|
|
55
|
-
|
|
56
|
-
|
|
80
|
+
return (said) => {
|
|
81
|
+
const named = given.filter((key) => MISSING_THING[key]?.test(said));
|
|
82
|
+
// A 404 that names none of them (or a bare one) still gets them all.
|
|
83
|
+
return (named.length > 0 ? named : given).map((key) => BY_ARGUMENT[key]).join(' ');
|
|
84
|
+
};
|
|
57
85
|
}
|
|
58
86
|
|
|
59
87
|
/** The hint of the tool call in progress, read by runTool when the API answers 404. */
|