@taiga-ai/mcp-server 0.12.0-dev.421 → 0.13.0-dev.422
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 +348 -85
- package/package.json +1 -1
- package/src/client.ts +34 -21
- package/src/server.integration.test.ts +295 -92
- package/src/tools/annotations.ts +6 -0
- package/src/tools/backlogs.ts +118 -58
- package/src/tools/builds.ts +13 -29
- package/src/tools/index.ts +2 -0
- package/src/tools/instructions.ts +19 -13
- package/src/tools/jobs.ts +89 -0
- package/src/tools/queue.ts +61 -4
- package/src/tools/request-prompt.test.ts +32 -0
- package/src/tools/request-prompt.ts +28 -0
- package/src/tools/wait.ts +38 -0
- package/taiga-ai-mcp-server-0.13.0-dev.422.tgz +0 -0
- package/taiga-ai-mcp-server-0.12.0-dev.421.tgz +0 -0
package/src/tools/backlogs.ts
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
PLAN_REJECTION_COMMENT_MAX_CHARS,
|
|
7
|
-
PR_CLOSING_STATES,
|
|
8
|
-
resolveAutoBuild,
|
|
9
|
-
} from '@taiga/types';
|
|
10
|
-
import { OPERATE, READ_ONLY, WRITE } from './annotations.js';
|
|
4
|
+
import { PLAN_REJECTION_COMMENT_MAX_CHARS, PR_CLOSING_STATES, USER_DROP_STATES, resolveAutoBuild } from '@taiga/types';
|
|
5
|
+
import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
11
6
|
import { pagination, productId } from './inputs.js';
|
|
12
7
|
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
8
|
+
import { jobReport } from './jobs.js';
|
|
9
|
+
import { requestPrompt } from './request-prompt.js';
|
|
13
10
|
import { queueView } from './queue.js';
|
|
14
11
|
import { criteriaCounts, itemRows, page, pickFields } from './summarize.js';
|
|
15
12
|
|
|
@@ -74,23 +71,32 @@ const outcome = (item: unknown) => toolResult(outcomeOf(item));
|
|
|
74
71
|
|
|
75
72
|
/**
|
|
76
73
|
* Each tool is registered under the capability its route requires, so a
|
|
77
|
-
* key sees exactly what it may call: reads on backlog:read
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* resume and the review gate on products:write alone, as
|
|
81
|
-
* (the item ID they take also comes back from a
|
|
82
|
-
* products:write together with backlog:read,
|
|
83
|
-
* comes only from the plan read
|
|
84
|
-
*
|
|
74
|
+
* key sees exactly what it may call: reads on backlog:read; the board move on
|
|
75
|
+
* backlog:write; queueing on both backlog:write and products:write (the
|
|
76
|
+
* state route checks both for a drop into queue); an initiative request
|
|
77
|
+
* (an agent job), the resume and the review gate on products:write alone, as
|
|
78
|
+
* their routes are (the item ID they take also comes back from a request or a
|
|
79
|
+
* queue); the plan verdicts on products:write together with backlog:read,
|
|
80
|
+
* because the plan ID they take comes only from the plan read; and Plan again
|
|
81
|
+
* on the state route's two (backlog:write, then products:write for a move
|
|
82
|
+
* into plan) together with backlog:read, because it reads the initiative's
|
|
83
|
+
* place at the Build station first.
|
|
84
|
+
*
|
|
85
|
+
* The tools follow how Taiga is meant to be used (docs.tai.ga, Deliver):
|
|
86
|
+
* new work is described and an agent writes the initiative; the queue is
|
|
87
|
+
* the only way to start work; a run is stopped by moving its initiative out
|
|
88
|
+
* of Build and carried on from the initiative.
|
|
85
89
|
*/
|
|
86
90
|
export function registerBacklogTools(server: McpServer, client: TaigaClient) {
|
|
87
91
|
const may = (capability: string) => client.hasCapability(capability);
|
|
88
92
|
if (may('backlog:read')) registerBacklogReads(server, client);
|
|
89
|
-
if (may('backlog:write'))
|
|
93
|
+
if (may('backlog:write')) registerMove(server, client);
|
|
94
|
+
if (may('products:write')) registerRequest(server, client);
|
|
90
95
|
if (may('backlog:write') && may('products:write')) registerQueue(server, client);
|
|
91
96
|
if (may('products:write')) registerResume(server, client);
|
|
92
97
|
if (may('products:write')) registerReviewGate(server, client);
|
|
93
98
|
if (may('backlog:read') && may('products:write')) registerPlanVerdicts(server, client);
|
|
99
|
+
if (may('backlog:read') && may('backlog:write') && may('products:write')) registerPlanAgain(server, client);
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
@@ -179,59 +185,71 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
179
185
|
);
|
|
180
186
|
}
|
|
181
187
|
|
|
182
|
-
function
|
|
188
|
+
function registerRequest(server: McpServer, client: TaigaClient) {
|
|
183
189
|
server.registerTool(
|
|
184
|
-
'
|
|
190
|
+
'taiga_request_initiatives',
|
|
185
191
|
{
|
|
186
|
-
title: '
|
|
192
|
+
title: 'Request new initiatives',
|
|
187
193
|
description:
|
|
188
|
-
'
|
|
189
|
-
'
|
|
190
|
-
'
|
|
191
|
-
'
|
|
194
|
+
'Add work the way Taiga adds it: describe it, and an agent turns the request into initiatives in the ' +
|
|
195
|
+
"product's Backlog. The agent reads the product documents and its repository, expands the request into a " +
|
|
196
|
+
'full brief, and sizes it: one piece of work stays one initiative, a program of work comes back as an ordered ' +
|
|
197
|
+
'set with its dependencies linked. It can also add nothing, and says why: an existing initiative already ' +
|
|
198
|
+
'covers the request, the request is not readable as work, it is outside what the product builds, or one of ' +
|
|
199
|
+
"the organization's policies does not allow it as asked (named, with the line it runs into and the way to " +
|
|
200
|
+
'decide otherwise). Leave both title and description out and the agent suggests the one next initiative the ' +
|
|
201
|
+
'product most needs. It runs in the background, a few minutes: this returns the job, and taiga_wait_for_job ' +
|
|
202
|
+
'or taiga_get_job (both need products:read) say what came of it. One request per product at a time ' +
|
|
203
|
+
'(CONFLICT while one runs). Inline images in the description are left out; the web is where a request ' +
|
|
204
|
+
'carries images. It starts nothing on the line: new initiatives land in Backlog. Runs an agent; ask the ' +
|
|
205
|
+
'person first.',
|
|
192
206
|
inputSchema: {
|
|
193
207
|
productId,
|
|
194
|
-
title: z.string().
|
|
208
|
+
title: z.string().max(500).optional().describe('What the work is, in a line'),
|
|
195
209
|
description: z
|
|
196
210
|
.string()
|
|
197
|
-
.min(1)
|
|
198
211
|
.max(20_000)
|
|
199
|
-
.describe('The brief in Markdown: the problem, what it looks like, what it covers and leaves alone'),
|
|
200
|
-
category: z
|
|
201
|
-
.enum(['feature', 'bug', 'tech_debt', 'improvement'])
|
|
202
212
|
.optional()
|
|
203
|
-
.describe('
|
|
204
|
-
priority: z.number().int().min(1).optional().describe('1 is the most urgent (default 3)'),
|
|
205
|
-
estimate: z.number().int().min(0).optional().describe('Story points, 0 for unestimated (default 0)'),
|
|
213
|
+
.describe('The request in Markdown: the problem, what it should look like, what it covers and leaves alone'),
|
|
206
214
|
},
|
|
207
|
-
annotations:
|
|
215
|
+
annotations: OPERATE,
|
|
208
216
|
},
|
|
209
217
|
(params) =>
|
|
210
218
|
runTool(async () => {
|
|
211
|
-
const
|
|
212
|
-
|
|
219
|
+
const userPrompt = requestPrompt(params.title, params.description);
|
|
220
|
+
const job = (
|
|
221
|
+
await client.createAgentJob({
|
|
222
|
+
scope: 'product',
|
|
223
|
+
type: 'generate_backlog',
|
|
224
|
+
targetId: params.productId,
|
|
225
|
+
...(userPrompt ? { metadata: { userPrompt } } : {}),
|
|
226
|
+
})
|
|
227
|
+
).data as Record<string, unknown>;
|
|
228
|
+
return toolResult(jobReport(job));
|
|
213
229
|
})
|
|
214
230
|
);
|
|
231
|
+
}
|
|
215
232
|
|
|
233
|
+
function registerMove(server: McpServer, client: TaigaClient) {
|
|
216
234
|
server.registerTool(
|
|
217
235
|
'taiga_move_backlog_item',
|
|
218
236
|
{
|
|
219
237
|
title: 'Move backlog item on the board',
|
|
220
238
|
description:
|
|
221
|
-
'Move an initiative
|
|
222
|
-
'queue puts it in the line (taiga_queue_backlog_item says more);
|
|
223
|
-
|
|
224
|
-
'
|
|
225
|
-
'
|
|
226
|
-
'
|
|
227
|
-
'
|
|
228
|
-
|
|
229
|
-
'
|
|
230
|
-
'
|
|
231
|
-
'out of lists unless includeArchived asks for it.',
|
|
239
|
+
'Move an initiative on the board, or order it within a column: backlog and todo are yours and the line ' +
|
|
240
|
+
'never moves an initiative out of them; queue puts it in the line (taiga_queue_backlog_item says more); ' +
|
|
241
|
+
'done and canceled finish it by hand. The queue is the only way to start work: there is no "plan this ' +
|
|
242
|
+
'now", and nothing is moved into Build by a person. Moving an initiative out of Build stops the plan ' +
|
|
243
|
+
'being written or the run in flight: that is how work is stopped, and it throws that work away. A move to ' +
|
|
244
|
+
`${PR_CLOSING_STATES.join(', ')} CLOSES a pull request the initiative is still waiting on (a move to queue ` +
|
|
245
|
+
'keeps it, and done leaves it to be merged); Taiga leaves a comment on the pull request saying why. The ' +
|
|
246
|
+
'state machine refuses a move that is not allowed (CONFLICT). A move into queue starts paid work and ' +
|
|
247
|
+
'needs products:write. Ask the person before a move that starts work, stops work or closes a pull request. ' +
|
|
248
|
+
'archived: true files the initiative where it stands (pass its current column as state; never from the Build station, plan or build) ' +
|
|
249
|
+
'and archived: false brings it back; archived work is left out of lists unless includeArchived asks for it.',
|
|
232
250
|
inputSchema: {
|
|
233
251
|
itemId,
|
|
234
|
-
state: z.enum(
|
|
252
|
+
state: z.enum(USER_DROP_STATES).describe('The column to move into'),
|
|
235
253
|
afterId: z
|
|
236
254
|
.string()
|
|
237
255
|
.uuid()
|
|
@@ -248,13 +266,13 @@ function registerBacklogWrites(server: McpServer, client: TaigaClient) {
|
|
|
248
266
|
(params) =>
|
|
249
267
|
runTool(async () => {
|
|
250
268
|
const { itemId: id, ...body } = params;
|
|
251
|
-
// What the route checks on top of backlog:write for
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
if (
|
|
269
|
+
// What the route checks on top of backlog:write for a drop into the
|
|
270
|
+
// queue; refused here so the model learns which capability, not just
|
|
271
|
+
// that it was forbidden.
|
|
272
|
+
if (body.state === 'queue' && !client.hasCapability('products:write')) {
|
|
255
273
|
return toolError(
|
|
256
274
|
'FORBIDDEN',
|
|
257
|
-
|
|
275
|
+
'A move into queue starts agent work and needs products:write, which this key lacks.'
|
|
258
276
|
);
|
|
259
277
|
}
|
|
260
278
|
return outcome((await client.transitionInitiative(id, body)).data);
|
|
@@ -262,8 +280,50 @@ function registerBacklogWrites(server: McpServer, client: TaigaClient) {
|
|
|
262
280
|
);
|
|
263
281
|
}
|
|
264
282
|
|
|
265
|
-
/** The
|
|
266
|
-
const
|
|
283
|
+
/** The two halves of the Build station, where Plan again is offered. */
|
|
284
|
+
const BUILD_STATION: ReadonlySet<string> = new Set(['plan', 'build']);
|
|
285
|
+
|
|
286
|
+
function registerPlanAgain(server: McpServer, client: TaigaClient) {
|
|
287
|
+
server.registerTool(
|
|
288
|
+
'taiga_plan_again',
|
|
289
|
+
{
|
|
290
|
+
title: 'Plan an initiative again',
|
|
291
|
+
description:
|
|
292
|
+
'Supersede the plan of the initiative at the Build station and ask the planner for a new one: for a plan ' +
|
|
293
|
+
'that failed, a plan you do not want built, or a run whose direction was wrong once it has stopped. The ' +
|
|
294
|
+
'earlier plan is kept, and every run records which plan version it built; a pull request still open ' +
|
|
295
|
+
'stays open. Offered only when the turn is yours: refused (CONFLICT) while an agent holds it (planning, ' +
|
|
296
|
+
'building, reviewing). Also refused while a dependency is still in flight or a UI direction has no ' +
|
|
297
|
+
'chosen concept, unless force says to plan anyway, which only the person decides. Anywhere off the ' +
|
|
298
|
+
'Build station, put the initiative in the queue instead (taiga_queue_backlog_item): the queue is the ' +
|
|
299
|
+
'only way to start work. To send a plan back with direction, taiga_reject_plan. Starts a planner run; ' +
|
|
300
|
+
'ask the person first.',
|
|
301
|
+
inputSchema: {
|
|
302
|
+
itemId,
|
|
303
|
+
force: z
|
|
304
|
+
.boolean()
|
|
305
|
+
.optional()
|
|
306
|
+
.describe(
|
|
307
|
+
'Plan anyway past an unsettled dependency or an unchosen UI direction; only when the person says so'
|
|
308
|
+
),
|
|
309
|
+
},
|
|
310
|
+
annotations: OPERATE,
|
|
311
|
+
},
|
|
312
|
+
(params) =>
|
|
313
|
+
runTool(async () => {
|
|
314
|
+
const item = (await client.getBacklogItem(params.itemId)).data as Record<string, unknown>;
|
|
315
|
+
if (!BUILD_STATION.has(String(item.state))) {
|
|
316
|
+
return toolError(
|
|
317
|
+
'CONFLICT',
|
|
318
|
+
`This initiative is in ${String(item.state)}, not at the Build station. The queue is the only way to ` +
|
|
319
|
+
'start work: put it in the queue with taiga_queue_backlog_item and it is planned when its turn comes.'
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const body = { state: 'plan', ...(params.force ? { force: true } : {}) };
|
|
323
|
+
return outcome((await client.transitionInitiative(params.itemId, body)).data);
|
|
324
|
+
})
|
|
325
|
+
);
|
|
326
|
+
}
|
|
267
327
|
|
|
268
328
|
/** Whether a failure is the API's (refused, missing, unreachable) rather than a bug here. */
|
|
269
329
|
const isApiFailure = (error: unknown) => error instanceof TaigaApiError || isNetworkFailure(error);
|
|
@@ -385,7 +445,7 @@ function registerReviewGate(server: McpServer, client: TaigaClient) {
|
|
|
385
445
|
'initiative gets, including one being written now. Opening the gate on a plan that is already waiting ' +
|
|
386
446
|
'starts its build, so ask the person first. Returns the initiative with the gate that now applies.',
|
|
387
447
|
inputSchema: { itemId, autoBuild: autoBuildInput },
|
|
388
|
-
annotations:
|
|
448
|
+
annotations: OPERATE_SETTING,
|
|
389
449
|
},
|
|
390
450
|
(params) =>
|
|
391
451
|
runTool(async () => {
|
|
@@ -407,10 +467,10 @@ function registerResume(server: McpServer, client: TaigaClient) {
|
|
|
407
467
|
title: 'Resume backlog item',
|
|
408
468
|
description:
|
|
409
469
|
'Resume an initiative the line stopped on (needsHumanCause is set): a failed or cancelled plan is ' +
|
|
410
|
-
'planned again, a stopped or failed run resumes from its first incomplete step
|
|
411
|
-
'is cleared and retried.
|
|
412
|
-
'
|
|
413
|
-
'Starts paid work; ask the person first.',
|
|
470
|
+
'planned again, a stopped or failed run resumes from its first incomplete step (the steps already ' +
|
|
471
|
+
'committed are kept), a refused build start is cleared and retried. This is the way to carry work on: a ' +
|
|
472
|
+
'run is the record of an attempt, and decisions about the work are made on the initiative. Refused ' +
|
|
473
|
+
'(CONFLICT) when nothing is flagged or the cause has no resume. Starts paid work; ask the person first.',
|
|
414
474
|
inputSchema: { itemId },
|
|
415
475
|
annotations: OPERATE,
|
|
416
476
|
},
|
package/src/tools/builds.ts
CHANGED
|
@@ -132,8 +132,10 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
132
132
|
title: 'List build runs',
|
|
133
133
|
description:
|
|
134
134
|
"List the coding agent's runs, newest first: status, branch, pull request, step progress and the " +
|
|
135
|
-
'actions a person may take on each
|
|
136
|
-
'
|
|
135
|
+
'actions a person may take on each. A run is the record of an attempt: work is carried on from its ' +
|
|
136
|
+
'initiative (resume and retry_from_failed_step are taiga_resume_backlog_item; start_over is in the web ' +
|
|
137
|
+
'only for now) and stopped by moving the initiative out of Build; a maintenance run, which has no ' +
|
|
138
|
+
'initiative, is retried from its finding. With productId, ' +
|
|
137
139
|
'the runs of that product, optionally of one backlog item; without it, the runs of every product the ' +
|
|
138
140
|
'key can see, each row naming its product, and active: true narrows to the runs executing right now. ' +
|
|
139
141
|
'Use this to see what Taiga already built or is building, then read one run with taiga_get_build. ' +
|
|
@@ -191,8 +193,9 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
191
193
|
'status, commit, files changed, checks, any error and headline, the first paragraph of what the agent ' +
|
|
192
194
|
"reported. Pass full: true for each step's complete instructions and report, several thousand tokens on " +
|
|
193
195
|
'a long run. Use this to pick up where the coding agent stopped, or to understand the pull request it ' +
|
|
194
|
-
'opened. Its actions
|
|
195
|
-
'
|
|
196
|
+
'opened. Its actions name what the initiative accepts for it: resume and retry_from_failed_step are ' +
|
|
197
|
+
'taiga_resume_backlog_item, start_over is in the web only for now; to stop it, move the initiative out of ' +
|
|
198
|
+
'Build. A maintenance run has no initiative and is retried from its finding.',
|
|
196
199
|
inputSchema: {
|
|
197
200
|
buildId,
|
|
198
201
|
full: z
|
|
@@ -229,10 +232,12 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
229
232
|
{
|
|
230
233
|
title: 'Stop a build run',
|
|
231
234
|
description:
|
|
232
|
-
'
|
|
233
|
-
'
|
|
234
|
-
'
|
|
235
|
-
'
|
|
235
|
+
'The emergency brake: stop a run the coding agent is executing when its initiative cannot be moved. The ' +
|
|
236
|
+
'way to stop work is on the initiative: move it out of Build (taiga_move_backlog_item), which stops the ' +
|
|
237
|
+
'plan or run in flight. Use this only for a run whose initiative does not offer that (it is finished, ' +
|
|
238
|
+
'canceled or gone) yet the run is still going. The agent halts, its branch and any draft pull request ' +
|
|
239
|
+
'stay. Only a run whose actions include stop; the API refuses the rest with CONFLICT. Ask the person ' +
|
|
240
|
+
'before calling this: it interrupts work in progress.',
|
|
236
241
|
inputSchema: { buildId },
|
|
237
242
|
annotations: OPERATE,
|
|
238
243
|
},
|
|
@@ -242,25 +247,4 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
242
247
|
return toolResult(pickFields([result.data as Record<string, unknown>], RUN_OUTCOME_FIELDS)[0]);
|
|
243
248
|
})
|
|
244
249
|
);
|
|
245
|
-
|
|
246
|
-
server.registerTool(
|
|
247
|
-
'taiga_resume_build',
|
|
248
|
-
{
|
|
249
|
-
title: 'Resume a build run',
|
|
250
|
-
description:
|
|
251
|
-
'Resume a stopped or failed run from its first incomplete step: the agent starts again on the same ' +
|
|
252
|
-
'branch, which spends tokens and may push commits and open a pull request. This is the route behind ' +
|
|
253
|
-
'both the resume and the retry_from_failed_step actions: the API accepts a run whose actions include ' +
|
|
254
|
-
'one of them and refuses the rest with CONFLICT. start_over (a new run) is web-only for now. For an initiative ' +
|
|
255
|
-
'the line stopped on, ' +
|
|
256
|
-
'taiga_resume_backlog_item does whatever its cause needs instead. Ask the person before calling this.',
|
|
257
|
-
inputSchema: { buildId },
|
|
258
|
-
annotations: OPERATE,
|
|
259
|
-
},
|
|
260
|
-
(params) =>
|
|
261
|
-
runTool(async () => {
|
|
262
|
-
const result = await client.resumeBuildRun(params.buildId);
|
|
263
|
-
return toolResult(pickFields([result.data as Record<string, unknown>], RUN_OUTCOME_FIELDS)[0]);
|
|
264
|
-
})
|
|
265
|
-
);
|
|
266
250
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { registerDocumentTools } from './documents.js';
|
|
|
6
6
|
import { registerBacklogTools } from './backlogs.js';
|
|
7
7
|
import { registerBuildTools } from './builds.js';
|
|
8
8
|
import { registerContextTools } from './context.js';
|
|
9
|
+
import { registerJobTools } from './jobs.js';
|
|
9
10
|
import { registerPrompts } from './prompts.js';
|
|
10
11
|
import { registerQueueTools } from './queue.js';
|
|
11
12
|
import { registerResources } from './resources.js';
|
|
@@ -45,6 +46,7 @@ export function registerAllTools(server: McpServer, client: TaigaClient, options
|
|
|
45
46
|
registerBacklogTools(server, client);
|
|
46
47
|
registerBuildTools(server, client);
|
|
47
48
|
registerQueueTools(server, client);
|
|
49
|
+
registerJobTools(server, client);
|
|
48
50
|
registerWaitTools(server, client, options.wait ?? DEFAULT_WAIT_OPTIONS);
|
|
49
51
|
registerContextTools(server, client);
|
|
50
52
|
registerResources(server, client);
|
|
@@ -46,20 +46,26 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
46
46
|
'for them: runs, steps, commits and pull requests. taiga_list_builds without a product lists the runs of',
|
|
47
47
|
'every product the key can see, and active: true the ones executing now.',
|
|
48
48
|
'',
|
|
49
|
-
'**Operating the line**
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
'
|
|
49
|
+
'**Operating the line** works the way Taiga is meant to be used. New work is described, not written:',
|
|
50
|
+
'taiga_request_initiatives asks an agent, which reads the product and its repository, expands the request',
|
|
51
|
+
'into initiatives in the Backlog (or says why it added none: already covered, not work, out of scope, or a',
|
|
52
|
+
'policy that does not allow it) -- follow it with taiga_wait_for_job. The queue is the only way to start',
|
|
53
|
+
'work (taiga_queue_backlog_item): Taiga plans and builds one initiative at a time, in queue order, and starts',
|
|
54
|
+
'the next when the pull request merges. Whether a landed plan waits for a review is the review gate',
|
|
55
|
+
'(taiga_set_auto_build, or autoBuild on the queue); read it with taiga_get_plan and answer with',
|
|
56
|
+
'taiga_approve_plan or taiga_reject_plan. Decisions about the work are made on the initiative: stop work by',
|
|
57
|
+
'moving it out of Build (taiga_move_backlog_item), carry it on with taiga_resume_backlog_item, ask for a new',
|
|
58
|
+
'plan with taiga_plan_again; a run is the record of an attempt. taiga_pause_line pauses the whole line.',
|
|
59
|
+
'taiga_stop_build is the emergency brake for a run whose initiative cannot be moved. Queueing, approving,',
|
|
60
|
+
'resuming and requesting start paid agent work; stopping and pausing interrupt it. Tell the person what you',
|
|
61
|
+
'are about to start or stop and wait for their yes; a client that prompts on destructive tools does that',
|
|
62
|
+
'for you. A refusal (CONFLICT) carries the API reason: say it as it is.',
|
|
57
63
|
'',
|
|
58
|
-
'**Watching the line** (taiga_get_queue / taiga_wait_for_backlog_item / taiga_wait_for_build
|
|
59
|
-
'read says what the line is waiting on (a
|
|
60
|
-
'it). Planning takes minutes and a build up to an
|
|
61
|
-
'same item again and again; each returns when the
|
|
62
|
-
'or at its timeout (call again to keep waiting).',
|
|
64
|
+
'**Watching the line** (taiga_get_queue / taiga_wait_for_backlog_item / taiga_wait_for_build /',
|
|
65
|
+
'taiga_get_job / taiga_wait_for_job): the queue read says what the line is waiting on (a paused line, a plan',
|
|
66
|
+
'to review, a pull request to merge, a halt and what lifts it). Planning takes minutes and a build up to an',
|
|
67
|
+
'hour: wait with the wait tools rather than reading the same item again and again; each returns when the',
|
|
68
|
+
'point is reached, when only a person can move it on, or at its timeout (call again to keep waiting).',
|
|
63
69
|
'',
|
|
64
70
|
'**Sections.** Policies and instructions are maps of sections. Each section carries its text and two',
|
|
65
71
|
'provenance fields you can read past: `source` says where the text originally came from (template:',
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { createdInitiativeIdsOf, createdInitiativesOf, isBacklogGenerationEmptyResult } from '@taiga/types';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { TaigaClient } from '../client.js';
|
|
5
|
+
import { READ_ONLY } from './annotations.js';
|
|
6
|
+
import { runTool, toolResult } from './errors.js';
|
|
7
|
+
import { pickFields } from './summarize.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Agent jobs: the background work a person asks an agent for (an initiative
|
|
11
|
+
* request, a document, a refresh). The job is the record of that ask: where
|
|
12
|
+
* it is, and what came of it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type Row = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
/** What a job says about itself: its kind, how far it got, and why it failed. */
|
|
18
|
+
const JOB_FIELDS = [
|
|
19
|
+
'id',
|
|
20
|
+
'type',
|
|
21
|
+
'documentType',
|
|
22
|
+
'targetId',
|
|
23
|
+
'status',
|
|
24
|
+
'progress',
|
|
25
|
+
'progressMessage',
|
|
26
|
+
'error',
|
|
27
|
+
'queuePosition',
|
|
28
|
+
'createdAt',
|
|
29
|
+
'startedAt',
|
|
30
|
+
'completedAt',
|
|
31
|
+
'cancelledAt',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/** A job that will not change again: it finished, failed, or was cancelled. */
|
|
35
|
+
export const SETTLED_JOB_STATUSES: ReadonlySet<string> = new Set(['completed', 'failed', 'cancelled']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What an initiative request came to, read off its finished job: the
|
|
39
|
+
* initiatives it added, or the answer that added none (the backlog already
|
|
40
|
+
* covers it, it is not work to build, it is outside the product, or one of
|
|
41
|
+
* the organization's policies does not allow it as asked, with the policy's
|
|
42
|
+
* own words and the way to decide otherwise). Null while the job runs, and
|
|
43
|
+
* for a job that is not an initiative request.
|
|
44
|
+
*/
|
|
45
|
+
function requestOutcome(job: Row): Row | null {
|
|
46
|
+
if (job.type !== 'generate_backlog' || job.status !== 'completed') return null;
|
|
47
|
+
const created = createdInitiativesOf(job.result);
|
|
48
|
+
if (created) return { outcome: 'created', initiatives: created };
|
|
49
|
+
// A job finished before the result carried titles has the ids alone.
|
|
50
|
+
const ids = createdInitiativeIdsOf(job.result);
|
|
51
|
+
if (ids && ids.length > 0) return { outcome: 'created', initiatives: ids.map((id) => ({ id })) };
|
|
52
|
+
if (isBacklogGenerationEmptyResult(job.result)) {
|
|
53
|
+
const result = job.result;
|
|
54
|
+
return {
|
|
55
|
+
outcome: result.outcome,
|
|
56
|
+
message: result.message,
|
|
57
|
+
coveredBy: result.coveredInitiative,
|
|
58
|
+
policy: result.reason ?? null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { outcome: 'unknown', note: 'The job finished without a result this server can read.' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A job as the tools hand it over: its own fields, and for an initiative request, what came of it. */
|
|
65
|
+
export function jobReport(job: Row): Row {
|
|
66
|
+
const outcome = requestOutcome(job);
|
|
67
|
+
return { ...pickFields([job], JOB_FIELDS)[0], ...(outcome ? { outcome } : {}) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function registerJobTools(server: McpServer, client: TaigaClient) {
|
|
71
|
+
if (!client.hasCapability('products:read')) return;
|
|
72
|
+
|
|
73
|
+
server.registerTool(
|
|
74
|
+
'taiga_get_job',
|
|
75
|
+
{
|
|
76
|
+
title: 'Get an agent job',
|
|
77
|
+
description:
|
|
78
|
+
'Read one agent job: background work a person asked an agent for, such as an initiative request ' +
|
|
79
|
+
'(taiga_request_initiatives). It says where the job is (pending, processing, completed, failed, cancelled), ' +
|
|
80
|
+
'its progress and any error. A finished initiative request also says what came of it: the initiatives it ' +
|
|
81
|
+
'added, or why it added none (already covered by an existing initiative, not readable as work, outside ' +
|
|
82
|
+
'the product, or not allowed by a policy, with the policy, the line it runs into and the way to decide ' +
|
|
83
|
+
'otherwise). taiga_wait_for_job waits for a job to finish.',
|
|
84
|
+
inputSchema: { jobId: z.string().uuid().describe('Agent job ID -- a request tool returns it') },
|
|
85
|
+
annotations: READ_ONLY,
|
|
86
|
+
},
|
|
87
|
+
(params) => runTool(async () => toolResult(jobReport((await client.getAgentJob(params.jobId)).data as Row)))
|
|
88
|
+
);
|
|
89
|
+
}
|
package/src/tools/queue.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { HALT_CAUSES, canResumeHalt, haltClearsItself, renderHalt, type Halt, type HaltCause } from '@taiga/types';
|
|
3
3
|
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
4
|
-
import {
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
5
6
|
import { isNetworkFailure, runTool, toolResult } from './errors.js';
|
|
6
7
|
import { productId } from './inputs.js';
|
|
7
8
|
import { criteriaCounts } from './summarize.js';
|
|
@@ -84,7 +85,8 @@ export async function queueView(
|
|
|
84
85
|
product: string,
|
|
85
86
|
options: { name: 'all' | 'blocking' } = { name: 'all' }
|
|
86
87
|
) {
|
|
87
|
-
const
|
|
88
|
+
const [queue, paused] = await Promise.all([client.listQueue(product), linePausedSince(client, product)]);
|
|
89
|
+
const entries = (queue.data as Row[]).filter((entry) => entry.state !== 'settled');
|
|
88
90
|
const canName = client.hasCapability('backlog:read');
|
|
89
91
|
// 'blocking' names only the entry the line waits on: a caller after one
|
|
90
92
|
// position and waitingOn need no more reads than that.
|
|
@@ -144,7 +146,31 @@ export async function queueView(
|
|
|
144
146
|
: entries.length > NAMED_ENTRIES
|
|
145
147
|
? `Only the first ${NAMED_ENTRIES} entries are named; the rest carry backlogItemId only.`
|
|
146
148
|
: 'An entry whose initiative could not be read carries backlogItemId only; taiga_get_backlog_item reads it.';
|
|
147
|
-
|
|
149
|
+
// A paused line answers before anything in it: nothing is planned or built until it is resumed.
|
|
150
|
+
const line =
|
|
151
|
+
typeof paused === 'string'
|
|
152
|
+
? `The line is paused (since ${paused}): nothing is planned or built until a person resumes it.`
|
|
153
|
+
: null;
|
|
154
|
+
return {
|
|
155
|
+
linePaused: paused === null ? null : typeof paused === 'string',
|
|
156
|
+
entries: rows,
|
|
157
|
+
waitingOn: line ?? waitingOn(rows),
|
|
158
|
+
...(note ? { note } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* When the product's line was paused, false when it runs, null when the
|
|
164
|
+
* product cannot be read (the queue is still worth reading without it).
|
|
165
|
+
*/
|
|
166
|
+
async function linePausedSince(client: TaigaClient, product: string): Promise<string | false | null> {
|
|
167
|
+
try {
|
|
168
|
+
const row = (await client.getProduct(product)).data as { lineStoppedAt?: unknown };
|
|
169
|
+
return typeof row.lineStoppedAt === 'string' ? row.lineStoppedAt : false;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
148
174
|
}
|
|
149
175
|
|
|
150
176
|
export function registerQueueTools(server: McpServer, client: TaigaClient) {
|
|
@@ -157,7 +183,8 @@ export function registerQueueTools(server: McpServer, client: TaigaClient) {
|
|
|
157
183
|
description:
|
|
158
184
|
"Read a product's queue, the line the coding agent works one initiative at a time: each entry in order " +
|
|
159
185
|
'with its title, where it is (planning, building, in review) and whose turn it is, a halted entry with why ' +
|
|
160
|
-
'it stopped and what lifts it,
|
|
186
|
+
'it stopped and what lifts it, linePaused (null when the product could not be read), and waitingOn, one ' +
|
|
187
|
+
'sentence saying what the line is waiting for. The ' +
|
|
161
188
|
"line advances only when the active entry's pull request merges, so an entry waiting on a review holds " +
|
|
162
189
|
'everything behind it. Use this when a queued initiative is not moving.',
|
|
163
190
|
inputSchema: { productId },
|
|
@@ -165,4 +192,34 @@ export function registerQueueTools(server: McpServer, client: TaigaClient) {
|
|
|
165
192
|
},
|
|
166
193
|
(params) => runTool(async () => toolResult(await queueView(client, params.productId)))
|
|
167
194
|
);
|
|
195
|
+
|
|
196
|
+
if (!client.hasCapability('products:write')) return;
|
|
197
|
+
|
|
198
|
+
server.registerTool(
|
|
199
|
+
'taiga_pause_line',
|
|
200
|
+
{
|
|
201
|
+
title: "Pause or resume a product's line",
|
|
202
|
+
description:
|
|
203
|
+
"Pause the product's whole line, or resume it. Pausing cancels the plan being written and stops the " +
|
|
204
|
+
'build in flight where it stands (both are flagged), and nothing more is planned or built; initiatives ' +
|
|
205
|
+
'keep their places. Resuming plans the interrupted initiative again from scratch, carries the interrupted ' +
|
|
206
|
+
'build on from its first incomplete step, and the line picks up where it was; a build you had stopped ' +
|
|
207
|
+
'yourself before pausing stays stopped. Idempotent. Pausing interrupts work and resuming starts it: read ' +
|
|
208
|
+
'taiga_get_queue first to see what it would interrupt, and ask the person.',
|
|
209
|
+
inputSchema: {
|
|
210
|
+
productId,
|
|
211
|
+
paused: z.boolean().describe('true pauses the line, false resumes it'),
|
|
212
|
+
},
|
|
213
|
+
annotations: OPERATE_SETTING,
|
|
214
|
+
},
|
|
215
|
+
(params) =>
|
|
216
|
+
runTool(async () => {
|
|
217
|
+
const row = (await client.setLineStopped(params.productId, params.paused)).data as Row;
|
|
218
|
+
return toolResult({
|
|
219
|
+
productId: row.id,
|
|
220
|
+
linePaused: typeof row.lineStoppedAt === 'string',
|
|
221
|
+
lineStoppedAt: row.lineStoppedAt ?? null,
|
|
222
|
+
});
|
|
223
|
+
})
|
|
224
|
+
);
|
|
168
225
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { requestPrompt, stripInlineImages } from './request-prompt.js';
|
|
3
|
+
|
|
4
|
+
const IMAGE = '';
|
|
5
|
+
|
|
6
|
+
describe('requestPrompt', () => {
|
|
7
|
+
it('joins the trimmed title and description with a blank line', () => {
|
|
8
|
+
expect(requestPrompt(' Add login ', ' Users log in. ')).toBe('Add login\n\nUsers log in.');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('is undefined when both are empty, which asks the agent for a suggestion', () => {
|
|
12
|
+
expect(requestPrompt(' ', '\n\n')).toBeUndefined();
|
|
13
|
+
expect(requestPrompt(undefined, undefined)).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('drops inline images and collapses the blank lines they and the author left', () => {
|
|
17
|
+
expect(requestPrompt('T', `Before\n\n${IMAGE}\n\n\n\nAfter`)).toBe('T\n\nBefore\n\nAfter');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('stripInlineImages', () => {
|
|
22
|
+
it('keeps an image that is not a Taiga inline image, and a link to one that is not an image', () => {
|
|
23
|
+
const external = '';
|
|
24
|
+
const download = '[file](/v1/custom-documents/0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5c/image)';
|
|
25
|
+
expect(stripInlineImages(`${external} ${download}`)).toBe(`${external} ${download}`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('does not take /imagefoo or /image/download for an inline image', () => {
|
|
29
|
+
const near = '';
|
|
30
|
+
expect(stripInlineImages(near)).toBe(near);
|
|
31
|
+
});
|
|
32
|
+
});
|