@taiga-ai/mcp-server 0.11.0 → 0.12.0-dev.417
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 +912 -53
- package/package.json +1 -1
- package/src/client.ts +26 -9
- package/src/server.integration.test.ts +1134 -16
- package/src/tools/backlogs.ts +131 -16
- package/src/tools/builds.ts +53 -6
- package/src/tools/context.ts +132 -22
- package/src/tools/errors.ts +4 -4
- package/src/tools/index.ts +29 -1
- package/src/tools/instructions.ts +15 -7
- package/src/tools/products.ts +27 -2
- package/src/tools/prompts.ts +5 -2
- package/src/tools/queue.ts +168 -0
- package/src/tools/sections.test.ts +36 -1
- package/src/tools/sections.ts +50 -0
- package/src/tools/summarize.ts +23 -0
- package/src/tools/wait.ts +445 -0
- package/taiga-ai-mcp-server-0.12.0-dev.417.tgz +0 -0
- package/tsconfig.json +4 -0
- package/taiga-ai-mcp-server-0.11.0.tgz +0 -0
package/src/tools/backlogs.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import type
|
|
4
|
-
import {
|
|
3
|
+
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
4
|
+
import {
|
|
5
|
+
INITIATIVE_TRANSITION_STATES,
|
|
6
|
+
PLAN_REJECTION_COMMENT_MAX_CHARS,
|
|
7
|
+
PR_CLOSING_STATES,
|
|
8
|
+
resolveAutoBuild,
|
|
9
|
+
} from '@taiga/types';
|
|
5
10
|
import { OPERATE, READ_ONLY, WRITE } from './annotations.js';
|
|
6
11
|
import { pagination, productId } from './inputs.js';
|
|
7
|
-
import { runTool, toolError, toolResult } from './errors.js';
|
|
8
|
-
import {
|
|
12
|
+
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
13
|
+
import { queueView } from './queue.js';
|
|
14
|
+
import { criteriaCounts, itemRows, page, pickFields } from './summarize.js';
|
|
9
15
|
|
|
10
16
|
/** What a list row needs to say for the model to pick an item to read in full. */
|
|
11
17
|
export const BACKLOG_SUMMARY_FIELDS = [
|
|
@@ -38,6 +44,7 @@ const ITEM_OUTCOME_FIELDS = [
|
|
|
38
44
|
'planningStatus',
|
|
39
45
|
'needsHumanCause',
|
|
40
46
|
'needsHumanDetail',
|
|
47
|
+
'autoBuild',
|
|
41
48
|
'archivedAt',
|
|
42
49
|
'lifecycle',
|
|
43
50
|
] as const;
|
|
@@ -57,15 +64,21 @@ const PLAN_STEP_SUMMARY_FIELDS = [
|
|
|
57
64
|
'createsResources',
|
|
58
65
|
] as const;
|
|
59
66
|
|
|
60
|
-
|
|
67
|
+
/** The outcome fields of an initiative, with how many of its criteria are met. */
|
|
68
|
+
const outcomeOf = (item: unknown) => {
|
|
69
|
+
const row = item as Record<string, unknown>;
|
|
70
|
+
return { ...pickFields([row], ITEM_OUTCOME_FIELDS)[0], criteria: criteriaCounts(row) };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const outcome = (item: unknown) => toolResult(outcomeOf(item));
|
|
61
74
|
|
|
62
75
|
/**
|
|
63
76
|
* Each tool is registered under the capability its route requires, so a
|
|
64
77
|
* key sees exactly what it may call: reads on backlog:read, the create and
|
|
65
78
|
* the board move on backlog:write, queueing on both backlog:write and
|
|
66
79
|
* products:write (the state route checks both for a drop into queue), the
|
|
67
|
-
* resume on products:write alone, as
|
|
68
|
-
* also comes back from a create or a queue), and the plan verdicts on
|
|
80
|
+
* resume and the review gate on products:write alone, as their routes are
|
|
81
|
+
* (the item ID they take also comes back from a create or a queue), and the plan verdicts on
|
|
69
82
|
* products:write together with backlog:read, because the plan ID they take
|
|
70
83
|
* comes only from the plan read: a key that cannot read plans has nothing
|
|
71
84
|
* to feed them.
|
|
@@ -76,6 +89,7 @@ export function registerBacklogTools(server: McpServer, client: TaigaClient) {
|
|
|
76
89
|
if (may('backlog:write')) registerBacklogWrites(server, client);
|
|
77
90
|
if (may('backlog:write') && may('products:write')) registerQueue(server, client);
|
|
78
91
|
if (may('products:write')) registerResume(server, client);
|
|
92
|
+
if (may('products:write')) registerReviewGate(server, client);
|
|
79
93
|
if (may('backlog:read') && may('products:write')) registerPlanVerdicts(server, client);
|
|
80
94
|
}
|
|
81
95
|
|
|
@@ -85,8 +99,9 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
85
99
|
{
|
|
86
100
|
title: 'List backlog items',
|
|
87
101
|
description:
|
|
88
|
-
"List a product's backlog items (initiatives): titles, category, status
|
|
89
|
-
'line (backlog, todo, queue, plan, build, done)
|
|
102
|
+
"List a product's backlog items (initiatives): titles, category, status, where each one is on the " +
|
|
103
|
+
'line (backlog, todo, queue, plan, build, done) and criteria, how many of its acceptance criteria are met ' +
|
|
104
|
+
'(null before it is planned). Use this to find the item you are working on, then ' +
|
|
90
105
|
'read its acceptance criteria and technical context with taiga_get_backlog_item. ' +
|
|
91
106
|
'Archived items are left out unless asked for.',
|
|
92
107
|
inputSchema: {
|
|
@@ -109,7 +124,7 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
109
124
|
runTool(async () => {
|
|
110
125
|
const result = await client.listBacklogItems(params.productId, params);
|
|
111
126
|
return toolResult(
|
|
112
|
-
page(
|
|
127
|
+
page(itemRows(result.data as Record<string, unknown>[], BACKLOG_SUMMARY_FIELDS), result.nextCursor)
|
|
113
128
|
);
|
|
114
129
|
})
|
|
115
130
|
);
|
|
@@ -250,6 +265,56 @@ function registerBacklogWrites(server: McpServer, client: TaigaClient) {
|
|
|
250
265
|
/** The columns a drop into starts paid work, which the state route gates on products:write. */
|
|
251
266
|
const LINE_STARTING_STATES: ReadonlySet<string> = new Set(['queue', 'plan']);
|
|
252
267
|
|
|
268
|
+
/** Whether a failure is the API's (refused, missing, unreachable) rather than a bug here. */
|
|
269
|
+
const isApiFailure = (error: unknown) => error instanceof TaigaApiError || isNetworkFailure(error);
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Whether this initiative's plan builds the moment it lands, and which scope
|
|
273
|
+
* decided it: its own setting, or the product's default when it has none.
|
|
274
|
+
* Null when the product cannot be read; the outcome then says so instead.
|
|
275
|
+
*/
|
|
276
|
+
async function reviewGate(client: TaigaClient, item: Record<string, unknown>) {
|
|
277
|
+
const own = typeof item.autoBuild === 'boolean' ? item.autoBuild : null;
|
|
278
|
+
try {
|
|
279
|
+
// The product's default answers only when the initiative has no answer of its own.
|
|
280
|
+
const product =
|
|
281
|
+
own === null ? ((await client.getProduct(String(item.productId))).data as { autoBuild?: unknown }) : {};
|
|
282
|
+
// Absent on the product reads as its true default (@taiga/types).
|
|
283
|
+
const productDefault = typeof product.autoBuild === 'boolean' ? product.autoBuild : true;
|
|
284
|
+
const builds = resolveAutoBuild({ initiative: own, product: productDefault });
|
|
285
|
+
return {
|
|
286
|
+
buildsWithoutReview: builds,
|
|
287
|
+
decidedBy: own === null ? 'product' : 'initiative',
|
|
288
|
+
meaning: builds
|
|
289
|
+
? 'The plan is built as soon as it lands; nobody reads it first.'
|
|
290
|
+
: 'The line stops at the plan for a person to approve or reject it (taiga_get_plan, taiga_approve_plan).',
|
|
291
|
+
};
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (!isApiFailure(error)) throw error;
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Where a queued initiative stands in its product's line; null, and the outcome says so, when the queue cannot be read. */
|
|
299
|
+
async function placeInQueue(client: TaigaClient, item: Record<string, unknown>) {
|
|
300
|
+
try {
|
|
301
|
+
const view = await queueView(client, String(item.productId), { name: 'blocking' });
|
|
302
|
+
const entry = view.entries.find((row) => row.backlogItemId === item.id);
|
|
303
|
+
return { position: entry?.position ?? null, of: view.entries.length, waitingOn: view.waitingOn };
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (!isApiFailure(error)) throw error;
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const autoBuildInput = z
|
|
311
|
+
.boolean()
|
|
312
|
+
.nullable()
|
|
313
|
+
.describe(
|
|
314
|
+
"false stops the line at this initiative's plan for a person to read it; true builds it as soon as it lands; " +
|
|
315
|
+
"null follows the product's default"
|
|
316
|
+
);
|
|
317
|
+
|
|
253
318
|
function registerQueue(server: McpServer, client: TaigaClient) {
|
|
254
319
|
server.registerTool(
|
|
255
320
|
'taiga_queue_backlog_item',
|
|
@@ -258,10 +323,13 @@ function registerQueue(server: McpServer, client: TaigaClient) {
|
|
|
258
323
|
description:
|
|
259
324
|
'Add an initiative to the product queue, the line the coding agent works: queued initiatives are ' +
|
|
260
325
|
'planned and built one at a time, each advancing when the previous pull request merges. This starts ' +
|
|
261
|
-
'paid work: a planner run, then
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
'
|
|
326
|
+
'paid work: a planner run, then a coding run that pushes commits and opens a pull request in the product ' +
|
|
327
|
+
"repository. Whether the plan is built at once or waits for a person is the initiative's review gate: " +
|
|
328
|
+
"pass autoBuild: false to read the plan before anything is built (the product's default applies " +
|
|
329
|
+
'otherwise, and the outcome says which it is); the gate is set first and stays set if the move is then ' +
|
|
330
|
+
'refused. Ask the person before calling this. An initiative already ' +
|
|
331
|
+
'in the queue stays where it is unless afterId says where to go; one being planned or built cannot move ' +
|
|
332
|
+
'(CONFLICT). Returns the initiative with its new state, its review gate and its place in the queue.',
|
|
265
333
|
inputSchema: {
|
|
266
334
|
itemId,
|
|
267
335
|
afterId: z
|
|
@@ -272,15 +340,62 @@ function registerQueue(server: McpServer, client: TaigaClient) {
|
|
|
272
340
|
.describe(
|
|
273
341
|
'The queued initiative to follow; null for the head of the queue; omit to append a new entry or leave a queued one where it is'
|
|
274
342
|
),
|
|
343
|
+
autoBuild: autoBuildInput
|
|
344
|
+
.optional()
|
|
345
|
+
.describe(
|
|
346
|
+
"Set the initiative's review gate before queueing: false to read the plan first, true to build it at " +
|
|
347
|
+
'once, null to follow the product; omit to leave the gate as it is'
|
|
348
|
+
),
|
|
275
349
|
},
|
|
276
350
|
annotations: OPERATE,
|
|
277
351
|
},
|
|
278
352
|
(params) =>
|
|
279
353
|
runTool(async () => {
|
|
354
|
+
const { itemId: id, autoBuild, ...placement } = params;
|
|
355
|
+
// The gate first: set after the move, a plan landing in between
|
|
356
|
+
// would be built under the old answer.
|
|
357
|
+
if (autoBuild !== undefined) await client.setAutoBuild(id, autoBuild);
|
|
280
358
|
// The move the web makes: one write that answers with the refreshed
|
|
281
359
|
// item, rather than the queue route plus two reads around it.
|
|
282
|
-
const {
|
|
283
|
-
|
|
360
|
+
const item = (await client.transitionInitiative(id, { state: 'queue', ...placement })).data as Record<
|
|
361
|
+
string,
|
|
362
|
+
unknown
|
|
363
|
+
>;
|
|
364
|
+
const [gate, queue] = await Promise.all([reviewGate(client, item), placeInQueue(client, item)]);
|
|
365
|
+
return toolResult({
|
|
366
|
+
...outcomeOf(item),
|
|
367
|
+
reviewGate: gate,
|
|
368
|
+
queue,
|
|
369
|
+
...(gate && queue
|
|
370
|
+
? {}
|
|
371
|
+
: { note: 'Part of the outcome could not be read; taiga_get_queue and taiga_get_product say the rest.' }),
|
|
372
|
+
});
|
|
373
|
+
})
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function registerReviewGate(server: McpServer, client: TaigaClient) {
|
|
378
|
+
server.registerTool(
|
|
379
|
+
'taiga_set_auto_build',
|
|
380
|
+
{
|
|
381
|
+
title: "Set an initiative's review gate",
|
|
382
|
+
description:
|
|
383
|
+
"Set whether an initiative's plan is built as soon as it lands (true) or waits for a person to read it and " +
|
|
384
|
+
"approve or reject it (false); null follows the product's default. Standing: it applies to every plan the " +
|
|
385
|
+
'initiative gets, including one being written now. Opening the gate on a plan that is already waiting ' +
|
|
386
|
+
'starts its build, so ask the person first. Returns the initiative with the gate that now applies.',
|
|
387
|
+
inputSchema: { itemId, autoBuild: autoBuildInput },
|
|
388
|
+
annotations: OPERATE,
|
|
389
|
+
},
|
|
390
|
+
(params) =>
|
|
391
|
+
runTool(async () => {
|
|
392
|
+
const item = (await client.setAutoBuild(params.itemId, params.autoBuild)).data as Record<string, unknown>;
|
|
393
|
+
const gate = await reviewGate(client, item);
|
|
394
|
+
return toolResult({
|
|
395
|
+
...outcomeOf(item),
|
|
396
|
+
reviewGate: gate,
|
|
397
|
+
...(gate ? {} : { note: "The product's default could not be read; taiga_get_product says it." }),
|
|
398
|
+
});
|
|
284
399
|
})
|
|
285
400
|
);
|
|
286
401
|
}
|
package/src/tools/builds.ts
CHANGED
|
@@ -52,6 +52,37 @@ const NAMED_PRODUCTS = 100;
|
|
|
52
52
|
|
|
53
53
|
const buildId = z.string().uuid().describe('Build run ID -- get from taiga_list_builds');
|
|
54
54
|
|
|
55
|
+
/** A step without its instructions: what it is, how it stands and what it changed. */
|
|
56
|
+
const STEP_SUMMARY_FIELDS = [
|
|
57
|
+
'stepOrder',
|
|
58
|
+
'title',
|
|
59
|
+
'status',
|
|
60
|
+
'currentPhase',
|
|
61
|
+
'commitSha',
|
|
62
|
+
'filesChanged',
|
|
63
|
+
'verification',
|
|
64
|
+
'error',
|
|
65
|
+
'errorClassification',
|
|
66
|
+
'startedAt',
|
|
67
|
+
'completedAt',
|
|
68
|
+
] as const;
|
|
69
|
+
|
|
70
|
+
/** Past this many characters a step's headline is cut; the full read has the rest. */
|
|
71
|
+
const HEADLINE_LIMIT = 600;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The first paragraph of a step's summary: the agent writes its result
|
|
75
|
+
* there (what changed, which checks passed), and the detail after it.
|
|
76
|
+
*/
|
|
77
|
+
export function summaryHeadline(summary: unknown): string | null {
|
|
78
|
+
if (typeof summary !== 'string' || !summary.trim()) return null;
|
|
79
|
+
const first = summary
|
|
80
|
+
.trim()
|
|
81
|
+
.split(/\n\s*\n/, 1)[0]!
|
|
82
|
+
.trim();
|
|
83
|
+
return first.length > HEADLINE_LIMIT ? `${first.slice(0, HEADLINE_LIMIT).trimEnd()}...` : first;
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
/**
|
|
56
87
|
* The runs of every product the key can see, each row naming its product.
|
|
57
88
|
* The route carries product ids only; one products read names them, and a
|
|
@@ -156,11 +187,19 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
156
187
|
{
|
|
157
188
|
title: 'Get build run',
|
|
158
189
|
description:
|
|
159
|
-
|
|
160
|
-
'
|
|
161
|
-
|
|
162
|
-
'
|
|
163
|
-
|
|
190
|
+
"Get one run with its steps: the run (status, pull request, hardening, metrics) and each step's title, " +
|
|
191
|
+
'status, commit, files changed, checks, any error and headline, the first paragraph of what the agent ' +
|
|
192
|
+
"reported. Pass full: true for each step's complete instructions and report, several thousand tokens on " +
|
|
193
|
+
'a long run. Use this to pick up where the coding agent stopped, or to understand the pull request it ' +
|
|
194
|
+
'opened. Its actions list says what it accepts: stop (taiga_stop_build), resume or retry_from_failed_step ' +
|
|
195
|
+
'(both taiga_resume_build), start_over (web UI only for now).',
|
|
196
|
+
inputSchema: {
|
|
197
|
+
buildId,
|
|
198
|
+
full: z
|
|
199
|
+
.boolean()
|
|
200
|
+
.optional()
|
|
201
|
+
.describe("Carry each step's complete instructions and report (default false: a summary)"),
|
|
202
|
+
},
|
|
164
203
|
annotations: READ_ONLY,
|
|
165
204
|
},
|
|
166
205
|
(params) =>
|
|
@@ -171,7 +210,15 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
171
210
|
client.getBuildRun(params.buildId),
|
|
172
211
|
client.listBuildRunSteps(params.buildId),
|
|
173
212
|
]);
|
|
174
|
-
return toolResult({ ...(run.data as Record<string, unknown>), steps: steps.data });
|
|
213
|
+
if (params.full) return toolResult({ ...(run.data as Record<string, unknown>), steps: steps.data });
|
|
214
|
+
const rows = steps.data as Record<string, unknown>[];
|
|
215
|
+
return toolResult({
|
|
216
|
+
...(run.data as Record<string, unknown>),
|
|
217
|
+
steps: pickFields(rows, STEP_SUMMARY_FIELDS).map((step, index) => ({
|
|
218
|
+
...step,
|
|
219
|
+
headline: summaryHeadline(rows[index]!.summary),
|
|
220
|
+
})),
|
|
221
|
+
});
|
|
175
222
|
})
|
|
176
223
|
);
|
|
177
224
|
|
package/src/tools/context.ts
CHANGED
|
@@ -5,13 +5,12 @@ import { z } from 'zod';
|
|
|
5
5
|
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
6
6
|
import { READ_ONLY } from './annotations.js';
|
|
7
7
|
import { BACKLOG_SUMMARY_FIELDS } from './backlogs.js';
|
|
8
|
-
import { BUILD_SUMMARY_FIELDS } from './builds.js';
|
|
9
8
|
import { POLICY_TYPES, defaultOriginFor, isImportedProduct, type ProductSettingsView } from './catalogue.js';
|
|
10
9
|
import { apiFailureText, isNetworkFailure, networkFailureDetail, runTool, toolError, toolResult } from './errors.js';
|
|
11
10
|
import { productId, remoteUrl, slug } from './inputs.js';
|
|
12
11
|
import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
|
|
13
|
-
import { instructionsText, sectionsOf, stripProvenance } from './sections.js';
|
|
14
|
-
import { pickFields } from './summarize.js';
|
|
12
|
+
import { budgetSections, instructionsText, isRecord, sectionsOf, stripProvenance } from './sections.js';
|
|
13
|
+
import { itemRows, pickFields } from './summarize.js';
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* One call at the start of a coding session: everything the model would
|
|
@@ -38,9 +37,64 @@ const REPOSITORY_SUMMARY_FIELDS = [
|
|
|
38
37
|
/** How many of the latest runs to carry; taiga_list_builds pages the rest. */
|
|
39
38
|
const BUILDS_IN_CONTEXT = 10;
|
|
40
39
|
|
|
40
|
+
/** A run as the bundle carries it: enough to say what was built and where; taiga_get_build has the rest. */
|
|
41
|
+
const CONTEXT_BUILD_FIELDS = [
|
|
42
|
+
'id',
|
|
43
|
+
'runNumber',
|
|
44
|
+
'backlogItemId',
|
|
45
|
+
'targetTitle',
|
|
46
|
+
'status',
|
|
47
|
+
'failedReason',
|
|
48
|
+
'completedSteps',
|
|
49
|
+
'totalSteps',
|
|
50
|
+
'prUrl',
|
|
51
|
+
'prState',
|
|
52
|
+
'prMerged',
|
|
53
|
+
'createdAt',
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Characters of section text each of the two documents may bring by default.
|
|
58
|
+
* Two of them plus the rest of the bundle stay well inside the 25k-token
|
|
59
|
+
* tool result Claude Code accepts (a 67k-character bundle was refused); a
|
|
60
|
+
* larger product's documents come cut, with the sections left out named.
|
|
61
|
+
*/
|
|
62
|
+
export const DOCUMENT_BUDGET = 12_000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The document without each section's citations: on a reality document they
|
|
66
|
+
* are the code evidence behind every claim and as long as the text itself,
|
|
67
|
+
* which a session's opening read does not need. The full read keeps them.
|
|
68
|
+
* Says whether any were dropped, so the bundle can tell the reader.
|
|
69
|
+
*/
|
|
70
|
+
function withoutCitations(doc: Record<string, unknown>): { doc: Record<string, unknown>; dropped: boolean } {
|
|
71
|
+
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
72
|
+
if (!sections) return { doc, dropped: false };
|
|
73
|
+
let dropped = false;
|
|
74
|
+
const kept = Object.fromEntries(
|
|
75
|
+
Object.entries(sections).map(([id, section]) => {
|
|
76
|
+
if (!isRecord(section) || !('citations' in section)) return [id, section];
|
|
77
|
+
dropped = true;
|
|
78
|
+
const { citations: _citations, ...rest } = section;
|
|
79
|
+
return [id, rest];
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
return { doc: { ...doc, data: { ...(doc.data as Record<string, unknown>), sections: kept } }, dropped };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** How much of the two documents the bundle carries. */
|
|
86
|
+
export type DocumentDepth = 'full' | 'budget' | 'sectionIds';
|
|
87
|
+
|
|
41
88
|
/** Pages of 100 the backlog part follows before it stops and says so. */
|
|
42
89
|
const BACKLOG_PAGE_LIMIT = 10;
|
|
43
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Open items the bundle carries, the newest of those read; past this it says truncated and
|
|
93
|
+
* taiga_list_backlog_items pages the rest. Keeps a product with hundreds of
|
|
94
|
+
* open initiatives inside the tool result the document budget is sized for.
|
|
95
|
+
*/
|
|
96
|
+
const BACKLOG_IN_CONTEXT = 50;
|
|
97
|
+
|
|
44
98
|
const PARTS = {
|
|
45
99
|
factory: 'factories:read',
|
|
46
100
|
documents: 'products:read',
|
|
@@ -96,11 +150,11 @@ async function factoryPart(client: TaigaClient, factoryId: string) {
|
|
|
96
150
|
}
|
|
97
151
|
|
|
98
152
|
/**
|
|
99
|
-
* The specification and architecture from the product's own timeline:
|
|
100
|
-
* by default, or as section ids when
|
|
101
|
-
* with a note, where unpublished.
|
|
153
|
+
* The specification and architecture from the product's own timeline: their
|
|
154
|
+
* whole sections up to a budget by default, in full or as section ids when
|
|
155
|
+
* asked. Null, with a note, where unpublished.
|
|
102
156
|
*/
|
|
103
|
-
async function documentsPart(client: TaigaClient, product: Record<string, unknown>,
|
|
157
|
+
async function documentsPart(client: TaigaClient, product: Record<string, unknown>, depth: DocumentDepth) {
|
|
104
158
|
const productId = String(product.id);
|
|
105
159
|
const origin = defaultOriginFor(product as ProductSettingsView);
|
|
106
160
|
const reads = await Promise.allSettled(
|
|
@@ -112,8 +166,29 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
|
|
|
112
166
|
const read = reads[index]!;
|
|
113
167
|
if (read.status === 'fulfilled') {
|
|
114
168
|
const doc = stripProvenance(read.value.data as Record<string, unknown>);
|
|
115
|
-
if (full) out[type] = doc;
|
|
116
|
-
else {
|
|
169
|
+
if (depth === 'full') out[type] = doc;
|
|
170
|
+
else if (depth === 'budget') {
|
|
171
|
+
const lean = withoutCitations(doc);
|
|
172
|
+
if (lean.dropped) {
|
|
173
|
+
notes.push(
|
|
174
|
+
`The ${origin} ${type} leaves out each section's citations (its code evidence); taiga_get_product_document carries them.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const cut = budgetSections(lean.doc, DOCUMENT_BUDGET);
|
|
178
|
+
if (!cut) {
|
|
179
|
+
// Not made of sections: whole when small, else pointed at.
|
|
180
|
+
const small = JSON.stringify(lean.doc).length <= DOCUMENT_BUDGET;
|
|
181
|
+
out[type] = small ? lean.doc : { version: doc.version, sections: null };
|
|
182
|
+
if (!small)
|
|
183
|
+
notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
|
|
184
|
+
} else if (cut.omittedSections.length > 0) {
|
|
185
|
+
out[type] = { ...cut.doc, omittedSections: cut.omittedSections };
|
|
186
|
+
notes.push(
|
|
187
|
+
`The ${origin} ${type} carries the whole sections that fit; read the ones under omittedSections with ` +
|
|
188
|
+
'taiga_get_product_document, naming them in sections.'
|
|
189
|
+
);
|
|
190
|
+
} else out[type] = cut.doc;
|
|
191
|
+
} else {
|
|
117
192
|
const sections = sectionsOf((doc.data as Record<string, unknown>) ?? {});
|
|
118
193
|
if (sections) out[type] = { version: doc.version, sections: Object.keys(sections) };
|
|
119
194
|
else {
|
|
@@ -158,11 +233,13 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
158
233
|
// two reads would otherwise appear twice.
|
|
159
234
|
const open = new Map<string, Record<string, unknown>>();
|
|
160
235
|
let truncated = false;
|
|
236
|
+
let pageCapped = false;
|
|
161
237
|
for (const status of OPEN_BACKLOG_STATUSES) {
|
|
162
238
|
let cursor: string | undefined;
|
|
163
239
|
for (let pageIndex = 0; ; pageIndex += 1) {
|
|
164
240
|
if (pageIndex === BACKLOG_PAGE_LIMIT) {
|
|
165
241
|
truncated = true;
|
|
242
|
+
pageCapped = true;
|
|
166
243
|
break;
|
|
167
244
|
}
|
|
168
245
|
const result = await client.listBacklogItems(productId, { status, limit: 100, cursor });
|
|
@@ -177,7 +254,26 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
177
254
|
cursor = result.nextCursor;
|
|
178
255
|
}
|
|
179
256
|
}
|
|
180
|
-
|
|
257
|
+
// One read per status, so the rows arrive grouped by status, not by age.
|
|
258
|
+
// Ids are UUIDv7, time-ordered: sorted by id, the newest are the last.
|
|
259
|
+
const rows = [...open.values()].sort((a, b) =>
|
|
260
|
+
String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0
|
|
261
|
+
);
|
|
262
|
+
if (rows.length > BACKLOG_IN_CONTEXT) truncated = true;
|
|
263
|
+
return {
|
|
264
|
+
items: itemRows(rows.slice(-BACKLOG_IN_CONTEXT), BACKLOG_SUMMARY_FIELDS),
|
|
265
|
+
truncated,
|
|
266
|
+
// The list pages oldest first and takes no order, so a status past the
|
|
267
|
+
// page cap was read only up to its oldest items: these are the newest of
|
|
268
|
+
// what was read, not of the backlog.
|
|
269
|
+
...(pageCapped
|
|
270
|
+
? {
|
|
271
|
+
note:
|
|
272
|
+
`More than ${BACKLOG_PAGE_LIMIT * 100} open items in one status: only the oldest of them were read, so ` +
|
|
273
|
+
'the newest may be missing here; taiga_list_backlog_items pages the rest.',
|
|
274
|
+
}
|
|
275
|
+
: {}),
|
|
276
|
+
};
|
|
181
277
|
}
|
|
182
278
|
|
|
183
279
|
/**
|
|
@@ -194,13 +290,13 @@ async function repositoriesPart(client: TaigaClient, productId: string) {
|
|
|
194
290
|
|
|
195
291
|
async function buildsPart(client: TaigaClient, productId: string) {
|
|
196
292
|
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
|
|
197
|
-
return pickFields(rows,
|
|
293
|
+
return pickFields(rows, CONTEXT_BUILD_FIELDS);
|
|
198
294
|
}
|
|
199
295
|
|
|
200
296
|
export async function productContext(
|
|
201
297
|
client: TaigaClient,
|
|
202
298
|
product: Record<string, unknown>,
|
|
203
|
-
options: {
|
|
299
|
+
options: { documents: DocumentDepth }
|
|
204
300
|
): Promise<CallToolResult> {
|
|
205
301
|
const productId = String(product.id);
|
|
206
302
|
const granted = (part: Part) => client.hasCapability(PARTS[part]);
|
|
@@ -208,7 +304,7 @@ export async function productContext(
|
|
|
208
304
|
|
|
209
305
|
const fetchers: Record<Part, () => Promise<unknown>> = {
|
|
210
306
|
factory: () => factoryPart(client, String(product.factoryId)),
|
|
211
|
-
documents: () => documentsPart(client, product, options.
|
|
307
|
+
documents: () => documentsPart(client, product, options.documents),
|
|
212
308
|
policies: () => policiesPart(client),
|
|
213
309
|
backlog: () => backlogPart(client, productId),
|
|
214
310
|
repositories: () => repositoriesPart(client, productId),
|
|
@@ -231,14 +327,23 @@ export async function productContext(
|
|
|
231
327
|
else if (isApiFailure(result.reason)) unavailable[part] = unavailableReason(result.reason);
|
|
232
328
|
else throw result.reason;
|
|
233
329
|
});
|
|
234
|
-
|
|
330
|
+
const result = {
|
|
235
331
|
...bundle,
|
|
236
332
|
notGranted: notGranted.map((part) => `${part} (needs ${PARTS[part]})`),
|
|
237
333
|
unavailable,
|
|
238
334
|
readNext:
|
|
239
335
|
'Read a policy in full with taiga_get_document, another product document with taiga_get_product_document, ' +
|
|
240
|
-
'a backlog item with taiga_get_backlog_item and a run
|
|
241
|
-
|
|
336
|
+
'a backlog item with taiga_get_backlog_item (taiga_list_backlog_items pages a truncated backlog) and a run ' +
|
|
337
|
+
'with taiga_get_build.',
|
|
338
|
+
};
|
|
339
|
+
// Its own size, so a model can judge what one more read would cost: counted
|
|
340
|
+
// with the field in place, until the count no longer changes its own length.
|
|
341
|
+
let characters = JSON.stringify(result, null, 2).length;
|
|
342
|
+
for (let measured = -1; measured !== characters;) {
|
|
343
|
+
measured = characters;
|
|
344
|
+
characters = JSON.stringify({ ...result, characters: measured }, null, 2).length;
|
|
345
|
+
}
|
|
346
|
+
return toolResult({ ...result, characters });
|
|
242
347
|
}
|
|
243
348
|
|
|
244
349
|
export function registerContextTools(server: McpServer, client: TaigaClient) {
|
|
@@ -254,17 +359,21 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
|
|
|
254
359
|
'every published policy as its section ids, the open backlog items, the linked repositories and the ' +
|
|
255
360
|
'latest runs. Name the product by ID, slug or the git remote URL of the checkout. A part the API refused ' +
|
|
256
361
|
'or failed is named under unavailable with what it said; a part the key cannot read is named under ' +
|
|
257
|
-
'notGranted; neither is shown as empty. The two documents
|
|
258
|
-
|
|
259
|
-
'read
|
|
362
|
+
'notGranted; neither is shown as empty. The two documents bring as many whole sections as fit, about ' +
|
|
363
|
+
`${DOCUMENT_BUDGET / 1000}k characters each, and name the sections left out (omittedSections) for ` +
|
|
364
|
+
'taiga_get_product_document to read; fullDocuments: true carries them whole, tens of thousands of ' +
|
|
365
|
+
'tokens on a large product and more than some clients accept, and false carries their section ids only. ' +
|
|
366
|
+
'characters says how large the result is. Call this first, then read what you need.',
|
|
260
367
|
inputSchema: {
|
|
261
368
|
productId: productId.optional(),
|
|
262
369
|
slug: slug.optional(),
|
|
263
370
|
remoteUrl: remoteUrl.optional(),
|
|
264
371
|
fullDocuments: z
|
|
265
372
|
.boolean()
|
|
266
|
-
.
|
|
267
|
-
.describe(
|
|
373
|
+
.optional()
|
|
374
|
+
.describe(
|
|
375
|
+
'true carries the specification and architecture whole, false as section ids only; omit for the whole sections that fit a budget'
|
|
376
|
+
),
|
|
268
377
|
},
|
|
269
378
|
annotations: READ_ONLY,
|
|
270
379
|
},
|
|
@@ -272,7 +381,8 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
|
|
|
272
381
|
runTool(async () => {
|
|
273
382
|
const resolved = await resolveProductRef(client, params, ['productId', 'slug', 'remoteUrl']);
|
|
274
383
|
if ('refusal' in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
|
|
275
|
-
|
|
384
|
+
const documents = params.fullDocuments === undefined ? 'budget' : params.fullDocuments ? 'full' : 'sectionIds';
|
|
385
|
+
return productContext(client, resolved.product, { documents });
|
|
276
386
|
})
|
|
277
387
|
);
|
|
278
388
|
}
|
package/src/tools/errors.ts
CHANGED
|
@@ -85,11 +85,11 @@ export async function runTool(handler: () => Promise<CallToolResult>): Promise<C
|
|
|
85
85
|
* "MCP error -32602:" and the client would prefix it again.
|
|
86
86
|
*/
|
|
87
87
|
export class RequestError extends Error {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
) {
|
|
88
|
+
readonly code: number;
|
|
89
|
+
|
|
90
|
+
constructor(code: number, message: string) {
|
|
92
91
|
super(message);
|
|
92
|
+
this.code = code;
|
|
93
93
|
this.name = 'RequestError';
|
|
94
94
|
}
|
|
95
95
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
import type { TaigaClient } from '../client.js';
|
|
3
4
|
import { registerProductTools } from './products.js';
|
|
4
5
|
import { registerDocumentTools } from './documents.js';
|
|
@@ -6,18 +7,45 @@ import { registerBacklogTools } from './backlogs.js';
|
|
|
6
7
|
import { registerBuildTools } from './builds.js';
|
|
7
8
|
import { registerContextTools } from './context.js';
|
|
8
9
|
import { registerPrompts } from './prompts.js';
|
|
10
|
+
import { registerQueueTools } from './queue.js';
|
|
9
11
|
import { registerResources } from './resources.js';
|
|
12
|
+
import { DEFAULT_WAIT_OPTIONS, registerWaitTools, type WaitOptions } from './wait.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every tool refuses an argument it does not take. A plain object schema
|
|
16
|
+
* strips one silently: a review note passed to a tool with no place for it
|
|
17
|
+
* vanished and the call still succeeded, and a misspelt option quietly
|
|
18
|
+
* became its default. Strict, the call fails with "Unrecognized key" and the
|
|
19
|
+
* model learns the tool's real shape. Applied here, once, so no tool can be
|
|
20
|
+
* registered without it.
|
|
21
|
+
*/
|
|
22
|
+
function withStrictInputs(server: McpServer): McpServer {
|
|
23
|
+
const register = server.registerTool.bind(server) as (...args: unknown[]) => unknown;
|
|
24
|
+
server.registerTool = ((name: string, config: { inputSchema?: unknown }, callback: unknown) => {
|
|
25
|
+
const shape = config.inputSchema;
|
|
26
|
+
const isRawShape = typeof shape === 'object' && shape !== null && !('_zod' in shape);
|
|
27
|
+
return register(
|
|
28
|
+
name,
|
|
29
|
+
isRawShape ? { ...config, inputSchema: z.strictObject(shape as z.ZodRawShape) } : config,
|
|
30
|
+
callback
|
|
31
|
+
);
|
|
32
|
+
}) as unknown as McpServer['registerTool'];
|
|
33
|
+
return server;
|
|
34
|
+
}
|
|
10
35
|
|
|
11
36
|
/**
|
|
12
37
|
* Register all MCP tools, resources and prompts based on the API key's effective capabilities.
|
|
13
38
|
* Only tools matching the key's scopes are registered -- the AI assistant
|
|
14
39
|
* never sees tools it can't use.
|
|
15
40
|
*/
|
|
16
|
-
export function registerAllTools(server: McpServer, client: TaigaClient): void {
|
|
41
|
+
export function registerAllTools(server: McpServer, client: TaigaClient, options: { wait?: WaitOptions } = {}): void {
|
|
42
|
+
withStrictInputs(server);
|
|
17
43
|
registerProductTools(server, client);
|
|
18
44
|
registerDocumentTools(server, client);
|
|
19
45
|
registerBacklogTools(server, client);
|
|
20
46
|
registerBuildTools(server, client);
|
|
47
|
+
registerQueueTools(server, client);
|
|
48
|
+
registerWaitTools(server, client, options.wait ?? DEFAULT_WAIT_OPTIONS);
|
|
21
49
|
registerContextTools(server, client);
|
|
22
50
|
registerResources(server, client);
|
|
23
51
|
registerPrompts(server, client);
|