@taiga-ai/mcp-server 0.18.0-dev.450 → 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 +1036 -628
- package/package.json +1 -1
- package/src/client.ts +11 -0
- package/src/modern.integration.test.ts +1 -1
- package/src/server.integration.test.ts +767 -232
- package/src/tools/autonomy.ts +2 -2
- package/src/tools/backlogs.ts +164 -51
- package/src/tools/builds.ts +134 -47
- package/src/tools/catalogue.test.ts +23 -17
- package/src/tools/context.ts +98 -30
- package/src/tools/documents.ts +4 -1
- package/src/tools/errors.ts +40 -2
- package/src/tools/hints.ts +34 -9
- package/src/tools/inbox.ts +151 -28
- package/src/tools/index.ts +15 -1
- package/src/tools/instructions.ts +32 -154
- package/src/tools/maintenance.ts +44 -18
- package/src/tools/product-documents.ts +1 -1
- package/src/tools/products.ts +7 -5
- package/src/tools/prompts.ts +7 -7
- package/src/tools/queue.ts +12 -12
- package/src/tools/readiness.ts +172 -0
- package/src/tools/repositories.ts +4 -1
- package/src/tools/resources.ts +6 -6
- package/src/tools/sections.ts +13 -1
- package/src/tools/setup.ts +30 -30
- package/src/tools/wait.ts +13 -7
- package/taiga-ai-mcp-server-0.19.0-dev.451.tgz +0 -0
- package/taiga-ai-mcp-server-0.18.0-dev.450.tgz +0 -0
package/src/tools/builds.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
type BuildRunStep,
|
|
5
|
+
type BuildRunWithContext,
|
|
6
|
+
BUILD_RUN_STATUSES,
|
|
7
|
+
dispositionOf,
|
|
8
|
+
type BuildRunStatus,
|
|
9
|
+
} from '@taiga/types';
|
|
4
10
|
import type { TaigaClient, WithoutTenant } from '../client.js';
|
|
5
11
|
import { OPERATE, READ_ONLY } from './annotations.js';
|
|
6
12
|
import { idInput, pagination, productId } from './inputs.js';
|
|
@@ -44,7 +50,9 @@ const RUN_OUTCOME_FIELDS = [
|
|
|
44
50
|
'failedReason',
|
|
45
51
|
'completedSteps',
|
|
46
52
|
'totalSteps',
|
|
53
|
+
'prNumber',
|
|
47
54
|
'prUrl',
|
|
55
|
+
'prMerged',
|
|
48
56
|
'actions',
|
|
49
57
|
] as const;
|
|
50
58
|
|
|
@@ -81,10 +89,80 @@ export function stoppedIn(
|
|
|
81
89
|
return 'after the steps and hardening, at the pull request';
|
|
82
90
|
}
|
|
83
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The disposition with the merge latch authoritative: a run whose pull
|
|
94
|
+
* request merged landed, whatever settled status it kept when the completion
|
|
95
|
+
* write after the merge failed (build.ts allows a merged run to stay failed
|
|
96
|
+
* or stopped). A run still executing keeps its in-flight disposition.
|
|
97
|
+
*/
|
|
98
|
+
export function landedDisposition(status: BuildRunStatus, prMerged: boolean): string {
|
|
99
|
+
const disposition = dispositionOf(status, prMerged);
|
|
100
|
+
return prMerged && disposition !== 'in_flight' ? 'succeeded' : disposition;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What each executing status is doing, in a phrase. */
|
|
104
|
+
const RUNNING: Partial<Record<BuildRunStatus, string>> = {
|
|
105
|
+
cloning: 'setting up the workspace',
|
|
106
|
+
implementing: 'implementing the steps',
|
|
107
|
+
hardening: 'hardening: running the security scanners and fixing what they find',
|
|
108
|
+
pushing: 'pushing the branch',
|
|
109
|
+
ci_checking: "waiting for the pull request's CI",
|
|
110
|
+
ci_fixing: 'fixing what CI found',
|
|
111
|
+
reviewing: 'answering review comments on the pull request',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How a run stands, in one sentence, with its disposition: the meaning the
|
|
116
|
+
* status and the pull request facts carry between them
|
|
117
|
+
* (PRO-2800). An agent read `closed` with failedReason null, every step
|
|
118
|
+
* completed and hardening passed, and could not tell what had happened; the
|
|
119
|
+
* status alone cannot say whether a completed run merged (dispositionOf).
|
|
120
|
+
*/
|
|
121
|
+
export function runOutcome(run: Record<string, unknown>): { disposition: string; outcome: string } | null {
|
|
122
|
+
const status = run['status'] as BuildRunStatus;
|
|
123
|
+
if (!(BUILD_RUN_STATUSES as readonly string[]).includes(status)) return null;
|
|
124
|
+
const disposition = landedDisposition(status, run['prMerged'] === true);
|
|
125
|
+
const pr = typeof run['prNumber'] === 'number' ? `PR #${run['prNumber']}` : 'its pull request';
|
|
126
|
+
const progress =
|
|
127
|
+
typeof run['completedSteps'] === 'number' && typeof run['totalSteps'] === 'number'
|
|
128
|
+
? ` (${run['completedSteps']} of ${run['totalSteps']} steps done)`
|
|
129
|
+
: '';
|
|
130
|
+
const reason = typeof run['failedReason'] === 'string' && run['failedReason'] ? `: ${run['failedReason']}` : '';
|
|
131
|
+
const sentence: Record<string, string> = {
|
|
132
|
+
in_flight: status === 'pending' ? 'Queued: waiting to start.' : `Running: ${RUNNING[status] ?? status}${progress}.`,
|
|
133
|
+
gated: `Waiting for a person to review and merge ${pr}.`,
|
|
134
|
+
succeeded: `Merged: ${pr} is in ${typeof run['baseBranch'] === 'string' ? run['baseBranch'] : 'the base branch'}.`,
|
|
135
|
+
unmerged:
|
|
136
|
+
typeof run['prNumber'] === 'number' || run['prUrl']
|
|
137
|
+
? `Settled without merging: ${pr} was left unmerged.`
|
|
138
|
+
: 'Settled without a merged pull request.',
|
|
139
|
+
failed: `Failed${reason}${progress}.`,
|
|
140
|
+
stopped: `Stopped before it finished${progress}.`,
|
|
141
|
+
rejected: `${pr} was closed without merging, so the work did not land.`,
|
|
142
|
+
};
|
|
143
|
+
return { disposition, outcome: sentence[disposition] ?? status };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A run row as an agent reads it: its outcome added, and prDraft only where
|
|
148
|
+
* there is a pull request. The column defaults to true, so a run that never
|
|
149
|
+
* opened one said it had a draft (PRO-2800).
|
|
150
|
+
*/
|
|
151
|
+
export function readableRun(run: Record<string, unknown>): Record<string, unknown> {
|
|
152
|
+
const { prDraft, ...rest } = run;
|
|
153
|
+
const said = runOutcome(run);
|
|
154
|
+
return {
|
|
155
|
+
...rest,
|
|
156
|
+
// A PR number proves a PR exists (isSettledWithoutPr); the URL is nullable apart from it.
|
|
157
|
+
...(typeof rest['prNumber'] === 'number' || rest['prUrl'] ? { prDraft } : {}),
|
|
158
|
+
...(said ?? {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
84
162
|
/** How many products the org-wide list names; past this a row keeps its productId only. */
|
|
85
163
|
const NAMED_PRODUCTS = 100;
|
|
86
164
|
|
|
87
|
-
const
|
|
165
|
+
const runId = idInput('run', 'taiga_list_runs gives it');
|
|
88
166
|
|
|
89
167
|
/** A step without its instructions: what it is, how it stands and what it changed. */
|
|
90
168
|
const STEP_SUMMARY_FIELDS = [
|
|
@@ -139,10 +217,12 @@ async function activityRows(
|
|
|
139
217
|
for (const product of products?.data ?? []) {
|
|
140
218
|
nameById.set(product.id, { name: product.name, slug: product.slug });
|
|
141
219
|
}
|
|
142
|
-
const rows = pickFields(runs.data, BUILD_SUMMARY_FIELDS)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
220
|
+
const rows = pickFields(runs.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS)
|
|
221
|
+
.map(readableRun)
|
|
222
|
+
.map((row) => {
|
|
223
|
+
const product = nameById.get(String(row['productId']));
|
|
224
|
+
return product ? { ...row, product } : row;
|
|
225
|
+
});
|
|
146
226
|
const unnamed = rows.some((row) => !('product' in row));
|
|
147
227
|
// Why a row stayed unnamed, by what the products read said: it failed, it
|
|
148
228
|
// was cut before that product, or it listed everything the key can see and
|
|
@@ -161,24 +241,23 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
161
241
|
if (!client.hasCapability('products:read')) return;
|
|
162
242
|
|
|
163
243
|
server.registerTool(
|
|
164
|
-
'
|
|
244
|
+
'taiga_list_runs',
|
|
165
245
|
{
|
|
166
|
-
title: 'List
|
|
246
|
+
title: 'List runs',
|
|
167
247
|
description:
|
|
168
|
-
"List the coding agent's runs, newest first
|
|
169
|
-
'
|
|
170
|
-
'
|
|
171
|
-
'
|
|
172
|
-
'
|
|
173
|
-
'the runs of
|
|
174
|
-
'
|
|
175
|
-
'
|
|
176
|
-
'A run of a deleted initiative keeps its title.',
|
|
248
|
+
"List the coding agent's runs, newest first. A run is one attempt at building an initiative: its branch, " +
|
|
249
|
+
'pull request and step progress. Each row says how it stands in one sentence (outcome) and as a ' +
|
|
250
|
+
'disposition: in_flight (the agent is working), gated (a person owes a review and merge), succeeded ' +
|
|
251
|
+
'(merged), failed, stopped, rejected (the pull request was closed without merging) or unmerged. actions ' +
|
|
252
|
+
'name what a person may do with it. With productId, the runs of that product, optionally of one ' +
|
|
253
|
+
'initiative; without it, the runs of every product the key can see, each row naming its product, and ' +
|
|
254
|
+
'active: true narrows to the runs executing now. taiga_get_run reads one with its steps. Work is carried ' +
|
|
255
|
+
'on from the initiative (taiga_resume_initiative), not the run.',
|
|
177
256
|
inputSchema: z.object({
|
|
178
257
|
productId: productId.optional(),
|
|
179
|
-
|
|
258
|
+
initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it')
|
|
180
259
|
.optional()
|
|
181
|
-
.describe("Only this
|
|
260
|
+
.describe("Only this initiative's runs -- get from taiga_list_initiatives; needs productId"),
|
|
182
261
|
active: z
|
|
183
262
|
.boolean()
|
|
184
263
|
.optional()
|
|
@@ -192,10 +271,10 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
192
271
|
},
|
|
193
272
|
(params) =>
|
|
194
273
|
runTool(async () => {
|
|
195
|
-
if (!params.productId && params.
|
|
274
|
+
if (!params.productId && params.initiativeId) {
|
|
196
275
|
return toolError(
|
|
197
276
|
'VALIDATION_ERROR',
|
|
198
|
-
'
|
|
277
|
+
'initiativeId narrows the runs of one product: pass its productId as well (the initiative carries it).'
|
|
199
278
|
);
|
|
200
279
|
}
|
|
201
280
|
// The product route has no such filter; a silently ignored one would
|
|
@@ -207,29 +286,38 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
207
286
|
);
|
|
208
287
|
}
|
|
209
288
|
if (params.productId) {
|
|
210
|
-
const result = await client.listBuildRuns(params.productId,
|
|
211
|
-
|
|
289
|
+
const result = await client.listBuildRuns(params.productId, {
|
|
290
|
+
backlogItemId: params.initiativeId,
|
|
291
|
+
limit: params.limit,
|
|
292
|
+
cursor: params.cursor,
|
|
293
|
+
});
|
|
294
|
+
return toolResult(
|
|
295
|
+
page(
|
|
296
|
+
pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS).map(readableRun),
|
|
297
|
+
result.nextCursor
|
|
298
|
+
)
|
|
299
|
+
);
|
|
212
300
|
}
|
|
213
301
|
return toolResult(await activityRows(client, params));
|
|
214
302
|
})
|
|
215
303
|
);
|
|
216
304
|
|
|
217
305
|
server.registerTool(
|
|
218
|
-
'
|
|
306
|
+
'taiga_get_run',
|
|
219
307
|
{
|
|
220
|
-
title: 'Get
|
|
308
|
+
title: 'Get run',
|
|
221
309
|
description:
|
|
222
|
-
|
|
223
|
-
'status, commit, files changed,
|
|
224
|
-
"reported.
|
|
225
|
-
'
|
|
226
|
-
'
|
|
227
|
-
|
|
228
|
-
'
|
|
229
|
-
'
|
|
230
|
-
'Build. A maintenance run has no initiative and is retried from its finding.',
|
|
310
|
+
'Get one run with its steps: how it stands (outcome, disposition), its pull request and hardening, and ' +
|
|
311
|
+
"each step's title, status, commit, files changed, verification and headline, the first paragraph of " +
|
|
312
|
+
"what the agent reported. A step's verification is Taiga's own check after the step (tests, lint, build), " +
|
|
313
|
+
'independent of what the agent says it ran: a docs-only step can show tests failed because the ' +
|
|
314
|
+
"repository's tests fail. A run that failed or was stopped says where (stoppedIn) and when it last made " +
|
|
315
|
+
"progress (lastProgressAt). Pass full: true for each step's complete instructions and report, several " +
|
|
316
|
+
'thousand tokens on a long run. Its actions name what the initiative accepts: resume and ' +
|
|
317
|
+
'retry_from_failed_step are taiga_resume_initiative; start_over is in the web only; to stop it, move the ' +
|
|
318
|
+
'initiative out of Build. A maintenance run has no initiative and is retried from its finding.',
|
|
231
319
|
inputSchema: z.object({
|
|
232
|
-
|
|
320
|
+
runId,
|
|
233
321
|
full: z
|
|
234
322
|
.boolean()
|
|
235
323
|
.optional()
|
|
@@ -242,16 +330,16 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
242
330
|
// Two routes, one answer: fanned out here rather than in the model's
|
|
243
331
|
// context, like the six-tool pattern asks.
|
|
244
332
|
const [run, steps] = await Promise.all([
|
|
245
|
-
client.getBuildRun(params.
|
|
246
|
-
client.listBuildRunSteps(params.
|
|
333
|
+
client.getBuildRun(params.runId),
|
|
334
|
+
client.listBuildRunSteps(params.runId),
|
|
247
335
|
]);
|
|
248
336
|
const runRow = run.data;
|
|
249
337
|
const rows = steps.data;
|
|
250
338
|
const where = stoppedIn(runRow, rows);
|
|
251
339
|
const stop = where ? { stoppedIn: where } : {};
|
|
252
|
-
if (params.full) return toolResult({ ...runRow, ...stop, steps: steps.data });
|
|
340
|
+
if (params.full) return toolResult({ ...readableRun(runRow), ...stop, steps: steps.data });
|
|
253
341
|
return toolResult({
|
|
254
|
-
...runRow,
|
|
342
|
+
...readableRun(runRow),
|
|
255
343
|
...stop,
|
|
256
344
|
steps: pickFields(rows, STEP_SUMMARY_FIELDS).map((step, index) => ({
|
|
257
345
|
...step,
|
|
@@ -264,23 +352,22 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
264
352
|
if (!client.hasCapability('products:write')) return;
|
|
265
353
|
|
|
266
354
|
server.registerTool(
|
|
267
|
-
'
|
|
355
|
+
'taiga_stop_run',
|
|
268
356
|
{
|
|
269
|
-
title: 'Stop a
|
|
357
|
+
title: 'Stop a run',
|
|
270
358
|
description:
|
|
271
359
|
'The emergency brake: stop a run the coding agent is executing when its initiative cannot be moved. The ' +
|
|
272
|
-
'way to stop work is on the initiative: move it out of Build (
|
|
360
|
+
'way to stop work is on the initiative: move it out of Build (taiga_move_initiative), which stops the ' +
|
|
273
361
|
'plan or run in flight. Use this only for a run whose initiative does not offer that (it is finished, ' +
|
|
274
362
|
'canceled or gone) yet the run is still going. The agent halts, its branch and any draft pull request ' +
|
|
275
|
-
'stay. Only a run whose actions include stop; the API refuses the rest with CONFLICT.
|
|
276
|
-
|
|
277
|
-
inputSchema: z.object({ buildId }),
|
|
363
|
+
'stay. Only a run whose actions include stop; the API refuses the rest with CONFLICT.',
|
|
364
|
+
inputSchema: z.object({ runId }),
|
|
278
365
|
annotations: OPERATE,
|
|
279
366
|
},
|
|
280
367
|
(params) =>
|
|
281
368
|
runTool(async () => {
|
|
282
|
-
const result = await client.stopBuildRun(params.
|
|
283
|
-
return toolResult(pickFields([result.data], RUN_OUTCOME_FIELDS)[0]);
|
|
369
|
+
const result = await client.stopBuildRun(params.runId);
|
|
370
|
+
return toolResult(readableRun(pickFields([result.data as Record<string, unknown>], RUN_OUTCOME_FIELDS)[0]!));
|
|
284
371
|
})
|
|
285
372
|
);
|
|
286
373
|
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from '@taiga/types';
|
|
11
11
|
import { TaigaClient } from '../client.js';
|
|
12
12
|
import { registerAllTools, writeNotice } from './index.js';
|
|
13
|
-
import {
|
|
13
|
+
import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_LIMIT } from './instructions.js';
|
|
14
14
|
import { PRODUCT_DOCUMENT_NAMES } from './catalogue.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -108,7 +108,6 @@ describe('tool descriptions against the catalogue', () => {
|
|
|
108
108
|
|
|
109
109
|
for (const type of POLICY_TYPES) {
|
|
110
110
|
expect(list.description, type).toContain(type);
|
|
111
|
-
expect(SERVER_INSTRUCTIONS, type).toContain(type);
|
|
112
111
|
}
|
|
113
112
|
expect(typeEnum).toEqual([...TENANT_DOCUMENT_TYPES]);
|
|
114
113
|
});
|
|
@@ -130,23 +129,30 @@ describe('tool descriptions against the catalogue', () => {
|
|
|
130
129
|
expect(PRODUCT_DOCUMENT_NAMES.ui_direction).toBe('Look & Feel');
|
|
131
130
|
});
|
|
132
131
|
|
|
133
|
-
it('explains both timelines
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
it('explains both timelines where a product document is read', async () => {
|
|
133
|
+
const tools = await allTools();
|
|
134
|
+
const origin = (
|
|
135
|
+
tools.find((t) => t.name === 'taiga_get_product_document')!.inputSchema.properties as Record<
|
|
136
|
+
string,
|
|
137
|
+
{ description?: string }
|
|
138
|
+
>
|
|
139
|
+
)['origin']!.description;
|
|
140
|
+
expect(origin).toMatch(/plan: .*discovery/);
|
|
141
|
+
expect(origin).toMatch(/reality: .*merged pull request/);
|
|
141
142
|
});
|
|
142
143
|
|
|
143
|
-
it('
|
|
144
|
-
// Claude Code
|
|
145
|
-
//
|
|
146
|
-
expect(SERVER_INSTRUCTIONS.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
144
|
+
it('fits the whole instructions inside what Claude Code keeps, and says where to start and when to ask', () => {
|
|
145
|
+
// Claude Code loads only tool names and the instructions until the model
|
|
146
|
+
// searches, and cuts the instructions at 2,048 characters (PRO-2800).
|
|
147
|
+
expect(SERVER_INSTRUCTIONS.length).toBeLessThanOrEqual(SERVER_INSTRUCTIONS_LIMIT);
|
|
148
|
+
for (const must of [
|
|
149
|
+
'taiga_get_product_context',
|
|
150
|
+
'Before you write',
|
|
151
|
+
'NOT_FOUND',
|
|
152
|
+
'initiative',
|
|
153
|
+
'Only in the web',
|
|
154
|
+
]) {
|
|
155
|
+
expect(SERVER_INSTRUCTIONS).toContain(must);
|
|
150
156
|
}
|
|
151
157
|
});
|
|
152
158
|
});
|
package/src/tools/context.ts
CHANGED
|
@@ -4,9 +4,17 @@ import type { BacklogItem, Product } from '@taiga/types';
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
6
6
|
import { READ_ONLY } from './annotations.js';
|
|
7
|
-
import {
|
|
8
|
-
import { POLICY_TYPES, defaultOriginFor, isImportedProduct
|
|
9
|
-
import {
|
|
7
|
+
import { situatedRows } from './backlogs.js';
|
|
8
|
+
import { POLICY_TYPES, defaultOriginFor, isImportedProduct } from './catalogue.js';
|
|
9
|
+
import {
|
|
10
|
+
apiFailureText,
|
|
11
|
+
inProductWords,
|
|
12
|
+
isNetworkFailure,
|
|
13
|
+
networkFailureDetail,
|
|
14
|
+
runTool,
|
|
15
|
+
toolError,
|
|
16
|
+
toolResult,
|
|
17
|
+
} from './errors.js';
|
|
10
18
|
import { productId, remoteUrl, slug } from './inputs.js';
|
|
11
19
|
import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
|
|
12
20
|
import {
|
|
@@ -19,7 +27,10 @@ import {
|
|
|
19
27
|
type DocumentData,
|
|
20
28
|
type StoredDocument,
|
|
21
29
|
} from './sections.js';
|
|
22
|
-
import {
|
|
30
|
+
import { pickFields } from './summarize.js';
|
|
31
|
+
import { readableRun } from './builds.js';
|
|
32
|
+
import { setupReadiness } from './readiness.js';
|
|
33
|
+
import { standingSummary } from './maintenance.js';
|
|
23
34
|
|
|
24
35
|
/**
|
|
25
36
|
* One call at the start of a coding session: everything the model would
|
|
@@ -43,10 +54,10 @@ const REPOSITORY_SUMMARY_FIELDS = [
|
|
|
43
54
|
'isManaged',
|
|
44
55
|
] as const;
|
|
45
56
|
|
|
46
|
-
/** How many of the latest runs to carry;
|
|
57
|
+
/** How many of the latest runs to carry; taiga_list_runs pages the rest. */
|
|
47
58
|
const BUILDS_IN_CONTEXT = 10;
|
|
48
59
|
|
|
49
|
-
/** 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. */
|
|
50
61
|
const CONTEXT_BUILD_FIELDS = [
|
|
51
62
|
'id',
|
|
52
63
|
'runNumber',
|
|
@@ -56,6 +67,8 @@ const CONTEXT_BUILD_FIELDS = [
|
|
|
56
67
|
'failedReason',
|
|
57
68
|
'completedSteps',
|
|
58
69
|
'totalSteps',
|
|
70
|
+
'baseBranch',
|
|
71
|
+
'prNumber',
|
|
59
72
|
'prUrl',
|
|
60
73
|
'prState',
|
|
61
74
|
'prMerged',
|
|
@@ -105,7 +118,7 @@ const BACKLOG_PAGE_LIMIT = 10;
|
|
|
105
118
|
|
|
106
119
|
/**
|
|
107
120
|
* Open items the bundle carries, the newest of those read; past this it says truncated and
|
|
108
|
-
*
|
|
121
|
+
* taiga_list_initiatives pages the rest. Keeps a product with hundreds of
|
|
109
122
|
* open initiatives inside the tool result the document budget is sized for.
|
|
110
123
|
*/
|
|
111
124
|
const BACKLOG_IN_CONTEXT = 50;
|
|
@@ -117,6 +130,9 @@ const PARTS = {
|
|
|
117
130
|
backlog: 'backlog:read',
|
|
118
131
|
repositories: 'products:read',
|
|
119
132
|
builds: 'products:read',
|
|
133
|
+
attention: 'products:read',
|
|
134
|
+
maintenance: 'products:read',
|
|
135
|
+
standing: 'products:read',
|
|
120
136
|
} as const;
|
|
121
137
|
type Part = keyof typeof PARTS;
|
|
122
138
|
|
|
@@ -281,7 +297,7 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
281
297
|
);
|
|
282
298
|
if (rows.length > BACKLOG_IN_CONTEXT) truncated = true;
|
|
283
299
|
return {
|
|
284
|
-
items:
|
|
300
|
+
items: situatedRows(rows.slice(-BACKLOG_IN_CONTEXT)),
|
|
285
301
|
truncated,
|
|
286
302
|
// The list pages oldest first and takes no order, so a status past the
|
|
287
303
|
// page cap was read only up to its oldest items: these are the newest of
|
|
@@ -290,7 +306,7 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
290
306
|
? {
|
|
291
307
|
note:
|
|
292
308
|
`More than ${BACKLOG_PAGE_LIMIT * 100} open items in one status: only the oldest of them were read, so ` +
|
|
293
|
-
'the newest may be missing here;
|
|
309
|
+
'the newest may be missing here; taiga_list_initiatives pages the rest.',
|
|
294
310
|
}
|
|
295
311
|
: {}),
|
|
296
312
|
};
|
|
@@ -309,8 +325,53 @@ async function repositoriesPart(client: TaigaClient, productId: string) {
|
|
|
309
325
|
}
|
|
310
326
|
|
|
311
327
|
async function buildsPart(client: TaigaClient, productId: string) {
|
|
312
|
-
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data;
|
|
313
|
-
return pickFields(rows, CONTEXT_BUILD_FIELDS);
|
|
328
|
+
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
|
|
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 };
|
|
314
375
|
}
|
|
315
376
|
|
|
316
377
|
export async function productContext(
|
|
@@ -329,18 +390,23 @@ export async function productContext(
|
|
|
329
390
|
backlog: () => backlogPart(client, productId),
|
|
330
391
|
repositories: () => repositoriesPart(client, productId),
|
|
331
392
|
builds: () => buildsPart(client, productId),
|
|
393
|
+
attention: () => attentionPart(client, productId),
|
|
394
|
+
maintenance: () => maintenancePart(client, productId),
|
|
395
|
+
standing: () => standingPart(client, productId),
|
|
332
396
|
};
|
|
333
397
|
const parts = (Object.keys(fetchers) as Part[]).filter(granted);
|
|
334
|
-
const settled = await Promise.
|
|
398
|
+
const [settled, readiness] = await Promise.all([
|
|
399
|
+
Promise.allSettled(parts.map((part) => fetchers[part]())),
|
|
400
|
+
setupReadiness(client, product),
|
|
401
|
+
]);
|
|
335
402
|
|
|
336
|
-
const nextStep = setupNextStep(product);
|
|
337
403
|
const bundle: Record<string, unknown> = {
|
|
338
404
|
product: {
|
|
339
405
|
...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
|
|
340
406
|
projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
|
|
341
407
|
defaultOrigin: defaultOriginFor(product),
|
|
342
|
-
// A product in setup says what it
|
|
343
|
-
...(
|
|
408
|
+
// A product in setup says what it still needs: nothing else in the bundle does.
|
|
409
|
+
...(readiness ? { readiness } : {}),
|
|
344
410
|
},
|
|
345
411
|
};
|
|
346
412
|
const unavailable: Record<string, string> = {};
|
|
@@ -357,15 +423,16 @@ export async function productContext(
|
|
|
357
423
|
readNext:
|
|
358
424
|
'Read a policy with taiga_get_document (sections: the ids listed here, for only those), the ' +
|
|
359
425
|
"specification's or architecture's sections or another product document with taiga_get_product_document, " +
|
|
360
|
-
'
|
|
361
|
-
'with
|
|
426
|
+
'an initiative with taiga_get_initiative (taiga_list_initiatives pages a truncated backlog) and a run ' +
|
|
427
|
+
'with taiga_get_run.',
|
|
362
428
|
};
|
|
363
429
|
// Its own size, so a model can judge what one more read would cost: counted
|
|
364
430
|
// with the field in place, until the count no longer changes its own length.
|
|
365
|
-
|
|
431
|
+
// Measured as sent: toolResult renames keys into the product's words.
|
|
432
|
+
let characters = JSON.stringify(inProductWords(result), null, 2).length;
|
|
366
433
|
for (let measured = -1; measured !== characters;) {
|
|
367
434
|
measured = characters;
|
|
368
|
-
characters = JSON.stringify({ ...result, characters: measured }, null, 2).length;
|
|
435
|
+
characters = JSON.stringify(inProductWords({ ...result, characters: measured }), null, 2).length;
|
|
369
436
|
}
|
|
370
437
|
return toolResult({ ...result, characters });
|
|
371
438
|
}
|
|
@@ -378,17 +445,18 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
|
|
|
378
445
|
{
|
|
379
446
|
title: 'Get product context',
|
|
380
447
|
description:
|
|
381
|
-
'
|
|
382
|
-
'
|
|
383
|
-
'
|
|
384
|
-
'
|
|
385
|
-
'
|
|
386
|
-
'
|
|
387
|
-
'
|
|
388
|
-
|
|
389
|
-
'
|
|
390
|
-
|
|
391
|
-
'
|
|
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.',
|
|
392
460
|
inputSchema: z.object({
|
|
393
461
|
productId: productId.optional(),
|
|
394
462
|
slug: slug.optional(),
|
package/src/tools/documents.ts
CHANGED
|
@@ -184,7 +184,10 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
184
184
|
'ids taiga_get_product_context lists for every policy, or sections: [] for the outline) and the read ' +
|
|
185
185
|
'carries only those, listing the rest with their sizes and any id the document lacks ' +
|
|
186
186
|
'(unknownSections). A read that finds no document says which documents are published and whether a ' +
|
|
187
|
-
|
|
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.',
|
|
188
191
|
inputSchema: z.object({
|
|
189
192
|
type: tenantDocumentType,
|
|
190
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
|
}
|