@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.
@@ -1,6 +1,12 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import type { BuildRunStep, BuildRunWithContext } from '@taiga/types';
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 buildId = idInput('build run', 'taiga_list_builds gives it');
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).map((row) => {
143
- const product = nameById.get(String(row.productId));
144
- return product ? { ...row, product } : row;
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
- 'taiga_list_builds',
244
+ 'taiga_list_runs',
165
245
  {
166
- title: 'List build runs',
246
+ title: 'List runs',
167
247
  description:
168
- "List the coding agent's runs, newest first: status, branch, pull request, step progress and the " +
169
- 'actions a person may take on each. A run is the record of an attempt: work is carried on from its ' +
170
- 'initiative (resume and retry_from_failed_step are taiga_resume_backlog_item; start_over is in the web ' +
171
- 'only for now) and stopped by moving the initiative out of Build; a maintenance run, which has no ' +
172
- 'initiative, is retried from its finding. With productId, ' +
173
- 'the runs of that product, optionally of one backlog item; without it, the runs of every product the ' +
174
- 'key can see, each row naming its product, and active: true narrows to the runs executing right now. ' +
175
- 'Use this to see what Taiga already built or is building, then read one run with taiga_get_build. ' +
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
- 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')
180
259
  .optional()
181
- .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"),
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.backlogItemId) {
274
+ if (!params.productId && params.initiativeId) {
196
275
  return toolError(
197
276
  'VALIDATION_ERROR',
198
- '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).'
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, params);
211
- return toolResult(page(pickFields(result.data, BUILD_SUMMARY_FIELDS), result.nextCursor));
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
- 'taiga_get_build',
306
+ 'taiga_get_run',
219
307
  {
220
- title: 'Get build run',
308
+ title: 'Get run',
221
309
  description:
222
- "Get one run with its steps: the run (status, pull request, hardening, metrics) and each step's title, " +
223
- 'status, commit, files changed, checks, any error and headline, the first paragraph of what the agent ' +
224
- "reported. Pass full: true for each step's complete instructions and report, several thousand tokens on " +
225
- 'a long run. Use this to pick up where the coding agent stopped, or to understand the pull request it ' +
226
- 'opened. A run that failed or was stopped says where (stoppedIn: a step, or the hardening pass after ' +
227
- 'them) and when it last made progress (lastProgressAt). Its actions name what the initiative accepts ' +
228
- 'for it: resume and retry_from_failed_step (which continues from the first unfinished part) are ' +
229
- 'taiga_resume_backlog_item, start_over is in the web only for now; to stop it, move the initiative out of ' +
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
- buildId,
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.buildId),
246
- client.listBuildRunSteps(params.buildId),
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
- 'taiga_stop_build',
355
+ 'taiga_stop_run',
268
356
  {
269
- title: 'Stop a build run',
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 (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 ' +
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. Ask the person ' +
276
- 'before calling this: it interrupts work in progress.',
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.buildId);
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 { 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
  /**
@@ -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 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
  });
@@ -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 { BACKLOG_SUMMARY_FIELDS } from './backlogs.js';
8
- import { POLICY_TYPES, defaultOriginFor, isImportedProduct, setupNextStep } from './catalogue.js';
9
- import { apiFailureText, isNetworkFailure, networkFailureDetail, runTool, toolError, toolResult } from './errors.js';
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 { itemRows, pickFields } from './summarize.js';
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; taiga_list_builds pages the rest. */
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; taiga_get_build has the rest. */
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
- * taiga_list_backlog_items pages the rest. Keeps a product with hundreds of
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: itemRows(rows.slice(-BACKLOG_IN_CONTEXT), BACKLOG_SUMMARY_FIELDS),
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; taiga_list_backlog_items pages the rest.',
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.allSettled(parts.map((part) => fetchers[part]()));
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 waits for: nothing else in the bundle does.
343
- ...(nextStep ? { nextStep } : {}),
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
- 'a backlog item with taiga_get_backlog_item (taiga_list_backlog_items pages a truncated backlog) and a run ' +
361
- 'with taiga_get_build.',
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
- let characters = JSON.stringify(result, null, 2).length;
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
- 'Everything a coding session starts from, in one call: the product and which document timeline is its ' +
382
- 'own, its factory with the instructions in full, the specification and architecture from that timeline, ' +
383
- 'every published policy as its section ids, the open backlog items, the linked repositories and the ' +
384
- 'latest runs. Name the product by ID, slug or the git remote URL of the checkout. A part the API refused ' +
385
- 'or failed is named under unavailable with what it said; a part the key cannot read is named under ' +
386
- 'notGranted; neither is shown as empty. The two documents come as their outline by default: each ' +
387
- 'section id with its size, to read with taiga_get_product_document naming the sections. Before writing ' +
388
- `code, documents: budget brings as many whole sections as fit, about ${DOCUMENT_BUDGET / 1000}k ` +
389
- 'characters each, naming the ones left out; full brings them whole, tens of thousands of tokens on a ' +
390
- 'large product and more than some clients accept. characters says how large the result is. Call this ' +
391
- 'first, then read what you need.',
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(),
@@ -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
- 'draft exists.',
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,
@@ -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
- content: [{ type: 'text', text: JSON.stringify(details ? { error, message, details } : { error, message }) }],
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
  }