@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.
@@ -0,0 +1,445 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { BUILD_RUN_TERMINAL_STATUSES, resolveAutoBuild } from '@taiga/types';
3
+ import { z } from 'zod';
4
+ import { TaigaApiError, type TaigaClient } from '../client.js';
5
+ import { READ_ONLY } from './annotations.js';
6
+ import { isNetworkFailure, runTool, toolResult } from './errors.js';
7
+ import { criteriaCounts, pickFields } from './summarize.js';
8
+
9
+ /**
10
+ * Waiting without a timer. A model in an editor has no clock: to learn that
11
+ * a plan landed or a run opened its pull request it either reads the item
12
+ * again and again or hands the wait to a shell loop outside the server. These
13
+ * tools poll on the server instead and return as soon as the condition holds,
14
+ * when it can no longer hold without a person, or at the timeout, whichever
15
+ * is first; a timeout is an answer too (reached: false), and the model calls
16
+ * again to keep waiting.
17
+ */
18
+
19
+ export interface WaitOptions {
20
+ /** How often the API is read while waiting. */
21
+ pollIntervalMs: number;
22
+ }
23
+
24
+ export const DEFAULT_WAIT_OPTIONS: WaitOptions = { pollIntervalMs: 5_000 };
25
+
26
+ /**
27
+ * Under the 60 seconds many clients allow a call by default (the MCP SDK's
28
+ * own request timeout), with room for the last read.
29
+ */
30
+ const DEFAULT_TIMEOUT_SECONDS = 50;
31
+ const MAX_TIMEOUT_SECONDS = 600;
32
+
33
+ const timeoutSeconds = z
34
+ .number()
35
+ .int()
36
+ .min(1)
37
+ .max(MAX_TIMEOUT_SECONDS)
38
+ .optional()
39
+ .describe(
40
+ `How long to wait at most (default ${DEFAULT_TIMEOUT_SECONDS}). Keep it under your client's tool-call limit; ` +
41
+ 'many stop a call at 60 seconds.'
42
+ );
43
+
44
+ type Row = Record<string, unknown>;
45
+
46
+ /** Why a wait ended: the condition held, it can no longer hold on its own, or time ran out. */
47
+ type Verdict = { reached: true } | { reached: false; stoppedBecause: string } | null;
48
+
49
+ interface Progress {
50
+ progressToken: string | number | undefined;
51
+ sendNotification: (notification: {
52
+ method: 'notifications/progress';
53
+ params: { progressToken: string | number; progress: number; total: number; message: string };
54
+ }) => Promise<void>;
55
+ signal: AbortSignal;
56
+ }
57
+
58
+ function pause(ms: number, signal: AbortSignal): Promise<void> {
59
+ return new Promise((resolve) => {
60
+ if (signal.aborted) return resolve();
61
+ const timer = setTimeout(done, ms);
62
+ function done() {
63
+ clearTimeout(timer);
64
+ signal.removeEventListener('abort', done);
65
+ resolve();
66
+ }
67
+ signal.addEventListener('abort', done, { once: true });
68
+ });
69
+ }
70
+
71
+ /** Failed reads in a row a wait rides out before it gives up and says why. */
72
+ const TRANSIENT_FAILURES_TOLERATED = 3;
73
+
74
+ /** A failure the next read may not hit: rate limited, the API erroring or unreachable. Not a refusal or a miss. */
75
+ function isTransient(error: unknown): boolean {
76
+ if (isNetworkFailure(error)) return true;
77
+ return error instanceof TaigaApiError && (error.status === 429 || error.status >= 500);
78
+ }
79
+
80
+ /**
81
+ * Read until `judge` gives a verdict or the time is up. Every read is bound
82
+ * by what is left of the timeout and by the call's cancellation, so a slow
83
+ * API cannot hold the wait past either: a read cut short ends the wait with
84
+ * the last state seen (none, when not even the first read came back). A
85
+ * read that fails transiently is ridden out, a few in a row and the first
86
+ * read included, so one 502 does not throw away minutes of waiting; a
87
+ * refusal fails as it comes. Each
88
+ * read that leaves the wait open reports progress when the client asked for
89
+ * it, which also keeps a client that resets its timeout on progress from
90
+ * cutting the wait short.
91
+ */
92
+ async function waitFor(
93
+ read: (signal: AbortSignal) => Promise<Row>,
94
+ judge: (now: Row, first: Row) => Verdict,
95
+ timeoutMs: number,
96
+ options: WaitOptions,
97
+ progress: Progress
98
+ ) {
99
+ const started = Date.now();
100
+ const deadline = started + timeoutMs;
101
+ let first: Row | null = null;
102
+ let now: Row | null = null;
103
+ let verdict: Verdict = null;
104
+ let failures = 0;
105
+ for (let attempt = 0; !progress.signal.aborted; attempt += 1) {
106
+ if (attempt > 0) {
107
+ const left = deadline - Date.now();
108
+ if (left <= 0) break;
109
+ await pause(Math.min(options.pollIntervalMs, left), progress.signal);
110
+ }
111
+ const remaining = deadline - Date.now();
112
+ if (progress.signal.aborted || remaining <= 0) break;
113
+ const bound = AbortSignal.timeout(remaining);
114
+ try {
115
+ now = await read(AbortSignal.any([progress.signal, bound]));
116
+ first ??= now;
117
+ failures = 0;
118
+ } catch (error) {
119
+ // Cut short by the deadline or the cancellation: the wait is over, not failed.
120
+ if (bound.aborted || progress.signal.aborted) break;
121
+ failures += 1;
122
+ if (!isTransient(error) || failures >= TRANSIENT_FAILURES_TOLERATED) throw error;
123
+ continue;
124
+ }
125
+ verdict = judge(now, first);
126
+ if (verdict) break;
127
+ if (attempt > 0 && progress.progressToken !== undefined) {
128
+ // Best effort: a progress line the client cannot take does not end the wait.
129
+ await progress
130
+ .sendNotification({
131
+ method: 'notifications/progress',
132
+ params: {
133
+ progressToken: progress.progressToken,
134
+ // Milliseconds, so every notification is larger than the last, as the protocol asks.
135
+ progress: Date.now() - started,
136
+ total: timeoutMs,
137
+ message: 'Still waiting',
138
+ },
139
+ })
140
+ .catch(() => undefined);
141
+ }
142
+ }
143
+ const waitedSeconds = Math.round((Date.now() - started) / 1000);
144
+ if (verdict) return { ...verdict, waitedSeconds, now };
145
+ const stoppedBecause = progress.signal.aborted ? 'cancelled' : 'timeout';
146
+ return { reached: false, stoppedBecause, waitedSeconds, now };
147
+ }
148
+
149
+ const SETTLED_RUN: ReadonlySet<string> = new Set(BUILD_RUN_TERMINAL_STATUSES);
150
+
151
+ /**
152
+ * A pull request that is open now. A run keeps its prUrl after the pull
153
+ * request merges or closes, so the URL alone says one existed, not that it
154
+ * is open; the mirrored state and the merged latch say which. An unknown
155
+ * state (null, just opened) counts as open.
156
+ */
157
+ function prIsOpen(run: Row): boolean {
158
+ return typeof run.prUrl === 'string' && run.prUrl !== '' && run.prMerged !== true && run.prState !== 'closed';
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Initiatives
163
+ // ---------------------------------------------------------------------------
164
+
165
+ const ITEM_CONDITIONS = ['changed', 'planned', 'your_turn', 'pr_open', 'run_settled', 'done'] as const;
166
+ type ItemCondition = (typeof ITEM_CONDITIONS)[number];
167
+
168
+ const latestBuildOf = (item: Row) => ((item.lifecycle as Row | undefined)?.latestBuild ?? null) as Row | null;
169
+ const lifecycleOf = (item: Row) => (item.lifecycle ?? {}) as Row;
170
+
171
+ /** What "changed" compares: where the item is, whose turn it is and how its latest run stands. */
172
+ function itemSignature(item: Row): string {
173
+ const lifecycle = lifecycleOf(item);
174
+ const run = latestBuildOf(item);
175
+ return JSON.stringify([
176
+ item.state,
177
+ item.planningStatus,
178
+ item.needsHumanCause,
179
+ lifecycle.stage,
180
+ lifecycle.turn,
181
+ lifecycle.cta,
182
+ run?.id,
183
+ run?.status,
184
+ run?.completedSteps,
185
+ run?.prUrl,
186
+ run?.prState,
187
+ run?.prMerged,
188
+ ]);
189
+ }
190
+
191
+ /**
192
+ * The product's auto-build default, as far as the wait knows it: read once,
193
+ * and only when an initiative with no gate of its own needs it. Null when the
194
+ * key may not read it (or the product is gone); undefined until read.
195
+ */
196
+ interface GateDefault {
197
+ product: boolean | null | undefined;
198
+ }
199
+
200
+ /**
201
+ * Whether the initiative waits on a person now: yes, no, or unknown. The
202
+ * lifecycle says `turn: 'you'` for every landed plan (`start_build`), but a
203
+ * plan whose review gate is open is waiting for the Build slot, not for
204
+ * anyone: the queue keeps such an entry active and starts it on its own. When
205
+ * the gate cannot be resolved (no gate of its own, and the product's default
206
+ * unreadable) the answer is unknown, and the wait says so rather than guess.
207
+ */
208
+ function personTurn(item: Row, gate: GateDefault): 'yes' | 'no' | 'unknown' {
209
+ const lifecycle = lifecycleOf(item);
210
+ if (lifecycle.turn !== 'you') return 'no';
211
+ if (lifecycle.cta !== 'start_build') return 'yes';
212
+ const own = typeof item.autoBuild === 'boolean' ? item.autoBuild : null;
213
+ const product = typeof gate.product === 'boolean' ? gate.product : null;
214
+ if (own === null && product === null) return 'unknown';
215
+ return resolveAutoBuild({ initiative: own, product: product ?? true }) ? 'no' : 'yes';
216
+ }
217
+
218
+ function itemHolds(until: ItemCondition, item: Row, first: Row, gate: GateDefault): boolean {
219
+ const lifecycle = lifecycleOf(item);
220
+ const run = latestBuildOf(item);
221
+ switch (until) {
222
+ case 'changed':
223
+ return itemSignature(item) !== itemSignature(first);
224
+ case 'planned':
225
+ return item.planningStatus === 'planned';
226
+ case 'your_turn':
227
+ return personTurn(item, gate) === 'yes';
228
+ case 'pr_open':
229
+ return run !== null && prIsOpen(run);
230
+ case 'run_settled':
231
+ return run !== null && SETTLED_RUN.has(String(run.status));
232
+ case 'done':
233
+ return lifecycle.stage === 'done';
234
+ }
235
+ }
236
+
237
+ function judgeItem(until: ItemCondition, gate: GateDefault) {
238
+ return (item: Row, first: Row): Verdict => {
239
+ if (itemHolds(until, item, first, gate)) return { reached: true };
240
+ // "changed" is how a model waits for a person to act on a flagged or
241
+ // finished initiative, so none of the stops below apply to it.
242
+ if (until === 'changed') return null;
243
+ // Past these the condition cannot come true without a person acting.
244
+ if (typeof item.needsHumanCause === 'string') {
245
+ return { reached: false, stoppedBecause: `needs a person: ${item.needsHumanCause}` };
246
+ }
247
+ // The ordinary gates set no flag: a plan waiting for its verdict, a pull
248
+ // request waiting for a review. The lifecycle says whose turn it is.
249
+ const lifecycle = lifecycleOf(item);
250
+ const turn = personTurn(item, gate);
251
+ if (until !== 'your_turn' && turn === 'yes') {
252
+ return { reached: false, stoppedBecause: `waits on a person: ${String(lifecycle.cta ?? lifecycle.stage)}` };
253
+ }
254
+ if (turn === 'unknown') {
255
+ return {
256
+ reached: false,
257
+ stoppedBecause:
258
+ "the plan has landed, and whether it waits for a review is unknown: the product's default could not be read " +
259
+ '(it needs products:read)',
260
+ };
261
+ }
262
+ if (until === 'planned' && item.planningStatus === 'failed') {
263
+ return { reached: false, stoppedBecause: 'planning failed' };
264
+ }
265
+ const stage = lifecycleOf(item).stage;
266
+ // Canceled ends every wait, done included: it is not the done asked for.
267
+ if (stage === 'canceled' || (until !== 'done' && stage === 'done')) {
268
+ return { reached: false, stoppedBecause: `the initiative is ${String(stage)}` };
269
+ }
270
+ return null;
271
+ };
272
+ }
273
+
274
+ /** The initiative as a wait reports it: where it is, whose turn, its latest run and its criteria. */
275
+ function itemReport(item: Row) {
276
+ const run = latestBuildOf(item);
277
+ return {
278
+ ...pickFields(
279
+ [item],
280
+ ['id', 'title', 'state', 'planningStatus', 'needsHumanCause', 'needsHumanDetail', 'autoBuild']
281
+ )[0],
282
+ lifecycle: pickFields([lifecycleOf(item)], ['turn', 'stage', 'cta', 'planFailureCause'])[0],
283
+ latestBuild: run
284
+ ? pickFields(
285
+ [run],
286
+ ['id', 'status', 'completedSteps', 'totalSteps', 'prUrl', 'prState', 'prMerged', 'actions']
287
+ )[0]
288
+ : null,
289
+ criteria: criteriaCounts(item),
290
+ };
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Runs
295
+ // ---------------------------------------------------------------------------
296
+
297
+ const BUILD_CONDITIONS = ['changed', 'step_done', 'pr_open', 'settled'] as const;
298
+ type BuildCondition = (typeof BUILD_CONDITIONS)[number];
299
+
300
+ const RUN_REPORT_FIELDS = [
301
+ 'id',
302
+ 'backlogItemId',
303
+ 'targetTitle',
304
+ 'status',
305
+ 'failedReason',
306
+ 'completedSteps',
307
+ 'totalSteps',
308
+ 'prUrl',
309
+ 'prState',
310
+ 'prDraft',
311
+ 'prMerged',
312
+ 'actions',
313
+ ] as const;
314
+
315
+ const runSignature = (run: Row) =>
316
+ JSON.stringify([run.status, run.completedSteps, run.prUrl, run.prState, run.prMerged]);
317
+
318
+ function judgeBuild(until: BuildCondition) {
319
+ return (run: Row, first: Row): Verdict => {
320
+ const holds =
321
+ until === 'changed'
322
+ ? runSignature(run) !== runSignature(first)
323
+ : until === 'step_done'
324
+ ? Number(run.completedSteps ?? 0) > Number(first.completedSteps ?? 0)
325
+ : until === 'pr_open'
326
+ ? prIsOpen(run)
327
+ : SETTLED_RUN.has(String(run.status));
328
+ if (holds) return { reached: true };
329
+ // "changed" waits out a settled run too: a person resuming it is the change.
330
+ if (until !== 'changed' && SETTLED_RUN.has(String(run.status))) {
331
+ return { reached: false, stoppedBecause: `the run is ${String(run.status)}` };
332
+ }
333
+ return null;
334
+ };
335
+ }
336
+
337
+ export function registerWaitTools(server: McpServer, client: TaigaClient, options: WaitOptions) {
338
+ if (client.hasCapability('backlog:read')) {
339
+ server.registerTool(
340
+ 'taiga_wait_for_backlog_item',
341
+ {
342
+ title: 'Wait for an initiative',
343
+ description:
344
+ 'Wait until an initiative reaches a point, instead of reading it again and again: changed (anything ' +
345
+ 'about where it is or its latest run), planned (its plan landed), your_turn (it waits on a person: a ' +
346
+ 'plan to review, a pull request, a flag), pr_open (a pull request open now; a merged or closed one does ' +
347
+ 'not count), run_settled (its latest run stopped moving) or done (finished; a canceled one stops the wait). ' +
348
+ 'Returns as soon as that holds (reached: true), early when it cannot hold without a person (a flag, a ' +
349
+ 'failed plan, a plan waiting for its verdict, a pull request waiting for a review, a cancelled ' +
350
+ 'initiative, or a landed plan whose review gate cannot be read; stoppedBecause says which), or at the ' +
351
+ 'timeout with reached: false: ' +
352
+ 'call again to keep waiting. Conditions are states, not events: an initiative whose latest run is already ' +
353
+ 'settled answers run_settled at once; item is null when not even the first read answered in time. ' +
354
+ 'Planning takes minutes and a build up to an hour.',
355
+ inputSchema: {
356
+ itemId: z.string().uuid().describe('Backlog item ID -- get from taiga_list_backlog_items'),
357
+ until: z.enum(ITEM_CONDITIONS).describe('What to wait for'),
358
+ timeoutSeconds,
359
+ },
360
+ annotations: READ_ONLY,
361
+ },
362
+ (params, extra) =>
363
+ runTool(async () => {
364
+ const gate: GateDefault = { product: undefined };
365
+ const read = async (signal: AbortSignal) => {
366
+ const item = (await client.getBacklogItem(params.itemId, signal)).data as Row;
367
+ // The product's default, once, when this initiative has no gate of its own to answer with.
368
+ if (gate.product === undefined && typeof item.autoBuild !== 'boolean') {
369
+ gate.product = client.hasCapability('products:read')
370
+ ? await client
371
+ .getProduct(String(item.productId), signal)
372
+ .then((p) => {
373
+ const value = (p.data as { autoBuild?: unknown }).autoBuild;
374
+ return typeof value === 'boolean' ? value : true;
375
+ })
376
+ // Not allowed, or gone: unknown for good. Anything transient
377
+ // propagates, so the wait rides it out and reads again.
378
+ .catch((error: unknown) => {
379
+ if (error instanceof TaigaApiError && (error.status === 403 || error.status === 404)) return null;
380
+ throw error;
381
+ })
382
+ : null;
383
+ }
384
+ return item;
385
+ };
386
+ const result = await waitFor(
387
+ read,
388
+ judgeItem(params.until, gate),
389
+ (params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
390
+ options,
391
+ {
392
+ progressToken: extra._meta?.progressToken,
393
+ sendNotification: extra.sendNotification,
394
+ signal: extra.signal,
395
+ }
396
+ );
397
+ const { now, ...outcome } = result;
398
+ return toolResult({ until: params.until, ...outcome, item: now ? itemReport(now) : null });
399
+ })
400
+ );
401
+ }
402
+
403
+ if (client.hasCapability('products:read')) {
404
+ server.registerTool(
405
+ 'taiga_wait_for_build',
406
+ {
407
+ title: 'Wait for a run',
408
+ description:
409
+ "Wait until a coding agent's run reaches a point: changed (status, steps or pull request), step_done " +
410
+ '(one more step finished), pr_open (open now, not merged or closed) or settled (it stopped moving: in ' +
411
+ 'review, completed, failed, stopped). ' +
412
+ 'Returns as soon as that holds (reached: true), early when the run settled without it (stoppedBecause; ' +
413
+ 'changed waits on, since a person resuming the run is a change), or at the timeout with reached: false: ' +
414
+ 'call again to keep waiting. Conditions are states: a run already settled answers settled at once; run ' +
415
+ 'is null when not even the first read answered in time.',
416
+ inputSchema: {
417
+ buildId: z.string().uuid().describe('Build run ID -- get from taiga_list_builds'),
418
+ until: z.enum(BUILD_CONDITIONS).describe('What to wait for'),
419
+ timeoutSeconds,
420
+ },
421
+ annotations: READ_ONLY,
422
+ },
423
+ (params, extra) =>
424
+ runTool(async () => {
425
+ const result = await waitFor(
426
+ async (signal) => (await client.getBuildRun(params.buildId, signal)).data as Row,
427
+ judgeBuild(params.until),
428
+ (params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
429
+ options,
430
+ {
431
+ progressToken: extra._meta?.progressToken,
432
+ sendNotification: extra.sendNotification,
433
+ signal: extra.signal,
434
+ }
435
+ );
436
+ const { now, ...outcome } = result;
437
+ return toolResult({
438
+ until: params.until,
439
+ ...outcome,
440
+ run: now ? pickFields([now], RUN_REPORT_FIELDS)[0] : null,
441
+ });
442
+ })
443
+ );
444
+ }
445
+ }
package/tsconfig.json CHANGED
@@ -11,6 +11,10 @@
11
11
  "resolveJsonModule": true,
12
12
  "types": ["node"],
13
13
 
14
+ // @tsconfig/strictest leaves these two out, so every package sets them.
15
+ "verbatimModuleSyntax": true,
16
+ "erasableSyntaxOnly": true,
17
+
14
18
  // Preset flags this package still fails (PRO-2509). Each line is deleted
15
19
  // by the issue named beside it; no new line is added here.
16
20
  "noPropertyAccessFromIndexSignature": false // PRO-2519
Binary file