@taiga-ai/mcp-server 0.18.0-rc.445 → 0.19.0-dev.451

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,13 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import type { TaigaClient } from '../client.js';
3
+ import {
4
+ type BuildRunStep,
5
+ type BuildRunWithContext,
6
+ BUILD_RUN_STATUSES,
7
+ dispositionOf,
8
+ type BuildRunStatus,
9
+ } from '@taiga/types';
10
+ import type { TaigaClient, WithoutTenant } from '../client.js';
4
11
  import { OPERATE, READ_ONLY } from './annotations.js';
5
12
  import { idInput, pagination, productId } from './inputs.js';
6
13
  import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
@@ -43,7 +50,9 @@ const RUN_OUTCOME_FIELDS = [
43
50
  'failedReason',
44
51
  'completedSteps',
45
52
  'totalSteps',
53
+ 'prNumber',
46
54
  'prUrl',
55
+ 'prMerged',
47
56
  'actions',
48
57
  ] as const;
49
58
 
@@ -54,7 +63,10 @@ const RUN_OUTCOME_FIELDS = [
54
63
  * stalled in the hardening pass after the steps and the retry continues it.
55
64
  * Null for a run still going or that finished.
56
65
  */
57
- export function stoppedIn(run: Record<string, unknown>, steps: Record<string, unknown>[]): string | null {
66
+ export function stoppedIn(
67
+ run: Pick<WithoutTenant<BuildRunWithContext>, 'prMerged' | 'status' | 'hardeningResult'>,
68
+ steps: readonly Pick<WithoutTenant<BuildRunStep>, 'status' | 'stepOrder' | 'title'>[]
69
+ ): string | null {
58
70
  // A merged run is finished, whatever its status still says: the merge is
59
71
  // latched apart from the status, which can stay at a stale failed or stopped.
60
72
  if (run.prMerged === true || (run.status !== 'failed' && run.status !== 'stopped')) return null;
@@ -77,10 +89,80 @@ export function stoppedIn(run: Record<string, unknown>, steps: Record<string, un
77
89
  return 'after the steps and hardening, at the pull request';
78
90
  }
79
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
+
80
162
  /** How many products the org-wide list names; past this a row keeps its productId only. */
81
163
  const NAMED_PRODUCTS = 100;
82
164
 
83
- const buildId = idInput('build run', 'taiga_list_builds gives it');
165
+ const runId = idInput('run', 'taiga_list_runs gives it');
84
166
 
85
167
  /** A step without its instructions: what it is, how it stands and what it changed. */
86
168
  const STEP_SUMMARY_FIELDS = [
@@ -131,14 +213,16 @@ async function activityRows(
131
213
  throw error;
132
214
  }),
133
215
  ]);
134
- const nameById = new Map<string, { name: unknown; slug: unknown }>();
135
- for (const product of (products?.data ?? []) as Record<string, unknown>[]) {
136
- nameById.set(String(product.id), { name: product.name, slug: product.slug });
216
+ const nameById = new Map<string, { name: string; slug: string }>();
217
+ for (const product of products?.data ?? []) {
218
+ nameById.set(product.id, { name: product.name, slug: product.slug });
137
219
  }
138
- const rows = pickFields(runs.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS).map((row) => {
139
- const product = nameById.get(String(row.productId));
140
- return product ? { ...row, product } : row;
141
- });
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
+ });
142
226
  const unnamed = rows.some((row) => !('product' in row));
143
227
  // Why a row stayed unnamed, by what the products read said: it failed, it
144
228
  // was cut before that product, or it listed everything the key can see and
@@ -157,24 +241,23 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
157
241
  if (!client.hasCapability('products:read')) return;
158
242
 
