@taiga-ai/mcp-server 0.21.0-dev.455 → 0.22.0-dev.457

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.
@@ -2368,7 +2368,12 @@ describe('MCP server integration', () => {
2368
2368
  `/v1/factories/${FACTORY_ID}/documents`,
2369
2369
  ['factories:read'],
2370
2370
  ],
2371
- ['taiga_list_initiatives', { productId: PRODUCT_ID }, `/v1/products/${PRODUCT_ID}/backlog`, ['backlog:read']],
2371
+ [
2372
+ 'taiga_list_initiatives',
2373
+ { productId: PRODUCT_ID, status: 'all' },
2374
+ `/v1/products/${PRODUCT_ID}/backlog`,
2375
+ ['backlog:read'],
2376
+ ],
2372
2377
  ['taiga_list_runs', { productId: PRODUCT_ID }, `/v1/products/${PRODUCT_ID}/builds`, ['products:read']],
2373
2378
  ] as const)('%s passes the cursor on and returns where the next page starts', async (name, args, path, caps) => {
2374
2379
  const { client, fetchSpy } = await bootMcpPair({
@@ -7145,7 +7150,8 @@ describe('what the third agent review found (PRO-2800)', () => {
7145
7150
  expect(list.data[0]).not.toHaveProperty('stale');
7146
7151
  expect(list.data[1].stale).toBe('its initiative was canceled; the pull request is still open on the forge');
7147
7152
  expect(list.summary).toEqual([
7148
- { productId: PRODUCT_ID, product: 'Web', open: 2, byKind: { review_pr: 2 }, stale: 1 },
7153
+ // The stale one is counted apart: open and byKind are what still stands.
7154
+ { productId: PRODUCT_ID, product: 'Web', open: 1, byKind: { review_pr: 1 }, stale: 1 },
7149
7155
  ]);
7150
7156
  });
7151
7157
 
@@ -7218,7 +7224,7 @@ describe('what the third agent review found (PRO-2800)', () => {
7218
7224
  const list = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
7219
7225
 
7220
7226
  expect(list.data[0]).not.toHaveProperty('stale');
7221
- expect(list.staleNote).toMatch(/^1 pull request review was not checked for staleness/);
7227
+ expect(list.staleNote).toMatch(/^1 review was not checked for staleness/);
7222
7228
  });
7223
7229
 
7224
7230
  it('names factories past the first page of them', async () => {
@@ -8028,6 +8034,325 @@ describe('what the fourth agent review found (PRO-2800)', () => {
8028
8034
  expect(runs[0]!['disposition']).toBe('in_flight');
8029
8035
  });
8030
8036
 
8037
+ it('marks a Review Initiatives item stale when nothing is left in the backlog, and keeps review rows lean', async () => {
8038
+ const OTHER = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f05';
8039
+ const ask = (id: string, productId: string) => ({
8040
+ id,
8041
+ productId,
8042
+ productName: id,
8043
+ kind: 'review_backlog',
8044
+ subjectType: 'product',
8045
+ subjectId: productId,
8046
+ title: 'Review Initiatives',
8047
+ summary: 'Initiatives were generated for this product.',
8048
+ status: 'open',
8049
+ createdAt: new Date().toISOString(),
8050
+ });
8051
+ const { client } = await bootMcpPair({
8052
+ capabilities: ['products:read', 'backlog:read'],
8053
+ routes: {
8054
+ 'GET /v1/inbox': {
8055
+ data: [
8056
+ ask('emptied', PRODUCT_ID),
8057
+ ask('waiting', OTHER),
8058
+ {
8059
+ id: 'pr',
8060
+ productId: OTHER,
8061
+ kind: 'review_pr',
8062
+ subjectType: 'pull_request',
8063
+ subjectId: 'x',
8064
+ title: 'Review & merge PR #3',
8065
+ summary: 'The coding agent finished its turn on a/b. Review PR #3, then merge.',
8066
+ status: 'open',
8067
+ createdAt: new Date().toISOString(),
8068
+ },
8069
+ ],
8070
+ nextCursor: null,
8071
+ },
8072
+ [`GET /v1/products/${PRODUCT_ID}/backlog`]: { data: [], nextCursor: null },
8073
+ [`GET /v1/products/${OTHER}/backlog`]: { data: [{ id: 'b1', status: 'backlog' }], nextCursor: null },
8074
+ },
8075
+ });
8076
+
8077
+ const list = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
8078
+ const full = parseToolResult(
8079
+ await client.callTool({ name: 'taiga_list_action_required', arguments: { full: true } })
8080
+ );
8081
+
8082
+ expect(list.data.map((row: { id: string; stale?: string }) => [row.id, row.stale ?? null])).toEqual([
8083
+ ['emptied', 'no initiative is open now: the ones it asked about are all done or canceled'],
8084
+ ['waiting', null],
8085
+ ['pr', null],
8086
+ ]);
8087
+ expect(list.data[2]).not.toHaveProperty('summary');
8088
+ expect(list.data[0].summary).toBe('Initiatives were generated for this product.');
8089
+ expect(full.data[2].summary).toMatch(/^The coding agent finished its turn/);
8090
+ });
8091
+
8092
+ it('says when a check failed after every step, docs-only steps included', async () => {
8093
+ const step = (order: number, files: string[], tests: string) => ({
8094
+ id: `s${order}`,
8095
+ stepOrder: order,
8096
+ title: `Step ${order}`,
8097
+ status: 'completed',
8098
+ filesChanged: files,
8099
+ summary: 'Done.',
8100
+ verification: [
8101
+ { check: 'format', status: 'passed' },
8102
+ { check: 'tests', status: tests },
8103
+ ],
8104
+ });
8105
+ const run = { id: BUILD_ID, productId: PRODUCT_ID, status: 'needs_review', prState: 'open' };
8106
+ const boot = (steps: unknown[]) =>
8107
+ bootMcpPair({
8108
+ capabilities: ['products:read'],
8109
+ routes: { [`/v1/builds/${BUILD_ID}`]: { data: run }, [`/v1/builds/${BUILD_ID}/steps`]: { data: steps } },
8110
+ });
8111
+
8112
+ // One pair at a time: each boot replaces the fetch routes.
8113
+ const always = await boot([step(1, ['src/a.rs'], 'failed'), step(2, ['README.md'], 'failed')]);
8114
+ const said = parseToolResult(
8115
+ await always.client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } })
8116
+ );
8117
+ const once = await boot([step(1, ['src/a.rs'], 'failed'), step(2, ['src/b.rs'], 'passed')]);
8118
+ const quiet = parseToolResult(
8119
+ await once.client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } })
8120
+ );
8121
+
8122
+ expect(said.verificationNote).toBe(
8123
+ "Taiga's tests check failed after every checked step (1 to 2), including step 2, which changed only " +
8124
+ 'documentation. A check failing from the first step on is usually one the repository or the check ' +
8125
+ "environment fails throughout, not one each step broke. It records no reason; each step's headline says " +
8126
+ 'what its own run found.'
8127
+ );
8128
+ expect(quiet).not.toHaveProperty('verificationNote');
8129
+
8130
+ // A code file named like a document is code: README.ts is not a docs-only step.
8131
+ const named = await boot([step(1, ['src/a.rs'], 'failed'), step(2, ['README.ts'], 'failed')]);
8132
+ const coded = parseToolResult(
8133
+ await named.client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } })
8134
+ );
8135
+ expect(coded).not.toHaveProperty('verificationNote');
8136
+
8137
+ // Two code steps both failing may be two breakages: no note. Three, with a reason, say the reason.
8138
+ const two = await boot([step(1, ['src/a.rs'], 'failed'), step(2, ['src/b.rs'], 'failed')]);
8139
+ const pair = parseToolResult(await two.client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } }));
8140
+ expect(pair).not.toHaveProperty('verificationNote');
8141
+ const scoped = (order: number) => ({
8142
+ ...step(order, [`src/${order}.rs`], 'failed'),
8143
+ verification: [{ check: 'tests', status: 'failed', reason: 'scoped to tests/x; CI runs the full suite' }],
8144
+ });
8145
+ const three = await boot([scoped(1), scoped(2), scoped(3)]);
8146
+ const triple = parseToolResult(
8147
+ await three.client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } })
8148
+ );
8149
+ expect(triple.verificationNote).toMatch(/Its reason: scoped to tests\/x; CI runs the full suite\.$/);
8150
+ });
8151
+
8152
+ it('counts a Review Initiatives item it could not check once, as unchecked', async () => {
8153
+ const ask = {
8154
+ id: 'ask',
8155
+ productId: PRODUCT_ID,
8156
+ kind: 'review_backlog',
8157
+ subjectType: 'product',
8158
+ subjectId: PRODUCT_ID,
8159
+ title: 'Review Initiatives',
8160
+ status: 'open',
8161
+ createdAt: new Date().toISOString(),
8162
+ };
8163
+ const { client } = await bootMcpPair({
8164
+ capabilities: ['products:read'],
8165
+ routes: { 'GET /v1/inbox': { data: [ask], nextCursor: null } },
8166
+ });
8167
+
8168
+ const list = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
8169
+
8170
+ expect(list.data[0]).not.toHaveProperty('stale');
8171
+ expect(list.staleNote).toMatch(/^1 review was not checked for staleness/);
8172
+ });
8173
+
8174
+ it('looks for a taken product name in its own factory only, and leaves the choice to the person', async () => {
8175
+ const { client, fetchSpy } = await bootMcpPair({
8176
+ capabilities: ALL_CAPABILITIES,
8177
+ routes: {
8178
+ 'GET /v1/products': {
8179
+ data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web', createdAt: '2026-10-10T10:00:00Z' }],
8180
+ nextCursor: null,
8181
+ },
8182
+ },
8183
+ });
8184
+
8185
+ const refused = parseToolResult(
8186
+ await client.callTool({
8187
+ name: 'taiga_create_product',
8188
+ arguments: { factoryId: FACTORY_ID, name: 'web', start: 'scratch' },
8189
+ })
8190
+ );
8191
+
8192
+ const listed = fetchSpy.mock.calls
8193
+ .map((call: unknown[]) => String(call[0]))
8194
+ .find((url: string) => url.includes('/v1/products?'));
8195
+ expect(new URL(listed!).searchParams.get('factoryId')).toBe(FACTORY_ID);
8196
+ expect(refused.message).toBe(
8197
+ `A product named "Web" already exists in this factory (${PRODUCT_ID}, created 2026-10-10T10:00:00Z). It may ` +
8198
+ "not be this person's: ask whether they meant that one, want another name, or want a second " +
8199
+ '(allowDuplicateName: true).'
8200
+ );
8201
+ expect(refused.message).not.toMatch(/Use it/);
8202
+ });
8203
+
8204
+ it('names a factory ID passed where a product ID belongs, and drops the transport text from a miss', async () => {
8205
+ const { client } = await bootMcpPair({
8206
+ capabilities: ['products:read', 'backlog:read', 'factories:read'],
8207
+ routes: {
8208
+ [`/v1/products/${FACTORY_ID}/backlog`]: refusal(404, 'NOT_FOUND', 'Product not found'),
8209
+ [`GET /v1/products/${FACTORY_ID}`]: refusal(404, 'NOT_FOUND', 'Product not found'),
8210
+ [`GET /v1/factories/${FACTORY_ID}`]: { data: { id: FACTORY_ID, name: 'Platform', slug: 'platform' } },
8211
+ },
8212
+ });
8213
+
8214
+ const listed = parseToolResult(
8215
+ await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: FACTORY_ID } })
8216
+ );
8217
+ const read = parseToolResult(
8218
+ await client.callTool({ name: 'taiga_get_product', arguments: { productId: FACTORY_ID } })
8219
+ );
8220
+
8221
+ expect(listed.message).toMatch(
8222
+ /That ID is the factory "Platform", not a product: taiga_list_products with factoryId/
8223
+ );
8224
+ expect(read.message).not.toMatch(/404|NOT_FOUND/);
8225
+ expect(read.message).toMatch(/That ID is the factory "Platform"/);
8226
+ });
8227
+
8228
+ it('says what a setup product needs after a repository link, and how to swap a linked one', async () => {
8229
+ const LINK = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f06';
8230
+ const { client } = await bootMcpPair({
8231
+ capabilities: ALL_CAPABILITIES,
8232
+ routes: {
8233
+ [`POST /v1/products/${PRODUCT_ID}/repositories`]: {
8234
+ data: { id: LINK, fullName: 'acme/web', baseBranch: null },
8235
+ },
8236
+ [`GET /v1/products/${PRODUCT_ID}`]: {
8237
+ data: { id: PRODUCT_ID, name: 'Web', status: 'draft', settings: { projectType: 'brownfield' } },
8238
+ },
8239
+ },
8240
+ });
8241
+ const linked = parseToolResult(
8242
+ await client.callTool({
8243
+ name: 'taiga_link_repository',
8244
+ arguments: { productId: PRODUCT_ID, githubRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
8245
+ })
8246
+ );
8247
+ expect(linked.readiness?.nextStep).toEqual(expect.any(String));
8248
+
8249
+ const { client: second } = await bootMcpPair({
8250
+ capabilities: ALL_CAPABILITIES,
8251
+ routes: {
8252
+ [`POST /v1/products/${PRODUCT_ID}/repositories`]: refusal(
8253
+ 409,
8254
+ 'CONFLICT',
8255
+ 'This product already has a repository. Change it instead of linking another.'
8256
+ ),
8257
+ },
8258
+ });
8259
+ const refused = parseToolResult(
8260
+ await second.callTool({
8261
+ name: 'taiga_link_repository',
8262
+ arguments: { productId: PRODUCT_ID, githubRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
8263
+ })
8264
+ );
8265
+ expect(refused.message).toBe(
8266
+ 'This product already has a repository. Pass replacesRepositoryId to swap it in one step, or ' +
8267
+ 'taiga_unlink_repository first (taiga_list_product_repositories gives its ID).'
8268
+ );
8269
+ });
8270
+
8271
+ it('says a dependency with no fixed version cannot be fixed, and keeps the advisory to what an agent reads', async () => {
8272
+ const FINDING = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f08';
8273
+ const finding = {
8274
+ id: FINDING,
8275
+ kind: 'vulnerable_dependency',
8276
+ packageName: 'rsa',
8277
+ recommendedVersion: null,
8278
+ remediableBy: 'coding_agent',
8279
+ runId: 'sweep-1',
8280
+ data: {
8281
+ id: 'RUSTSEC-2023-0071',
8282
+ summary: 'Marvin attack',
8283
+ details: 'd'.repeat(4_000),
8284
+ affected: [{ ranges: [] }],
8285
+ references: [1, 2, 3, 4, 5].map((n) => ({ url: `https://x/${n}` })),
8286
+ schema_version: '1.4',
8287
+ },
8288
+ };
8289
+ const { client } = await bootMcpPair({
8290
+ capabilities: ['products:read'],
8291
+ routes: {
8292
+ [`GET /v1/maintenance/findings/${FINDING}`]: { data: finding },
8293
+ [`/v1/products/${PRODUCT_ID}/maintenance/findings`]: { data: [finding], nextCursor: null },
8294
+ },
8295
+ });
8296
+
8297
+ const one = parseToolResult(
8298
+ await client.callTool({ name: 'taiga_get_finding', arguments: { findingId: FINDING } })
8299
+ );
8300
+ const whole = parseToolResult(
8301
+ await client.callTool({ name: 'taiga_get_finding', arguments: { findingId: FINDING, full: true } })
8302
+ );
8303
+ const rows = parseToolResult(
8304
+ await client.callTool({ name: 'taiga_list_findings', arguments: { productId: PRODUCT_ID } })
8305
+ );
8306
+
8307
+ expect(one.fixable).toBe(false);
8308
+ expect(one.fixNote).toMatch(/taiga_fix_finding refuses it/);
8309
+ expect(Object.keys(one.data).sort()).toEqual(['details', 'id', 'references', 'referencesNote', 'summary']);
8310
+ expect(one.data.details).toMatch(/cut at 1500 of 4000 characters/);
8311
+ expect(one.data.references).toHaveLength(3);
8312
+ expect(whole.data.affected).toEqual([{ ranges: [] }]);
8313
+ expect(rows.data[0].fixable).toBe(false);
8314
+ });
8315
+
8316
+ it('says "an" before an ID kind that starts with a vowel', async () => {
8317
+ const { client } = await bootMcpPair({ capabilities: ['backlog:read'] });
8318
+
8319
+ const result = await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: 'my item' } });
8320
+
8321
+ expect(JSON.stringify(result.content)).toMatch(/An initiative ID is a UUID/);
8322
+ });
8323
+
8324
+ it('lists only open initiatives unless asked for every one', async () => {
8325
+ const row = (id: string, status: string) => ({
8326
+ id,
8327
+ title: id,
8328
+ status,
8329
+ state: status === 'in_progress' ? 'build' : status,
8330
+ });
8331
+ const body = {
8332
+ data: [row('a', 'backlog'), row('b', 'done'), row('c', 'in_progress'), row('d', 'canceled')],
8333
+ nextCursor: null,
8334
+ };
8335
+ const { client } = await bootMcpPair({
8336
+ capabilities: ['backlog:read'],
8337
+ routes: { [`/v1/products/${PRODUCT_ID}/backlog`]: body },
8338
+ });
8339
+
8340
+ const open = parseToolResult(
8341
+ await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: PRODUCT_ID } })
8342
+ );
8343
+ const all = parseToolResult(
8344
+ await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: PRODUCT_ID, status: 'all' } })
8345
+ );
8346
+ const paged = await client.callTool({
8347
+ name: 'taiga_list_initiatives',
8348
+ arguments: { productId: PRODUCT_ID, cursor: 'p2' },
8349
+ });
8350
+
8351
+ expect(open.data.map((item: { id: string }) => item.id)).toEqual(['a', 'c']);
8352
+ expect(all.data.map((item: { id: string }) => item.id)).toEqual(['a', 'b', 'c', 'd']);
8353
+ expect(parseToolResult(paged)).toMatchObject({ error: 'VALIDATION_ERROR' });
8354
+ });
8355
+
8031
8356
  it('splits the ask-first notice into paid agent work and what others see, in whoami too', async () => {
8032
8357
  const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
8033
8358
 
@@ -22,16 +22,7 @@ import { jobReport } from './jobs.js';
22
22
  import { requestPrompt } from './request-prompt.js';
23
23
  import { queueView } from './queue.js';
24
24
  import { handbackNextStep, handbackSaid, landedDisposition } from './builds.js';
25
- import {
26
- SEARCH_NOTE,
27
- SEARCH_PAGE_SIZE,
28
- SEARCH_WITH_CURSOR,
29
- criteriaCounts,
30
- itemRows,
31
- page,
32
- pickFields,
33
- searchPages,
34
- } from './summarize.js';
25
+ import { SEARCH_PAGE_SIZE, criteriaCounts, itemRows, page, pickFields, searchPages } from './summarize.js';
35
26
 
36
27
  /** What a list row needs to say for the model to pick an item to read in full. */
37
28
  export const BACKLOG_SUMMARY_FIELDS = [
@@ -304,11 +295,12 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
304
295
  {
305
296
  title: 'List initiatives',
306
297
  description:
307
- "List a product's initiatives: title, category, status, place on the line (backlog, todo, queue, plan, " +
308
- 'build, done), situation (where it stands and what moves it on) and criteria (acceptance criteria met, ' +
309
- 'confirmed with evidence or checked off by a person; null before planning, so a done initiative can show ' +
310
- 'fewer met than it has). query finds one by title; taiga_get_initiative reads it in full. Archived ones ' +
311
- 'are left out unless asked for.',
298
+ "List a product's open initiatives (not done or canceled; status: 'all' for every one): title, " +
299
+ 'category, status, place on the line (backlog, todo, queue, plan, build, done), situation (where it ' +
300
+ 'stands and what moves it on) and criteria (acceptance criteria met, confirmed with evidence or checked ' +
301
+ 'off by a person; null before planning, so a done initiative can show fewer met than it has). query ' +
302
+ 'finds one by title, state one board column; taiga_get_initiative reads it in full. Archived ones are ' +
303
+ 'left out unless asked for.',
312
304
  inputSchema: z.object({
313
305
  productId,
314
306
  query: z
@@ -319,9 +311,11 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
319
311
  .optional()
320
312
  .describe('Part of the title, any case: only the items that contain it'),
321
313
  status: z
322
- .enum(['backlog', 'in_progress', 'done', 'canceled'])
314
+ .enum(['open', 'all', 'backlog', 'in_progress', 'done', 'canceled'])
323
315
  .optional()
324
- .describe('Only items with this status; in_progress covers everything between backlog and done'),
316
+ .describe(
317
+ 'open (default without state): not done or canceled; all: every one; in_progress covers everything between backlog and done'
318
+ ),
325
319
  planningStatus: z
326
320
  .enum(['unplanned', 'planning', 'planned', 'failed'])
327
321
  .optional()
@@ -340,45 +334,39 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
340
334
  },
341
335
  (params) =>
342
336
  runTool(async () => {
343
- const { query, state, ...filters } = params;
344
- // The API filters by status only; a board column is read across pages here (PRO-2800).
345
- if (state !== undefined) {
346
- if (params.cursor !== undefined) {
347
- return toolError(
348
- 'VALIDATION_ERROR',
349
- 'A board-column filter answers one page; narrow it with status or query.'
350
- );
351
- }
352
- const found = await searchPages(
353
- (cursor) => client.listBacklogItems(params.productId, { ...filters, limit: SEARCH_PAGE_SIZE, cursor }),
354
- query ?? '',
355
- (row) => [row.title],
356
- params.limit ?? 25,
357
- (row) => row.state === state
358
- );
359
- return toolResult({
360
- ...page(situatedRows(found.rows), null),
361
- ...(found.more
362
- ? { more: 'More items may be in that column: narrow it with status or query, or raise the limit.' }
363
- : {}),
364
- });
365
- }
366
- if (query === undefined) {
367
- const result = await client.listBacklogItems(params.productId, filters);
337
+ const { query, state, status = state === undefined ? 'open' : 'all', ...filters } = params;
338
+ // Two reviews asked "what is in the backlog" and got the whole history, done and canceled first
339
+ // (PRO-2800): open is the default. The API filters by one status only, so open, a board column and a
340
+ // title are read across pages here.
341
+ const apiFilters = { ...filters, status: status === 'open' || status === 'all' ? undefined : status };
342
+ const open = status === 'open';
343
+ if (query === undefined && state === undefined && !open) {
344
+ const result = await client.listBacklogItems(params.productId, apiFilters);
368
345
  return toolResult(page(situatedRows(result.data as Record<string, unknown>[]), result.nextCursor));
369
346
  }
370
347
  if (params.cursor !== undefined) {
371
- return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
348
+ return toolError(
349
+ 'VALIDATION_ERROR',
350
+ "Open items, a board column or a title search answer one page; narrow them, or pass status: 'all' " +
351
+ '(or one status) to page through every item.'
352
+ );
372
353
  }
373
354
  const found = await searchPages(
374
- (cursor) => client.listBacklogItems(params.productId, { ...filters, limit: SEARCH_PAGE_SIZE, cursor }),
375
- query,
355
+ (cursor) => client.listBacklogItems(params.productId, { ...apiFilters, limit: SEARCH_PAGE_SIZE, cursor }),
356
+ query ?? '',
376
357
  (row) => [row.title],
377
- params.limit ?? 25
358
+ params.limit ?? 25,
359
+ (row) => (state === undefined || row.state === state) && (!open || !['done', 'canceled'].includes(row.status))
378
360
  );
379
361
  return toolResult({
380
362
  ...page(situatedRows(found.rows), null),
381
- ...(found.more ? { more: SEARCH_NOTE } : {}),
363
+ ...(found.more
364
+ ? {
365
+ more:
366
+ 'There may be more than the limit carries: narrow it with state or query, or list one status ' +
367
+ '(backlog, in_progress, done or canceled) without them, which pages.',
368
+ }
369
+ : {}),
382
370
  });
383
371
  })
384
372
  );
@@ -309,6 +309,54 @@ export function withInitiativeState(
309
309
  };
310
310
  }
311
311
 
312
+ /** A step that changed only prose: no code of its own could have broken a test. */
313
+ const PROSE_ONLY = /(^|\/)(README|CHANGELOG|LICENSE|NOTICE|AUTHORS)$|\.(md|mdx|txt|rst|adoc)$/i;
314
+
315
+ /**
316
+ * What a check that failed after every step means (PRO-2800). Two reviews read
317
+ * seven steps of "tests: failed" as seven breakages, while the headlines said
318
+ * each step's own tests passed: a check that fails from the first step on,
319
+ * docs-only steps included, is one the repository or the check environment
320
+ * carries throughout. The check records no reason, so this says what the
321
+ * pattern shows and no more.
322
+ */
323
+ export function verificationNote(steps: Record<string, unknown>[]): string | null {
324
+ const checked = steps.filter((step) => Array.isArray(step['verification']) && step['verification'].length > 0);
325
+ if (checked.length < 2) return null;
326
+ type Check = { check?: unknown; status?: unknown; reason?: unknown };
327
+ const failedOn = (step: Record<string, unknown>) =>
328
+ (step['verification'] as Check[]).filter((check) => check.status === 'failed').map((check) => String(check.check));
329
+ const always = failedOn(checked[0]!).filter((check) => checked.every((step) => failedOn(step).includes(check)));
330
+ if (always.length === 0) return null;
331
+ const prose = checked.find((step) => {
332
+ const files = step['filesChanged'];
333
+ return Array.isArray(files) && files.length > 0 && files.every((file) => PROSE_ONLY.test(String(file)));
334
+ });
335
+ // Two code steps both failing may be two real breakages; the pattern needs three, or a step with no code.
336
+ if (!prose && checked.length < 3) return null;
337
+ const reasons = [
338
+ ...new Set(
339
+ checked.flatMap((step) =>
340
+ (step['verification'] as Check[])
341
+ .filter((check) => check.status === 'failed' && always.includes(String(check.check)))
342
+ .map((check) => check.reason)
343
+ .filter((reason): reason is string => typeof reason === 'string' && reason.length > 0)
344
+ )
345
+ ),
346
+ ];
347
+ const first = checked[0]!['stepOrder'];
348
+ const last = checked[checked.length - 1]!['stepOrder'];
349
+ const proseSaid = prose ? `, including step ${String(prose['stepOrder'])}, which changed only documentation` : '';
350
+ return (
351
+ `Taiga's ${always.join(' and ')} check failed after every checked step (${String(first)} to ${String(last)})` +
352
+ `${proseSaid}. A check failing from the first step on is usually one the repository or the check ` +
353
+ 'environment fails throughout, not one each step broke. ' +
354
+ (reasons.length > 0
355
+ ? `Its reason${reasons.length === 1 ? '' : 's'}: ${reasons.slice(0, 3).join('; ')}.`
356
+ : "It records no reason; each step's headline says what its own run found.")
357
+ );
358
+ }
359
+
312
360
  /** readableRun for a list row. */
313
361
  export const readableRow = (run: Record<string, unknown>) => readableRun(run, { row: true });
314
362
 
@@ -493,10 +541,13 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
493
541
  const stop = where ? { stoppedIn: where } : {};
494
542
  const states = await initiativeStates(client, [runRow as unknown as Record<string, unknown>]);
495
543
  const readable = withInitiativeState(readableRun(runRow), states);
496
- if (params.full) return toolResult({ ...readable, ...stop, steps: steps.data });
544
+ const note = verificationNote(rows as unknown as Record<string, unknown>[]);
545
+ const verification = note ? { verificationNote: note } : {};
546
+ if (params.full) return toolResult({ ...readable, ...stop, ...verification, steps: steps.data });
497
547
  return toolResult({
498
548
  ...readable,
499
549
  ...stop,
550
+ ...verification,
500
551
  steps: pickFields(rows, STEP_SUMMARY_FIELDS).map((step, index) => ({
501
552
  ...step,
502
553
  headline: summaryHeadline(rows[index]!.summary),
@@ -245,7 +245,7 @@ async function documentsPart(client: TaigaClient, product: WithoutTenant<Product
245
245
  return { ...out, notes };
246
246
  }
247
247
 
248
- /** Every published policy as its section ids, so the model knows which one to read with taiga_get_document. */
248
+ /** Every published policy with its section count, so the model knows which one to read with taiga_get_document. */
249
249
  async function policiesPart(client: TaigaClient) {
250
250
  const rows = (await client.listTenantDocuments({ limit: 100 })).data as Record<string, unknown>[];
251
251
  // Each policy with how many sections it has, not their ids: the same for every product, and about a fifth of
@@ -471,7 +471,7 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
471
471
  'Read where a product stands in one call; the first read for any product question. Name it by ID, slug ' +
472
472
  'or git remote URL. Returns product (in setup, readiness and next move), factory with its instructions, ' +
473
473
  'specification and architecture outlines (section ids, sizes), policies with section counts, open ' +
474
- 'initiatives (taiga_list_initiatives has the done and canceled), repositories, the latest ten runs, and counts for Action Required, open findings and ' +
474
+ "initiatives (taiga_list_initiatives status: 'all' has the done and canceled), repositories, the latest ten runs, and counts for Action Required, open findings and " +
475
475
  'policy violations. A part that failed is under unavailable, one the key cannot read under notGranted; ' +
476
476
  `neither shows as empty. documents: budget returns whole sections up to about ${DOCUMENT_BUDGET / 1000}k ` +
477
477
  'characters each, full returns all.',
@@ -40,11 +40,19 @@ export function inProductWords(value: unknown): unknown {
40
40
 
41
41
  /** The API's 404 sentence in the product's words: "Backlog item not found" is an initiative not found. */
42
42
  export function inProductWording(said: string): string {
43
- return said
44
- .replace(/^Backlog item not found/i, 'Initiative not found')
45
- .replace(/^Build run step not found/i, 'Run step not found')
46
- .replace(/^Build run not found/i, 'Run not found')
47
- .replace(/^Custom document not found/i, 'Knowledge file not found');
43
+ return (
44
+ said
45
+ .replace(/^Backlog item not found/i, 'Initiative not found')
46
+ .replace(/^Build run step not found/i, 'Run step not found')
47
+ .replace(/^Build run not found/i, 'Run not found')
48
+ .replace(/^Custom document not found/i, 'Knowledge file not found')
49
+ // The API's word for a second link names no way out; the tool has two.
50
+ .replace(
51
+ /^This product already has a repository\. Change it instead of linking another\./i,
52
+ 'This product already has a repository. Pass replacesRepositoryId to swap it in one step, or ' +
53
+ 'taiga_unlink_repository first (taiga_list_product_repositories gives its ID).'
54
+ )
55
+ );
48
56
  }
49
57
 
50
58
  /** A successful tool result: one JSON text block the model reads. */
@@ -129,7 +137,8 @@ export async function runTool(handler: () => Promise<CallToolResult>): Promise<C
129
137
  const code = error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
130
138
  const said = error.apiMessage ?? `Taiga API answered ${error.status}.`;
131
139
  // A miss says where the right IDs are (hints.ts): the API's own word is bare.
132
- const message = error.status === 404 ? withNotFoundHint(inProductWording(said)) : said;
140
+ const worded = inProductWording(said);
141
+ const message = error.status === 404 ? withNotFoundHint(worded) : worded;
133
142
  return toolError(code, message, error.details);
134
143
  }
135
144
  if (isNetworkFailure(error)) {