@taiga-ai/mcp-server 0.16.0-dev.437 → 0.17.0-dev.438

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.16.0-dev.437",
3
+ "version": "0.17.0-dev.438",
4
4
  "description": "MCP server for Taiga -- gives AI assistants access to documents and products",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/client.ts CHANGED
@@ -632,6 +632,7 @@ export class TaigaClient {
632
632
 
633
633
  async listSetupTasks(opts: {
634
634
  productId?: string | undefined;
635
+ openOnly?: boolean | undefined;
635
636
  limit?: number | undefined;
636
637
  cursor?: string | undefined;
637
638
  }) {
@@ -1527,7 +1527,8 @@ describe('MCP server integration', () => {
1527
1527
  expect(result.isError).toBe(true);
1528
1528
  expect(parseToolResult(result)).toEqual({
1529
1529
  error: 'NOT_FOUND',
1530
- message: 'The requested resource does not exist',
1530
+ message:
1531
+ 'The requested resource does not exist. taiga_list_products lists the products the key can see; a name is not an ID (taiga_get_product takes a slug).',
1531
1532
  });
1532
1533
  }
1533
1534
  });
@@ -1775,7 +1776,10 @@ describe('MCP server integration', () => {
1775
1776
  arguments: { factoryId: FACTORY_ID },
1776
1777
  });
1777
1778
 
1778
- expect(parseToolResult(result)).toEqual({ error: 'NOT_FOUND', message: 'The requested resource does not exist' });
1779
+ expect(parseToolResult(result)).toEqual({
1780
+ error: 'NOT_FOUND',
1781
+ message: 'The requested resource does not exist. taiga_list_factories lists the factories the key can see.',
1782
+ });
1779
1783
  });
1780
1784
 
1781
1785
  it('should report a factory the key cannot see as a factory miss, not an unpublished document', async () => {
@@ -1787,7 +1791,10 @@ describe('MCP server integration', () => {
1787
1791
  arguments: { factoryId: FACTORY_ID },
1788
1792
  });
1789
1793
 
1790
- expect(parseToolResult(result)).toEqual({ error: 'NOT_FOUND', message: 'The requested resource does not exist' });
1794
+ expect(parseToolResult(result)).toEqual({
1795
+ error: 'NOT_FOUND',
1796
+ message: 'The requested resource does not exist. taiga_list_factories lists the factories the key can see.',
1797
+ });
1791
1798
  });
1792
1799
 