159
243
  server.registerTool(
160
- 'taiga_list_builds',
244
+ 'taiga_list_runs',
161
245
  {
162
- title: 'List build runs',
246
+ title: 'List runs',
163
247
  description:
164
- "List the coding agent's runs, newest first: status, branch, pull request, step progress and the " +
165
- 'actions a person may take on each. A run is the record of an attempt: work is carried on from its ' +
166
- 'initiative (resume and retry_from_failed_step are taiga_resume_backlog_item; start_over is in the web ' +
167
- 'only for now) and stopped by moving the initiative out of Build; a maintenance run, which has no ' +
168
- 'initiative, is retried from its finding. With productId, ' +
169
- 'the runs of that product, optionally of one backlog item; without it, the runs of every product the ' +
170
- 'key can see, each row naming its product, and active: true narrows to the runs executing right now. ' +
171
- 'Use this to see what Taiga already built or is building, then read one run with taiga_get_build. ' +
172
- '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.',
173
256
  inputSchema: z.object({
174
257
  productId: productId.optional(),
175
- backlogItemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it')
258
+ initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it')
176
259
  .optional()
177
- .describe("Only this backlog item's runs -- get from taiga_list_backlog_items; needs productId"),
260
+ .describe("Only this initiative's runs -- get from taiga_list_initiatives; needs productId"),
178
261
  active: z
179
262
  .boolean()
180
263
  .optional()
@@ -188,10 +271,10 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
188
271
  },
189
272
  (params) =>
190
273
  runTool(async () => {
191
- if (!params.productId && params.backlogItemId) {
274
+ if (!params.productId && params.initiativeId) {
192
275
  return toolError(
193
276
  'VALIDATION_ERROR',
194
- 'backlogItemId narrows the runs of one product: pass its productId as well (the item carries it).'
277
+ 'initiativeId narrows the runs of one product: pass its productId as well (the initiative carries it).'
195
278
  );
196
279
  }
197
280
  // The product route has no such filter; a silently ignored one would
@@ -203,9 +286,16 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
203
286
  );
204
287
  }
205
288
  if (params.productId) {
206
- const result = await client.listBuildRuns(params.productId, params);
289
+ const result = await client.listBuildRuns(params.productId, {
290
+ backlogItemId: params.initiativeId,
291
+ limit: params.limit,
292
+ cursor: params.cursor,
293
+ });
207
294
  return toolResult(
208
- page(pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS), result.nextCursor)
295
+ page(
296
+ pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS).map(readableRun),
297
+ result.nextCursor
298
+ )
209
299
  );
210
300
  }
211
301
  return toolResult(await activityRows(client, params));
@@ -213,21 +303,21 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
213
303
  );
214
304
 
215
305
  server.registerTool(
216
- 'taiga_get_build',
306
+ 'taiga_get_run',
217
307
  {
218
- title: 'Get build run',
308
+ title: 'Get run',
219
309
  description:
220
- "Get one run with its steps: the run (status, pull request, hardening, metrics) and each step's title, " +
221
- 'status, commit, files changed, checks, any error and headline, the first paragraph of what the agent ' +
222
- "reported. Pass full: true for each step's complete instructions and report, several thousand tokens on " +
223
- 'a long run. Use this to pick up where the coding agent stopped, or to understand the pull request it ' +
224
- 'opened. A run that failed or was stopped says where (stoppedIn: a step, or the hardening pass after ' +
225
- 'them) and when it last made progress (lastProgressAt). Its actions name what the initiative accepts ' +
226
- 'for it: resume and retry_from_failed_step (which continues from the first unfinished part) are ' +
227
- 'taiga_resume_backlog_item, start_over is in the web only for now; to stop it, move the initiative out of ' +
228
- '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.',
229
319
  inputSchema: z.object({
230
- buildId,
320
+ runId,
231
321
  full: z
232
322
  .boolean()
233
323
  .optional()
@@ -240,16 +330,16 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
240
330
  // Two routes, one answer: fanned out here rather than in the model's
241
331
  // context, like the six-tool pattern asks.
242
332
  const [run, steps] = await Promise.all([
243
- client.getBuildRun(params.buildId),
244
- client.listBuildRunSteps(params.buildId),
333
+ client.getBuildRun(params.runId),
334
+ client.listBuildRunSteps(params.runId),
245
335
  ]);
246
- const runRow = run.data as Record<string, unknown>;
247
- const rows = steps.data as Record<string, unknown>[];
336
+ const runRow = run.data;
337
+ const rows = steps.data;
248
338
  const where = stoppedIn(runRow, rows);
249
339
  const stop = where ? { stoppedIn: where } : {};
250
- if (params.full) return toolResult({ ...runRow, ...stop, steps: steps.data });
340
+ if (params.full) return toolResult({ ...readableRun(runRow), ...stop, steps: steps.data });
251
341
  return toolResult({
252
- ...runRow,
342
+ ...readableRun(runRow),
253
343
  ...stop,
254
344
  steps: pickFields(rows, STEP_SUMMARY_FIELDS).map((step, index) => ({
255
345
  ...step,
@@ -262,23 +352,22 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
262
352
  if (!client.hasCapability('products:write')) return;
263
353
 
264
354
  server.registerTool(
265
- 'taiga_stop_build',
355
+ 'taiga_stop_run',
266
356
  {
267
- title: 'Stop a build run',
357
+ title: 'Stop a run',
268
358
  description:
269
359
  'The emergency brake: stop a run the coding agent is executing when its initiative cannot be moved. The ' +
270
- 'way to stop work is on the initiative: move it out of Build (taiga_move_backlog_item), which stops the ' +
360
+ 'way to stop work is on the initiative: move it out of Build (taiga_move_initiative), which stops the ' +
271
361
  'plan or run in flight. Use this only for a run whose initiative does not offer that (it is finished, ' +
272
362
  'canceled or gone) yet the run is still going. The agent halts, its branch and any draft pull request ' +
273
- 'stay. Only a run whose actions include stop; the API refuses the rest with CONFLICT. Ask the person ' +
274
- 'before calling this: it interrupts work in progress.',
275
- 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 }),
276
365
  annotations: OPERATE,
277
366
  },
278
367
  (params) =>
279
368
  runTool(async () => {
280
- const result = await client.stopBuildRun(params.buildId);
281
- return toolResult(pickFields([result.data as Record<string, unknown>], 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]!));
282
371
  })
283
372
  );
284
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 { INSTRUCTIONS_HEAD, INSTRUCTIONS_HEAD_LIMIT, SERVER_INSTRUCTIONS } from './instructions.js';
13
+ import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_LIMIT } from './instructions.js';
14
14
  import { PRODUCT_DOCUMENT_NAMES } from './catalogue.js';
15
15
 
16
16
  /**
@@ -104,11 +104,10 @@ describe('tool descriptions against the catalogue', () => {
104
104
  const tools = await allTools();
105
105
  const list = tools.find((t) => t.name === 'taiga_list_documents')!;
106
106
  const get = tools.find((t) => t.name === 'taiga_get_document')!;
107
- const typeEnum = (get.inputSchema.properties as Record<string, { enum?: string[] }>).type!.enum;
107
+ const typeEnum = (get.inputSchema.properties as Record<string, { enum?: string[] }>)['type']!.enum;
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
  });
@@ -118,10 +117,10 @@ describe('tool descriptions against the catalogue', () => {
118
117
  const props = (name: string) =>
119
118
  tools.find((t) => t.name === name)!.inputSchema.properties as Record<string, { enum?: string[] }>;
120
119
 
121
- expect(props('taiga_list_products').status!.enum).toEqual([...PRODUCT_STATUSES]);
122
- expect(props('taiga_get_product_document').type!.enum).toEqual([...PRODUCT_DOCUMENT_TYPES]);
123
- expect(props('taiga_get_product_document').origin!.enum).toEqual(['plan', 'reality']);
124
- expect(props('taiga_get_factory_document').type!.enum).toEqual([...FACTORY_DOCUMENT_TYPES]);
120
+ expect(props('taiga_list_products')['status']!.enum).toEqual([...PRODUCT_STATUSES]);
121
+ expect(props('taiga_get_product_document')['type']!.enum).toEqual([...PRODUCT_DOCUMENT_TYPES]);
122
+ expect(props('taiga_get_product_document')['origin']!.enum).toEqual(['plan', 'reality']);
123
+ expect(props('taiga_get_factory_document')['type']!.enum).toEqual([...FACTORY_DOCUMENT_TYPES]);
125
124
  });
126
125
 
127
126
  it('has a display name for every product document type, the one the web uses', () => {
@@ -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 and the section metadata in the server instructions', () => {
134
- expect(SERVER_INSTRUCTIONS).toMatch(/plan: .*discovery/);
135
- expect(SERVER_INSTRUCTIONS).toMatch(/reality: .*merged pull request/);
136
- for (const term of ['`source`', '`priority: core`', 'overlay-<id>', 'cloud-preset:<providers>', 'brownfield']) {
137
- expect(SERVER_INSTRUCTIONS).toContain(term);
138
- }
139
- // Nothing writes a `user` source: a hand edit keeps the source it had.
140
- expect(SERVER_INSTRUCTIONS).not.toMatch(/user: edited/);
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('opens the instructions with where to start and when to ask, inside what a client shows before it cuts', () => {
144
- // Claude Code shows about the first 2,000 characters; the review that
145
- // found the cut read only the document taxonomy (PRO-2759).
146
- expect(SERVER_INSTRUCTIONS.startsWith(INSTRUCTIONS_HEAD)).toBe(true);
147
- expect(INSTRUCTIONS_HEAD.length).toBeLessThanOrEqual(INSTRUCTIONS_HEAD_LIMIT);
148
- for (const must of ['taiga_get_product_context', 'Before you write', 'NOT_FOUND', 'initiative']) {
149
- expect(INSTRUCTIONS_HEAD).toContain(must);
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
  });