1793
1800
  it('should point at the draft when a factory has one unpublished', async () => {
@@ -1849,7 +1856,7 @@ describe('MCP server integration', () => {
1849
1856
 
1850
1857
  expect(result.isError).toBe(true);
1851
1858
  expect(parseToolResult(result)).toMatchObject({
1852
- error: 'VALIDATION_FAILED',
1859
+ error: 'VALIDATION_ERROR',
1853
1860
  message: expect.stringContaining('exactly once'),
1854
1861
  });
1855
1862
  expect(fetchSpy.mock.calls.length).toBe(before);
@@ -1862,7 +1869,7 @@ describe('MCP server integration', () => {
1862
1869
  const result = await client.callTool({ name: 'taiga_get_product', arguments: {} });
1863
1870
 
1864
1871
  expect(result.isError).toBe(true);
1865
- expect(parseToolResult(result).error).toBe('VALIDATION_FAILED');
1872
+ expect(parseToolResult(result).error).toBe('VALIDATION_ERROR');
1866
1873
  expect(fetchSpy.mock.calls.length).toBe(before);
1867
1874
  });
1868
1875
  });
@@ -1955,7 +1962,6 @@ describe('MCP server integration', () => {
1955
1962
  slug: 'web',
1956
1963
  status: 'active',
1957
1964
  factoryId: FACTORY_ID,
1958
- kind: 'application',
1959
1965
  projectType: 'greenfield',
1960
1966
  defaultOrigin: 'plan',
1961
1967
  });
@@ -2292,7 +2298,7 @@ describe('MCP server integration', () => {
2292
2298
  for (const args of [{}, { productId: PRODUCT_ID, slug: 'web' }]) {
2293
2299
  const result = await client.callTool({ name: 'taiga_get_product_context', arguments: args });
2294
2300
  expect(result.isError).toBe(true);
2295
- expect(parseToolResult(result).error).toBe('VALIDATION_FAILED');
2301
+ expect(parseToolResult(result).error).toBe('VALIDATION_ERROR');
2296
2302
  }
2297
2303
  expect(fetchSpy.mock.calls.length).toBe(before);
2298
2304
  });
@@ -2677,7 +2683,7 @@ describe('MCP server integration', () => {
2677
2683
  expect(failure).toMatchObject({ code: -32602 });
2678
2684
  // A slash command with no argument lands here; the message says how to pass one.
2679
2685
  expect(String((failure as Error).message)).toMatch(
2680
- /^VALIDATION_FAILED: Name the product exactly once: slug or productId or remoteUrl\. Pass the slug as the first argument \(\/taiga_start_session <slug>/
2686
+ /^VALIDATION_ERROR: Name the product exactly once: slug or productId or remoteUrl\. Pass the slug as the first argument \(\/taiga_start_session <slug>/
2681
2687
  );
2682
2688
  });
2683
2689
 
@@ -3394,7 +3400,11 @@ describe('MCP server integration', () => {
3394
3400
 
3395
3401
  const none = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: OTHER_ITEM } });
3396
3402
  expect(none.isError).toBe(true);
3397
- expect(outcomeOf(none)).toEqual({ error: 'NOT_FOUND', message: 'No active plan found' });
3403
+ expect(outcomeOf(none)).toEqual({
3404
+ error: 'NOT_FOUND',
3405
+ message:
3406
+ 'No active plan found. taiga_get_backlog_item says whether one is being generated (planningStatus planning) or failed, and taiga_plan_again asks for a new one.',
3407
+ });
3398
3408
  });
3399
3409
 
3400
3410
  it('should approve a plan by its ID and reject one with the comment', async () => {
@@ -3529,7 +3539,7 @@ describe('MCP server integration', () => {
3529
3539
  const result = await client.callTool({ name: 'taiga_list_builds', arguments: { backlogItemId: ITEM_ID } });
3530
3540
 
3531
3541
  expect(result.isError).toBe(true);
3532
- expect(outcomeOf(result)).toMatchObject({ error: 'VALIDATION_FAILED' });
3542
+ expect(outcomeOf(result)).toMatchObject({ error: 'VALIDATION_ERROR' });
3533
3543
  expect(fetchSpy.mock.calls.length).toBe(before);
3534
3544
  });
3535
3545
 
@@ -3650,7 +3660,7 @@ describe('MCP server integration', () => {
3650
3660
  });
3651
3661
 
3652
3662
  expect(result.isError).toBe(true);
3653
- expect(outcomeOf(result)).toMatchObject({ error: 'VALIDATION_FAILED' });
3663
+ expect(outcomeOf(result)).toMatchObject({ error: 'VALIDATION_ERROR' });
3654
3664
  expect(fetchSpy.mock.calls.length).toBe(before);
3655
3665
  });
3656
3666
 
@@ -5033,8 +5043,10 @@ describe('MCP server integration', () => {
5033
5043
  expect(summary.controls.map((c: { controlId: string }) => c.controlId)).toEqual(['SEC-3.4']);
5034
5044
  expect(summary.violations[0]).toMatchObject({ id: VIOLATION_ID, title: 'Plaintext' });
5035
5045
  expect(summary.violations[0]).not.toHaveProperty('evidence');
5036
- expect(summary.resolvedViolations).toBe(2);
5046
+ expect(summary.summary.resolvedViolations).toBe(2);
5037
5047
  expect(full.divergences[0].evidence).toBe('long evidence');
5048
+ // The counts lead the full answer too.
5049
+ expect(Object.keys(full)[0]).toBe('summary');
5038
5050
  });
5039
5051
 
5040
5052
  it('should fix a control or one violation through its own route', async () => {
@@ -6007,7 +6019,11 @@ describe('MCP server integration', () => {
6007
6019
  expect(finish.isError).toBe(true);
6008
6020
  expect(parseToolResult(finish)).toEqual({
6009
6021
  error: 'CONFLICT',
6010
- message: 'Every required document must be published before setup can be completed',
6022
+ message:
6023
+ 'Every required document must be published before setup can be completed. An imported product gets ' +
6024
+ 'them from its import (taiga_start_import); a product started from scratch gets the specification from ' +
6025
+ 'the Discovery conversation in the web and the rest from taiga_generate_product_documents. A draft is ' +
6026
+ 'published with taiga_publish_product_document.',
6011
6027
  details: { reason: 'documents_incomplete', missingDocuments: ['threat_model', 'risk_register'] },
6012
6028
  });
6013
6029
  expect(parseToolResult(move)).toMatchObject({ error: 'CONFLICT', details: { reason: 'work_in_flight' } });
@@ -6050,18 +6066,18 @@ describe('MCP server integration', () => {
6050
6066
  });
6051
6067
 
6052
6068
  expect(parseToolResult(unknown)).toMatchObject({ error: 'NOT_FOUND', details: { accounts: expect.any(Array) } });
6053
- expect(parseToolResult(cursor).error).toBe('VALIDATION_FAILED');
6069
+ expect(parseToolResult(cursor).error).toBe('VALIDATION_ERROR');
6054
6070
  const pagedSearch = await client.callTool({
6055
6071
  name: 'taiga_list_linkable_repositories',
6056
6072
  arguments: { installationId: INSTALL_ID, query: 'bill', cursor: 'abc' },
6057
6073
  });
6058
- expect(parseToolResult(pagedSearch).error).toBe('VALIDATION_FAILED');
6059
- expect(parseToolResult(notes).error).toBe('VALIDATION_FAILED');
6074
+ expect(parseToolResult(pagedSearch).error).toBe('VALIDATION_ERROR');
6075
+ expect(parseToolResult(notes).error).toBe('VALIDATION_ERROR');
6060
6076
  const updateNotes = await client.callTool({
6061
6077
  name: 'taiga_update_product',
6062
6078
  arguments: { productId: PRODUCT_ID, ownsInfrastructure: true, ownsDeployment: true, deploymentNotes: 'x' },
6063
6079
  });
6064
- expect(parseToolResult(updateNotes).error).toBe('VALIDATION_FAILED');
6080
+ expect(parseToolResult(updateNotes).error).toBe('VALIDATION_ERROR');
6065
6081
  const asked = fetchSpy.mock.calls.map((c: unknown[]) => new URL(c[0] as string).pathname);
6066
6082
  expect(asked.filter((p: string) => p.includes('/github/installations/'))).toEqual([]);
6067
6083
  expect(asked).not.toContain('/v1/products');
@@ -6176,3 +6192,344 @@ describe('MCP server integration', () => {
6176
6192
  });
6177
6193
  });
6178
6194
  });
6195
+
6196
+ // ---------------------------------------------------------------------------
6197
+ // What a fresh agent's review of the server found it needed (PRO-2759): a
6198
+ // miss that says where the right IDs are, a search by name, what a product
6199
+ // in setup waits for, an empty answer that says why, and a run that says
6200
+ // where it stopped.
6201
+ // ---------------------------------------------------------------------------
6202
+
6203
+ describe('what the agent review found (PRO-2759)', () => {
6204
+ const ENV_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a70';
6205
+ const LINK_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a71';
6206
+
6207
+ beforeEach(() => {
6208
+ vi.restoreAllMocks();
6209
+ });
6210
+
6211
+ it("adds to the API's 404 where the right IDs are, one hint per ID the call named, the product last", async () => {
6212
+ const { client } = await bootMcpPair({
6213
+ capabilities: ALL_CAPABILITIES,
6214
+ routes: {
6215
+ [`GET /v1/backlog/${ITEM_ID}`]: refusal(404, 'NOT_FOUND', 'Backlog item not found'),
6216
+ [`GET /v1/products/${PRODUCT_ID}/repositories/${LINK_ID}/branches`]: refusal(
6217
+ 404,
6218
+ 'NOT_FOUND',
6219
+ 'Repository link not found'
6220
+ ),
6221
+ },
6222
+ });
6223
+
6224
+ const item = await client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } });
6225
+ expect(parseToolResult(item)).toEqual({
6226
+ error: 'NOT_FOUND',
6227
+ message:
6228
+ "Backlog item not found. taiga_list_backlog_items lists a product's initiatives with their IDs; a run or " +
6229
+ 'plan ID is not one.',
6230
+ });
6231
+
6232
+ const branches = await client.callTool({
6233
+ name: 'taiga_list_repository_branches',
6234
+ arguments: { productId: PRODUCT_ID, repositoryId: LINK_ID },
6235
+ });
6236
+ expect(parseToolResult(branches).message).toBe(
6237
+ "Repository link not found. taiga_list_product_repositories lists the product's repository links; a GitHub " +
6238
+ 'repository id is not a link ID. taiga_list_products lists the products the key can see; a name is not ' +
6239
+ 'an ID (taiga_get_product takes a slug).'
6240
+ );
6241
+ });
6242
+
6243
+ it('sends a mistyped initiative on taiga_get_plan to the initiative list, and only a missing plan to planning', async () => {
6244
+ const OTHER = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a72';
6245
+ const { client } = await bootMcpPair({
6246
+ capabilities: ALL_CAPABILITIES,
6247
+ routes: {
6248
+ [`GET /v1/backlog/${ITEM_ID}/plans/active`]: refusal(404, 'NOT_FOUND', 'No active plan found'),
6249
+ [`GET /v1/backlog/${OTHER}/plans/active`]: refusal(404, 'NOT_FOUND', 'Backlog item not found'),
6250
+ },
6251
+ });
6252
+
6253
+ const noPlan = parseToolResult(await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: ITEM_ID } }));
6254
+ const noItem = parseToolResult(await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: OTHER } }));
6255
+
6256
+ expect(noPlan.message).toContain('taiga_plan_again asks for a new one');
6257
+ expect(noItem.message).toBe(
6258
+ "Backlog item not found. taiga_list_backlog_items lists a product's initiatives with their IDs; a run or " +
6259
+ 'plan ID is not one.'
6260
+ );
6261
+ });
6262
+
6263
+ it('says a run that failed before any step started stopped while setting up, not at a step or the PR', async () => {
6264
+ const { client } = await bootMcpPair({
6265
+ capabilities: ALL_CAPABILITIES,
6266
+ routes: {
6267
+ [`GET /v1/builds/${BUILD_ID}`]: { data: { id: BUILD_ID, status: 'failed', hardeningResult: null } },
6268
+ [`GET /v1/builds/${BUILD_ID}/steps`]: { data: [{ stepOrder: 1, title: 'Add the route', status: 'planned' }] },
6269
+ },
6270
+ });
6271
+
6272
+ const result = parseToolResult(
6273
+ await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
6274
+ );
6275
+
6276
+ expect(result.stoppedIn).toMatch(/^before the first step started/);
6277
+ });
6278
+
6279
+ it('says where a product ID comes from when a name is passed for one', async () => {
6280
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
6281
+
6282
+ const result = await client.callTool({ name: 'taiga_list_backlog_items', arguments: { productId: 'Web shop' } });
6283
+
6284
+ expect(result.isError).toBe(true);
6285
+ expect((result.content as { text: string }[])[0]!.text).toContain(
6286
+ 'A product ID is a UUID, not a name: taiga_list_products (query finds one by name)'
6287
+ );
6288
+ });
6289
+
6290
+ it('finds a product by part of its name across pages, and refuses a cursor with a search', async () => {
6291
+ const page1 = [
6292
+ { id: PRODUCT_ID, name: 'Web shop', slug: 'web-shop', status: 'active', factoryId: FACTORY_ID, kind: null },
6293
+ { id: 'p-2', name: 'Billing', slug: 'billing', status: 'active', factoryId: FACTORY_ID, kind: null },
6294
+ ];
6295
+ const page2 = [{ id: PRODUCT_ID_2, name: 'Shop admin', slug: 'admin', status: 'draft', factoryId: FACTORY_ID }];
6296
+ const { client } = await bootMcpPair({
6297
+ capabilities: ALL_CAPABILITIES,
6298
+ routes: {
6299
+ '/v1/products': (url: string) => {
6300
+ const second = new URL(url).searchParams.get('cursor') === 'c2';
6301
+ const body = second ? { data: page2, nextCursor: null } : { data: page1, nextCursor: 'c2' };
6302
+ return {
6303
+ ok: true,
6304
+ status: 200,
6305
+ json: () => Promise.resolve(body),
6306
+ text: () => Promise.resolve(JSON.stringify(body)),
6307
+ };
6308
+ },
6309
+ },
6310
+ });
6311
+
6312
+ const found = await client.callTool({ name: 'taiga_list_products', arguments: { query: 'SHOP' } });
6313
+ expect(parseToolResult(found)).toEqual({
6314
+ data: [
6315
+ { id: PRODUCT_ID, name: 'Web shop', slug: 'web-shop', status: 'active', factoryId: FACTORY_ID },
6316
+ { id: PRODUCT_ID_2, name: 'Shop admin', slug: 'admin', status: 'draft', factoryId: FACTORY_ID },
6317
+ ],
6318
+ nextCursor: null,
6319
+ });
6320
+
6321
+ const paged = await client.callTool({ name: 'taiga_list_products', arguments: { query: 'shop', cursor: 'c2' } });
6322
+ expect(parseToolResult(paged).error).toBe('VALIDATION_ERROR');
6323
+ });
6324
+
6325
+ it('reads a policy by section, and keeps the sections that exist when one asked for does not', async () => {
6326
+ const { client } = await bootMcpPair({
6327
+ capabilities: ALL_CAPABILITIES,
6328
+ routes: { [`GET /v1/tenants/current/documents/policy_security`]: { data: policyDocument() } },
6329
+ });
6330
+
6331
+ const result = await client.callTool({
6332
+ name: 'taiga_get_document',
6333
+ arguments: { type: 'policy_security', sections: ['scope', 'secrets'] },
6334
+ });
6335
+
6336
+ const doc = parseToolResult(result);
6337
+ expect(Object.keys(doc.data.sections)).toEqual(['scope']);
6338
+ expect(doc.omittedSections).toEqual([{ id: 'summary', characters: 'Old summary'.length }]);
6339
+ expect(doc.unknownSections).toEqual(['secrets']);
6340
+
6341
+ const none = await client.callTool({
6342
+ name: 'taiga_get_document',
6343
+ arguments: { type: 'policy_security', sections: ['secrets'] },
6344
+ });
6345
+ expect(parseToolResult(none)).toEqual({
6346
+ error: 'NOT_FOUND',
6347
+ message: 'The policy_security has no section secrets; its sections are summary, scope.',
6348
+ });
6349
+ });
6350
+
6351
+ it('says what a product in setup waits for, and warns of a half turned off with nothing said about it', async () => {
6352
+ const draft = {
6353
+ id: PRODUCT_ID,
6354
+ name: 'Web',
6355
+ slug: 'web',
6356
+ status: 'draft',
6357
+ factoryId: FACTORY_ID,
6358
+ discoveryStep: 'context',
6359
+ settings: { projectType: 'brownfield', importStatus: 'pending' },
6360
+ ownsInfrastructure: false,
6361
+ deploymentNotes: null,
6362
+ };
6363
+ const { client } = await bootMcpPair({
6364
+ capabilities: ALL_CAPABILITIES,
6365
+ routes: {
6366
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: draft },
6367
+ [`PATCH /v1/products/${PRODUCT_ID}`]: { data: draft },
6368
+ },
6369
+ });
6370
+
6371
+ const product = parseToolResult(
6372
+ await client.callTool({ name: 'taiga_get_product', arguments: { productId: PRODUCT_ID } })
6373
+ );
6374
+ expect(product.nextStep).toBe(
6375
+ 'Link its repository if it has none (taiga_list_linkable_repositories, taiga_link_repository), then ' +
6376
+ 'taiga_start_import: the import derives the documents from the code and runs by itself.'
6377
+ );
6378
+
6379
+ const updated = parseToolResult(
6380
+ await client.callTool({
6381
+ name: 'taiga_update_product',
6382
+ arguments: { productId: PRODUCT_ID, ownsInfrastructure: false },
6383
+ })
6384
+ );
6385
+ expect(updated.nextStep).toBe(product.nextStep);
6386
+ expect(updated.warnings).toEqual([expect.stringContaining('has no deploymentNotes')]);
6387
+ });
6388
+
6389
+ it("reads the step of a product the write's answer leaves it out of, and says nothing rather than guess", async () => {
6390
+ const written = { id: PRODUCT_ID, name: 'Web', slug: 'web', status: 'draft', factoryId: FACTORY_ID, settings: {} };
6391
+ const { client } = await bootMcpPair({
6392
+ capabilities: ALL_CAPABILITIES,
6393
+ routes: {
6394
+ [`PATCH /v1/products/${PRODUCT_ID}`]: { data: written },
6395
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: { ...written, discoveryStep: 'documents' } },
6396
+ [`PATCH /v1/products/${PRODUCT_ID_2}`]: { data: { ...written, id: PRODUCT_ID_2 } },
6397
+ },
6398
+ });
6399
+
6400
+ const read = parseToolResult(
6401
+ await client.callTool({ name: 'taiga_update_product', arguments: { productId: PRODUCT_ID, name: 'Web' } })
6402
+ );
6403
+ // The product's read is unreachable here (the mock answers 404): no nextStep, not the conversation's.
6404
+ const unread = parseToolResult(
6405
+ await client.callTool({ name: 'taiga_update_product', arguments: { productId: PRODUCT_ID_2, name: 'Web' } })
6406
+ );
6407
+
6408
+ expect(read.nextStep).toMatch(/^The specification is done/);
6409
+ expect(unread).not.toHaveProperty('nextStep');
6410
+ });
6411
+
6412
+ it('says nothing of where a merged run stopped, whatever its stale status says', async () => {
6413
+ const { client } = await bootMcpPair({
6414
+ capabilities: ALL_CAPABILITIES,
6415
+ routes: {
6416
+ [`GET /v1/builds/${BUILD_ID}`]: {
6417
+ data: { id: BUILD_ID, status: 'failed', prMerged: true, hardeningResult: null },
6418
+ },
6419
+ [`GET /v1/builds/${BUILD_ID}/steps`]: { data: [{ stepOrder: 1, title: 'Add the route', status: 'completed' }] },
6420
+ },
6421
+ });
6422
+
6423
+ const result = parseToolResult(
6424
+ await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
6425
+ );
6426
+
6427
+ expect(result).not.toHaveProperty('stoppedIn');
6428
+ });
6429
+
6430
+ it('lists the setup tasks still to do by default, each naming its product', async () => {
6431
+ const task = {
6432
+ id: 't-1',
6433
+ productId: PRODUCT_ID,
6434
+ productName: 'Web',
6435
+ title: 'Add STRIPE_KEY',
6436
+ inboxItemStatus: 'open',
6437
+ };
6438
+ const { client, fetchSpy } = await bootMcpPair({
6439
+ capabilities: ALL_CAPABILITIES,
6440
+ routes: { '/v1/setup-tasks': { data: [task], nextCursor: null } },
6441
+ });
6442
+
6443
+ const open = parseToolResult(await client.callTool({ name: 'taiga_list_setup_tasks', arguments: {} }));
6444
+ await client.callTool({ name: 'taiga_list_setup_tasks', arguments: { includeDone: true } });
6445
+
6446
+ expect(open.data[0]).toMatchObject({ productName: 'Web' });
6447
+ expect(sentUrl(fetchSpy, '/setup-tasks').searchParams.get('openOnly')).toBe('true');
6448
+ const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string).filter((u) => u.includes('/setup-tasks'));
6449
+ expect(urls[1]).not.toContain('openOnly');
6450
+ });
6451
+
6452
+ it('says where a failed run stopped: in hardening, when every step completed', async () => {
6453
+ const run = { id: BUILD_ID, status: 'failed', failedReason: 'agent_stalled', hardeningResult: null };
6454
+ const { client } = await bootMcpPair({
6455
+ capabilities: ALL_CAPABILITIES,
6456
+ routes: {
6457
+ [`GET /v1/builds/${BUILD_ID}`]: { data: run },
6458
+ [`GET /v1/builds/${BUILD_ID}/steps`]: { data: [{ stepOrder: 1, title: 'Add the route', status: 'completed' }] },
6459
+ },
6460
+ });
6461
+
6462
+ const result = parseToolResult(
6463
+ await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
6464
+ );
6465
+
6466
+ expect(result.stoppedIn).toMatch(/^after the last step, in hardening/);
6467
+ });
6468
+
6469
+ it('says why an empty deployments answer is empty, and refuses an environment that is not the product’s', async () => {
6470
+ const { client } = await bootMcpPair({
6471
+ capabilities: ALL_CAPABILITIES,
6472
+ routes: {
6473
+ [`GET /v1/products/${PRODUCT_ID}/environments`]: { data: [], nextCursor: null },
6474
+ [`GET /v1/products/${PRODUCT_ID}/deployments/current`]: { data: { current: [], deploying: [] } },
6475
+ [`GET /v1/products/${PRODUCT_ID}/deployments`]: { data: [], nextCursor: null },
6476
+ [`GET /v1/products/${PRODUCT_ID}/deployments/sync-status`]: { data: {} },
6477
+ [`GET /v1/products/${PRODUCT_ID}/monitoring/health`]: { data: [] },
6478
+ },
6479
+ });
6480
+
6481
+ const deployments = parseToolResult(
6482
+ await client.callTool({ name: 'taiga_get_deployments', arguments: { productId: PRODUCT_ID } })
6483
+ );
6484
+ expect(deployments.note).toMatch(/^This product has no environments yet/);
6485
+
6486
+ const health = parseToolResult(
6487
+ await client.callTool({ name: 'taiga_get_monitoring', arguments: { productId: PRODUCT_ID } })
6488
+ );
6489
+ expect(health.note).toMatch(/^No environment is monitored/);
6490
+
6491
+ // Every signal answers 404, as the API does for another product's environment.
6492
+ const wrong = parseToolResult(
6493
+ await client.callTool({
6494
+ name: 'taiga_get_monitoring',
6495
+ arguments: { productId: PRODUCT_ID, environmentId: ENV_ID },
6496
+ })
6497
+ );
6498
+ expect(wrong.error).toBe('NOT_FOUND');
6499
+ });
6500
+
6501
+ it('leads the policy standing with its counts', async () => {
6502
+ const { client } = await bootMcpPair({
6503
+ capabilities: ALL_CAPABILITIES,
6504
+ routes: {
6505
+ [`GET /v1/products/${PRODUCT_ID}/maintenance/policies`]: {
6506
+ data: {
6507
+ productId: PRODUCT_ID,
6508
+ hasPolicies: true,
6509
+ controls: [
6510
+ { controlId: 'SEC-5.1', divergenceCount: 2 },
6511
+ { controlId: 'SEC-1.1', divergenceCount: 0 },
6512
+ ],
6513
+ divergences: [
6514
+ { id: 'v1', controlId: 'SEC-5.1', severity: 'high' },
6515
+ { id: 'v2', controlId: 'SEC-5.1', severity: 'critical' },
6516
+ ],
6517
+ resolvedDivergences: [{}],
6518
+ },
6519
+ },
6520
+ },
6521
+ });
6522
+
6523
+ const standing = parseToolResult(
6524
+ await client.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID } })
6525
+ );
6526
+
6527
+ expect(Object.keys(standing)[0]).toBe('summary');
6528
+ expect(standing.summary).toEqual({
6529
+ controls: 1,
6530
+ violations: 2,
6531
+ bySeverity: { high: 1, critical: 1 },
6532
+ resolvedViolations: 1,
6533
+ });
6534
+ });
6535
+ });
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import type { TaigaClient } from '../client.js';
4
4
  import { OPERATE_SETTING } from './annotations.js';
5
5
  import { runTool, toolError, toolResult } from './errors.js';
6
- import { productId } from './inputs.js';
6
+ import { idInput, productId } from './inputs.js';
7
7
  import { pickFields } from './summarize.js';
8
8
 
9
9
  /**
@@ -44,7 +44,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
44
44
  (params) =>
45
45
  runTool(async () => {
46
46
  if (params.autoBuild === undefined && params.autoMerge === undefined) {
47
- return toolError('VALIDATION_FAILED', 'Set autoBuild, autoMerge or both.');
47
+ return toolError('VALIDATION_ERROR', 'Set autoBuild, autoMerge or both.');
48
48
  }
49
49
  // `true` and `null` mean the same at the product (the scopes above
50
50
  // decide), and the web writes null to allow; so does this.
@@ -72,7 +72,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
72
72
  'organization and factories allow: where either has turned autonomous merge off, true changes nothing. ' +
73
73
  'Taiga merges through your rules, never around them. Ask the person first.',
74
74
  inputSchema: z.object({
75
- itemId: z.string().uuid().describe('Backlog item ID -- get from taiga_list_backlog_items'),
75
+ itemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it'),
76
76
  autoMerge: z
77
77
  .boolean()
78
78
  .nullable()