@taiga-ai/mcp-server 0.20.0-dev.454 → 0.21.0-dev.455

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.20.0-dev.454",
3
+ "version": "0.21.0-dev.455",
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",
@@ -5,6 +5,7 @@ import { TaigaClient } from './client.js';
5
5
  import { createServer } from './server.js';
6
6
  import type { WaitOptions } from './tools/wait.js';
7
7
  import { inProductWords } from './tools/errors.js';
8
+ import { PAID_WORK } from './tools/index.js';
8
9
 
9
10
  /** What a tool returns for an API value: its keys in the product's words (PRO-2800). */
10
11
  const asSeen = <T>(value: T): T => inProductWords(value) as T;
@@ -38,9 +39,9 @@ const ALL_CAPABILITIES = [
38
39
  const ALL_TOOL_NAMES = [
39
40
  'taiga_add_knowledge',
40
41
  'taiga_approve_plan',
42
+ 'taiga_create_environment',
41
43
  'taiga_create_factory',
42
44
  'taiga_create_product',
43
- 'taiga_define_environment',
44
45
  'taiga_delete_knowledge',
45
46
  'taiga_finish_discovery',
46
47
  'taiga_fix_finding',
@@ -55,7 +56,6 @@ const ALL_TOOL_NAMES = [
55
56
  'taiga_get_job',
56
57
  'taiga_get_knowledge',
57
58
  'taiga_get_line',
58
- 'taiga_get_maintenance',
59
59
  'taiga_get_monitoring',
60
60
  'taiga_get_plan',
61
61
  'taiga_get_policy_standing',
@@ -97,7 +97,6 @@ const ALL_TOOL_NAMES = [
97
97
  'taiga_reopen_discovery',
98
98
  'taiga_request_initiatives',
99
99
  'taiga_resolve_action',
100
- 'taiga_resolve_product',
101
100
  'taiga_resume_initiative',
102
101
  'taiga_retry_knowledge',
103
102
  'taiga_set_auto_build',
@@ -439,7 +438,6 @@ describe('MCP server integration', () => {
439
438
  'taiga_list_products',
440
439
  'taiga_get_product',
441
440
  'taiga_get_product_context',
442
- 'taiga_resolve_product',
443
441
  'taiga_list_product_documents',
444
442
  'taiga_get_product_document',
445
443
  'taiga_list_runs',
@@ -448,7 +446,6 @@ describe('MCP server integration', () => {
448
446
  'taiga_wait_for_run',
449
447
  'taiga_get_job',
450
448
  'taiga_wait_for_job',
451
- 'taiga_get_maintenance',
452
449
  'taiga_list_findings',
453
450
  'taiga_get_finding',
454
451
  'taiga_get_policy_standing',
@@ -504,7 +501,7 @@ describe('MCP server integration', () => {
504
501
  'taiga_fix_finding',
505
502
  'taiga_fix_policy_violation',
506
503
  'taiga_resolve_action',
507
- 'taiga_define_environment',
504
+ 'taiga_create_environment',
508
505
  'taiga_update_environment',
509
506
  'taiga_update_product_document',
510
507
  'taiga_regenerate_product_document',
@@ -978,24 +975,19 @@ describe('MCP server integration', () => {
978
975
  };
979
976
  const step = { id: 'step-1', runId: BUILD_ID, stepOrder: 1, title: 'Add the route', status: 'completed' };
980
977
 
981
- it('should resolve the product from a git remote through the repository filter', async () => {
978
+ it('should read the product a git remote belongs to through the repository filter', async () => {
982
979
  const { client, fetchSpy } = await bootMcpPair({
983
980
  capabilities: ['products:read'],
984
981
  routes: { '/v1/products': { data: [product], nextCursor: null } },
985
982
  });
986
983
 
987
984
  const result = await client.callTool({
988
- name: 'taiga_resolve_product',
985
+ name: 'taiga_get_product',
989
986
  arguments: { remoteUrl: 'git@github.com:Acme/Web.git' },
990
987
  });
991
988
 
992
989
  expect(result.isError).toBeFalsy();
993
- expect(parseToolResult(result)).toEqual({
994
- repository: 'Acme/Web',
995
- host: 'https://github.com',
996
- products: [productSummary],
997
- truncated: false,
998
- });
990
+ expect(parseToolResult(result)).toMatchObject({ id: PRODUCT_ID, name: product.name });
999
991
  const url = sentUrl(fetchSpy, '/v1/products');
1000
992
  expect(url.searchParams.get('repository')).toBe('Acme/Web');
1001
993
  expect(url.searchParams.get('repositoryHost')).toBe('https://github.com');
@@ -1016,13 +1008,7 @@ describe('MCP server integration', () => {
1016
1008
  });
1017
1009
  }
1018
1010
 
1019
- const productPageCursors = (fetchSpy: ReturnType<typeof vi.fn>) =>
1020
- fetchSpy.mock.calls
1021
- .map((c: unknown[]) => new URL(c[0] as string))
1022
- .filter((u: URL) => u.pathname === '/v1/products')
1023
- .map((u: URL) => u.searchParams.get('cursor'));
1024
-
1025
- it('should follow the pages of a shared repository rather than stop at the first', async () => {
1011
+ it('should refuse a remote shared by several products with their IDs, across pages', async () => {
1026
1012
  const second = { ...product, id: PRODUCT_ID_2, name: 'Web Admin' };
1027
1013
  const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
1028
1014
  serveProductPages(fetchSpy, (cursor) =>
@@ -1030,32 +1016,18 @@ describe('MCP server integration', () => {
1030
1016
  );
1031
1017
 
1032
1018
  const result = await client.callTool({
1033
- name: 'taiga_resolve_product',
1034
- arguments: { remoteUrl: 'git@github.com:acme/web.git' },
1035
- });
1036
-
1037
- const parsed = parseToolResult(result);
1038
- expect(parsed.products.map((p: { id: string }) => p.id)).toEqual([PRODUCT_ID, PRODUCT_ID_2]);
1039
- expect(parsed.truncated).toBe(false);
1040
- expect(productPageCursors(fetchSpy)).toEqual([null, 'page-2']);
1041
- });
1042
-
1043
- it('should stop after ten pages and say the list is truncated', async () => {
1044
- const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
1045
- serveProductPages(fetchSpy, () => ({ data: [product], nextCursor: 'more' }));
1046
-
1047
- const result = await client.callTool({
1048
- name: 'taiga_resolve_product',
1019
+ name: 'taiga_get_product',
1049
1020
  arguments: { remoteUrl: 'git@github.com:acme/web.git' },
1050
1021
  });
1051
1022
 
1052
- const parsed = parseToolResult(result);
1053
- expect(parsed.products).toHaveLength(10);
1054
- expect(parsed.truncated).toBe(true);
1055
- expect(productPageCursors(fetchSpy)).toHaveLength(10);
1023
+ expect(result.isError).toBe(true);
1024
+ const said = parseToolResult(result);
1025
+ expect(said.error).toBe('AMBIGUOUS');
1026
+ expect(said.message).toContain(PRODUCT_ID);
1027
+ expect(said.message).toContain(PRODUCT_ID_2);
1056
1028
  });
1057
1029
 
1058
- it('should fail the whole resolution when a later page fails, not hand back a partial list', async () => {
1030
+ it('should fail the whole resolution when a later page fails, not answer from a partial list', async () => {
1059
1031
  const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
1060
1032
  const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
1061
1033
  fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
@@ -1070,7 +1042,7 @@ describe('MCP server integration', () => {
1070
1042
  });
1071
1043
 
1072
1044
  const result = await client.callTool({
1073
- name: 'taiga_resolve_product',
1045
+ name: 'taiga_get_product',
1074
1046
  arguments: { remoteUrl: 'git@github.com:acme/web.git' },
1075
1047
  });
1076
1048
 
@@ -1085,7 +1057,7 @@ describe('MCP server integration', () => {
1085
1057
  });
1086
1058
 
1087
1059
  const result = await client.callTool({
1088
- name: 'taiga_resolve_product',
1060
+ name: 'taiga_get_product',
1089
1061
  arguments: { remoteUrl: 'https://github.com/acme/web.git' },
1090
1062
  });
1091
1063
 
@@ -1101,7 +1073,7 @@ describe('MCP server integration', () => {
1101
1073
  const before = fetchSpy.mock.calls.length;
1102
1074
 
1103
1075
  const result = await client.callTool({
1104
- name: 'taiga_resolve_product',
1076
+ name: 'taiga_get_product',
1105
1077
  arguments: { remoteUrl: '/Users/dev/web' },
1106
1078
  });
1107
1079
 
@@ -1205,9 +1177,11 @@ describe('MCP server integration', () => {
1205
1177
  expect(parseToolResult(result)).toEqual({
1206
1178
  ...rest,
1207
1179
  initiativeId: ITEM_ID,
1208
- // Completed with no merge recorded: an older run that settled on an open pull request.
1180
+ // Completed with no merge recorded: an older run that settled on an open pull request. Without
1181
+ // backlog:read the key cannot say whether its initiative still wants it.
1209
1182
  disposition: 'unmerged',
1210
- outcome: 'Settled without merging: its pull request was left unmerged.',
1183
+ outcome:
1184
+ 'Settled without merging: its pull request was left unmerged. Whether its initiative still wants it was not checked.',
1211
1185
  steps: [step],
1212
1186
  });
1213
1187
  });
@@ -2031,9 +2005,7 @@ describe('MCP server integration', () => {
2031
2005
  expect(bundle.documents.notes).toEqual([
2032
2006
  'No published plan architecture; taiga_list_product_documents shows what the product has.',
2033
2007
  ]);
2034
- expect(bundle.policies).toEqual([
2035
- { type: 'policy_security', name: 'Security Policy', version: 3, sections: ['summary', 'scope'] },
2036
- ]);
2008
+ expect(bundle.policies).toEqual([{ type: 'policy_security', name: 'Security Policy', version: 3, sections: 2 }]);
2037
2009
  expect(bundle.initiatives.items.map((row: { id: string }) => row.id)).toEqual([ITEM_ID]);
2038
2010
  expect(bundle.initiatives.items[0]).not.toHaveProperty('description');
2039
2011
  expect(bundle.initiatives.truncated).toBe(false);
@@ -2966,7 +2938,7 @@ describe('MCP server integration', () => {
2966
2938
  'taiga_stop_run',
2967
2939
  'taiga_request_initiatives',
2968
2940
  'taiga_plan_again',
2969
- 'taiga_define_environment',
2941
+ 'taiga_create_environment',
2970
2942
  'taiga_add_knowledge',
2971
2943
  'taiga_retry_knowledge',
2972
2944
  ]) {
@@ -4978,7 +4950,7 @@ describe('MCP server integration', () => {
4978
4950
  .filter((c: unknown[]) => ((c[1] as RequestInit | undefined)?.method ?? 'GET') === method)
4979
4951
  .map((c: unknown[]) => new URL(c[0] as string));
4980
4952
 
4981
- it("should read a product's maintenance standing with its latest sweeps, and say when it was never swept", async () => {
4953
+ it('should open the findings with the sweep overview, and say when the product was never swept', async () => {
4982
4954
  const summary = {
4983
4955
  productId: PRODUCT_ID,
4984
4956
  openFindings: 2,
@@ -4990,6 +4962,7 @@ describe('MCP server integration', () => {
4990
4962
  capabilities: ['products:read'],
4991
4963
  routes: {
4992
4964
  [`/v1/products/${PRODUCT_ID}/maintenance/summary`]: { data: summary },
4965
+ [`/v1/products/${PRODUCT_ID}/maintenance/findings`]: { data: [], nextCursor: null },
4993
4966
  [`/v1/products/${PRODUCT_ID}/maintenance/runs`]: {
4994
4967
  data: [
4995
4968
  { id: 'run-1', trigger: 'event', status: 'completed', scanners: { deps: 'passed' }, agentJobId: 'x' },
@@ -4999,8 +4972,8 @@ describe('MCP server integration', () => {
4999
4972
  },
5000
4973
  });
5001
4974
  const standing = parseToolResult(
5002
- await swept.client.callTool({ name: 'taiga_get_maintenance', arguments: { productId: PRODUCT_ID } })
5003
- );
4975
+ await swept.client.callTool({ name: 'taiga_list_findings', arguments: { productId: PRODUCT_ID } })
4976
+ ).sweep;
5004
4977
  expect(standing).toMatchObject({ openFindings: 2, latestSweeps: [{ id: 'run-1', status: 'completed' }] });
5005
4978
  expect(standing.latestSweeps[0]).not.toHaveProperty('agentJobId');
5006
4979
  expect(standing).not.toHaveProperty('note');
@@ -5010,12 +4983,13 @@ describe('MCP server integration', () => {
5010
4983
  capabilities: ['products:read'],
5011
4984
  routes: {
5012
4985
  [`/v1/products/${PRODUCT_ID}/maintenance/summary`]: { data: { ...summary, openFindings: 0 } },
4986
+ [`/v1/products/${PRODUCT_ID}/maintenance/findings`]: { data: [], nextCursor: null },
5013
4987
  [`/v1/products/${PRODUCT_ID}/maintenance/runs`]: { data: [], nextCursor: null },
5014
4988
  },
5015
4989
  });
5016
4990
  const quiet = parseToolResult(
5017
- await never.client.callTool({ name: 'taiga_get_maintenance', arguments: { productId: PRODUCT_ID } })
5018
- );
4991
+ await never.client.callTool({ name: 'taiga_list_findings', arguments: { productId: PRODUCT_ID } })
4992
+ ).sweep;
5019
4993
  expect(quiet.note).toMatch(/never been swept/);
5020
4994
  });
5021
4995
 
@@ -5437,7 +5411,7 @@ describe('MCP server integration', () => {
5437
5411
  });
5438
5412
 
5439
5413
  await client.callTool({
5440
- name: 'taiga_define_environment',
5414
+ name: 'taiga_create_environment',
5441
5415
  arguments: { productId: PRODUCT_ID, name: 'staging', type: 'staging' },
5442
5416
  });
5443
5417
  expect(sentBody(fetchSpy, 'POST', '/environments')).toEqual({ name: 'staging', type: 'staging' });
@@ -7073,11 +7047,15 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
7073
7047
  for (const tool of tools) {
7074
7048
  const notes = tool.annotations ?? {};
7075
7049
  const description = tool.description ?? '';
7076
- if (notes.readOnlyHint) expect(description, tool.name).not.toMatch(/^(Writes\.|Ask first:)/);
7077
- else if (notes.destructiveHint) expect(description, tool.name).toMatch(/^Ask first: paid or shared work\. /);
7050
+ if (notes.readOnlyHint) expect(description, tool.name).not.toMatch(/^(Writes\.|Ask first)/);
7051
+ else if (notes.destructiveHint)
7052
+ expect(description, tool.name).toMatch(/^Ask first \((paid agent work|others see it)\)\. /);
7078
7053
  else expect(description, tool.name).toMatch(/^Writes\. /);
7079
7054
  }
7080
- expect(tools.find((t) => t.name === 'taiga_queue_initiative')!.description).toMatch(/^Ask first:/);
7055
+ expect(tools.find((t) => t.name === 'taiga_queue_initiative')!.description).toMatch(
7056
+ /^Ask first \(paid agent work\)/
7057
+ );
7058
+ expect(tools.find((t) => t.name === 'taiga_rename_factory')!.description).toMatch(/^Ask first \(others see it\)/);
7081
7059
  expect(tools.find((t) => t.name === 'taiga_create_factory')!.description).toMatch(/^Writes\. /);
7082
7060
  });
7083
7061
 
@@ -7132,7 +7110,7 @@ describe('what the third agent review found (PRO-2800)', () => {
7132
7110
  expect(me.role).toBeTypeOf('string');
7133
7111
  expect(me.capabilities).toEqual(expect.arrayContaining(['products:read', 'backlog:write']));
7134
7112
  expect(me.tools.reads).toContain('taiga_get_product_context');
7135
- expect(me.tools.askFirst).toContain('taiga_queue_initiative');
7113
+ expect(me.tools.askFirstPaid).toContain('taiga_queue_initiative');
7136
7114
  expect(me.tools.reads).not.toContain('taiga_whoami');
7137
7115
  });
7138
7116
 
@@ -7205,7 +7183,9 @@ describe('what the third agent review found (PRO-2800)', () => {
7205
7183
  await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
7206
7184
  ).data as Record<string, unknown>[];
7207
7185
 
7208
- expect(runs[0]!['outcome']).toMatch(/look at what is still running or queued in CI/);
7186
+ expect(runs[0]!['outcome']).toMatch(
7187
+ /look at what is still running or queued in CI on GitHub, then review the pull request/
7188
+ );
7209
7189
  expect(runs[1]!['outcome']).toMatch(/CI is still failing after 1 automatic round\./);
7210
7190
  expect(runs[2]!['outcome']).toBe('Failed: TypeError: boom (2 of 3 steps done).');
7211
7191
  // A row says the handback in its outcome; the full read keeps the record.
@@ -7297,6 +7277,38 @@ describe('what the third agent review found (PRO-2800)', () => {
7297
7277
  expect(got.lifecycle.latestRun.id).toBe(BUILD_ID);
7298
7278
  });
7299
7279
 
7280
+ it('points a control id asked of a product document at the policy', async () => {
7281
+ const { client } = await bootMcpPair({
7282
+ capabilities: ['products:read'],
7283
+ routes: {
7284
+ [`GET /v1/products/${PRODUCT_ID}`]: {
7285
+ data: { id: PRODUCT_ID, name: 'Web', slug: 'web', status: 'active', factoryId: FACTORY_ID, settings: {} },
7286
+ },
7287
+ [`GET /v1/products/${PRODUCT_ID}/documents/threat_model`]: {
7288
+ data: {
7289
+ id: 'd',
7290
+ type: 'threat_model',
7291
+ version: 1,
7292
+ isDraft: false,
7293
+ data: { sections: { risks: { content: 'SEC-5.1 applies.' } } },
7294
+ },
7295
+ },
7296
+ },
7297
+ });
7298
+
7299
+ const doc = parseToolResult(
7300
+ await client.callTool({
7301
+ name: 'taiga_get_product_document',
7302
+ arguments: { productId: PRODUCT_ID, type: 'threat_model', sections: ['SEC-5.1'] },
7303
+ })
7304
+ );
7305
+
7306
+ expect(doc.unknownSections).toEqual(['SEC-5.1']);
7307
+ expect(doc.note).toBe(
7308
+ 'SEC-5.1 is a control of policy_security: read it with taiga_get_document (type: policy_security, sections: ["SEC-5.1"]).'
7309
+ );
7310
+ });
7311
+
7300
7312
  it('reads the sections that state a policy control id', async () => {
7301
7313
  const { client } = await bootMcpPair({
7302
7314
  capabilities: ALL_CAPABILITIES,
@@ -7315,6 +7327,7 @@ describe('what the third agent review found (PRO-2800)', () => {
7315
7327
  logging: { content: '| SEC-5.10 | Log access |' },
7316
7328
  appendix: { content: 'See SEC-5.1A and SEC-5.1-appendix, and SEC-5.1.2.' },
7317
7329
  summary: { content: 'Secrets follow SEC-5.1.' },
7330
+ telecom: { content: '| TEL-TIC-1.1 | Lawful intercept |' },
7318
7331
  },
7319
7332
  },
7320
7333
  },
@@ -7325,14 +7338,15 @@ describe('what the third agent review found (PRO-2800)', () => {
7325
7338
  const doc = parseToolResult(
7326
7339
  await client.callTool({
7327
7340
  name: 'taiga_get_document',
7328
- arguments: { type: 'policy_security', sections: ['SEC-5.1', 'SEC-9.9'] },
7341
+ arguments: { type: 'policy_security', sections: ['SEC-5.1', 'SEC-9.9', 'TEL-TIC-1.1', 'TIC-1.1'] },
7329
7342
  })
7330
7343
  );
7331
7344
 
7332
- // A whole id only, and a sentence's full stop after it is still the id.
7333
- expect(Object.keys(doc.data.sections).sort()).toEqual(['secretsManagement', 'summary']);
7334
- expect(doc.controls).toEqual({ 'SEC-5.1': ['secretsManagement', 'summary'] });
7335
- expect(doc.unknownSections).toEqual(['SEC-9.9']);
7345
+ // A whole id only, and a sentence's full stop after it is still the id; an overlay family's id resolves,
7346
+ // and its tail (TIC-1.1) is not the same control.
7347
+ expect(Object.keys(doc.data.sections).sort()).toEqual(['secretsManagement', 'summary', 'telecom']);
7348
+ expect(doc.controls).toEqual({ 'SEC-5.1': ['secretsManagement', 'summary'], 'TEL-TIC-1.1': ['telecom'] });
7349
+ expect(doc.unknownSections).toEqual(['SEC-9.9', 'TIC-1.1']);
7336
7350
  });
7337
7351
 
7338
7352
  it('loads the three entry tools upfront in Claude Code, and only those', async () => {
@@ -7609,7 +7623,7 @@ describe('what the third agent review found (PRO-2800)', () => {
7609
7623
  [
7610
7624
  'gated',
7611
7625
  'Handed back to a person: CI is still failing on secret-scan, and the last fix round changed nothing. ' +
7612
- 'PR #29, still a draft, waits for them to fix it by hand or review it back to the agent.',
7626
+ 'PR #29, still a draft, waits for them to fix it by hand, or review the pull request on GitHub, which hands it back to the agent.',
7613
7627
  ],
7614
7628
  [
7615
7629
  'failed',
@@ -7751,3 +7765,276 @@ describe('what the third agent review found (PRO-2800)', () => {
7751
7765
  expect(instructions).toContain('taiga_get_product_context');
7752
7766
  });
7753
7767
  });
7768
+
7769
+ describe('what the fourth agent review found (PRO-2800)', () => {
7770
+ beforeEach(() => {
7771
+ vi.restoreAllMocks();
7772
+ });
7773
+
7774
+ it('says a run whose initiative was canceled or deleted is abandoned, not waiting on anyone', async () => {
7775
+ const GONE = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f01';
7776
+ const base = { productId: PRODUCT_ID, status: 'needs_review', prState: 'open', completedSteps: 3, totalSteps: 3 };
7777
+ const { client } = await bootMcpPair({
7778
+ capabilities: ['products:read', 'backlog:read'],
7779
+ routes: {
7780
+ [`/v1/products/${PRODUCT_ID}/builds`]: {
7781
+ data: [
7782
+ {
7783
+ ...base,
7784
+ id: 'r1',
7785
+ backlogItemId: ITEM_ID,
7786
+ prNumber: 10,
7787
+ prUrl: 'https://x/10',
7788
+ actions: ['retry_from_failed_step'],
7789
+ },
7790
+ { ...base, id: 'r2', backlogItemId: GONE, prNumber: 11, prUrl: 'https://x/11' },
7791
+ ],
7792
+ nextCursor: null,
7793
+ },
7794
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: { id: ITEM_ID, status: 'canceled', state: 'canceled' } },
7795
+ [`GET /v1/backlog/${GONE}`]: refusal(404, 'NOT_FOUND', 'Backlog item not found'),
7796
+ },
7797
+ });
7798
+
7799
+ const runs = parseToolResult(
7800
+ await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
7801
+ ).data as Record<string, unknown>[];
7802
+
7803
+ expect(runs.map((run) => [run['disposition'], run['outcome']])).toEqual([
7804
+ [
7805
+ 'abandoned',
7806
+ 'Its initiative was canceled, so nobody is waiting on this run. PR #10 is still open on the forge.',
7807
+ ],
7808
+ ['abandoned', 'Its initiative was deleted, so nobody is waiting on this run. PR #11 is still open on the forge.'],
7809
+ ]);
7810
+ expect(runs[0]!['actions']).toEqual([]);
7811
+ });
7812
+
7813
+ it('refuses a factory or product name already taken, unless the person wants a second', async () => {
7814
+ const { client, fetchSpy } = await bootMcpPair({
7815
+ capabilities: ALL_CAPABILITIES,
7816
+ routes: {
7817
+ 'GET /v1/factories': { data: [{ id: FACTORY_ID, name: 'Platform', slug: 'platform' }], nextCursor: null },
7818
+ 'POST /v1/factories': { data: { id: 'new', name: 'platform', slug: 'platform-2', isDefault: false } },
7819
+ },
7820
+ });
7821
+
7822
+ const refused = await client.callTool({ name: 'taiga_create_factory', arguments: { name: ' platform ' } });
7823
+ const made = await client.callTool({
7824
+ name: 'taiga_create_factory',
7825
+ arguments: { name: 'platform', allowDuplicateName: true },
7826
+ });
7827
+
7828
+ expect(parseToolResult(refused)).toMatchObject({ error: 'DUPLICATE_NAME', details: { existingId: FACTORY_ID } });
7829
+ expect(made.isError).toBeFalsy();
7830
+ expect(
7831
+ fetchSpy.mock.calls.filter((call: unknown[]) => (call[1] as RequestInit | undefined)?.method === 'POST')
7832
+ ).toHaveLength(1);
7833
+ });
7834
+
7835
+ it('refuses a product name already taken, unless the person wants a second', async () => {
7836
+ const { client, fetchSpy } = await bootMcpPair({
7837
+ capabilities: ALL_CAPABILITIES,
7838
+ routes: {
7839
+ 'GET /v1/products': {
7840
+ data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_ID }],
7841
+ nextCursor: null,
7842
+ },
7843
+ 'POST /v1/products': {
7844
+ data: { id: PRODUCT_ID_2, name: 'web', slug: 'web-2', status: 'active', factoryId: FACTORY_ID, settings: {} },
7845
+ },
7846
+ },
7847
+ });
7848
+
7849
+ const refused = await client.callTool({
7850
+ name: 'taiga_create_product',
7851
+ arguments: { factoryId: FACTORY_ID, name: 'WEB', start: 'scratch' },
7852
+ });
7853
+ const made = await client.callTool({
7854
+ name: 'taiga_create_product',
7855
+ arguments: { factoryId: FACTORY_ID, name: 'web', start: 'scratch', allowDuplicateName: true },
7856
+ });
7857
+
7858
+ expect(parseToolResult(refused)).toMatchObject({ error: 'DUPLICATE_NAME', details: { existingId: PRODUCT_ID } });
7859
+ expect(made.isError).toBeFalsy();
7860
+ const posts = fetchSpy.mock.calls.filter(
7861
+ (call: unknown[]) =>
7862
+ (call[1] as RequestInit | undefined)?.method === 'POST' && String(call[0]).endsWith('/v1/products')
7863
+ );
7864
+ expect(posts).toHaveLength(1);
7865
+ });
7866
+
7867
+ it('bounds the full policy standing and reads one violation whole', async () => {
7868
+ const long = 'x'.repeat(5_000);
7869
+ const V1 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f02';
7870
+ const { client } = await bootMcpPair({
7871
+ capabilities: ['products:read'],
7872
+ routes: {
7873
+ [`GET /v1/products/${PRODUCT_ID}/maintenance/policies`]: {
7874
+ data: {
7875
+ productId: PRODUCT_ID,
7876
+ controls: [{ controlId: 'SEC-1', divergenceCount: 1 }],
7877
+ divergences: [{ id: V1, controlId: 'SEC-1', severity: 'high', evidence: long }],
7878
+ resolvedDivergences: Array.from({ length: 25 }, (_, n) => ({ id: `r${n}` })),
7879
+ },
7880
+ },
7881
+ },
7882
+ });
7883
+
7884
+ const full = parseToolResult(
7885
+ await client.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID, full: true } })
7886
+ );
7887
+ const one = parseToolResult(
7888
+ await client.callTool({
7889
+ name: 'taiga_get_policy_standing',
7890
+ arguments: { productId: PRODUCT_ID, violationId: V1 },
7891
+ })
7892
+ );
7893
+
7894
+ expect(full.divergences[0].evidence.length).toBeLessThan(1_700);
7895
+ expect(full.divergences[0].evidence).toMatch(/cut at 1500 of 5000 characters/);
7896
+ expect(full.resolvedDivergences).toHaveLength(20);
7897
+ expect(full.resolvedNote).toBe('The first 20 of 25 resolved violations.');
7898
+ expect(one.evidence).toHaveLength(5_000);
7899
+ });
7900
+
7901
+ it('lists the initiatives in one board column, across pages', async () => {
7902
+ const row = (id: string, state: string) => ({ id, title: id, state, status: 'in_progress' });
7903
+ const { client } = await bootMcpPair({
7904
+ capabilities: ['backlog:read'],
7905
+ routes: {
7906
+ [`/v1/products/${PRODUCT_ID}/backlog`]: (url: string) => {
7907
+ const second = new URL(url).searchParams.get('cursor') === 'p2';
7908
+ const body = second
7909
+ ? { data: [row('c', 'queue')], nextCursor: null }
7910
+ : { data: [row('a', 'queue'), row('b', 'build')], nextCursor: 'p2' };
7911
+ return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
7912
+ },
7913
+ },
7914
+ });
7915
+
7916
+ const queued = parseToolResult(
7917
+ await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: PRODUCT_ID, state: 'queue' } })
7918
+ );
7919
+
7920
+ expect(queued.data.map((item: { id: string }) => item.id)).toEqual(['a', 'c']);
7921
+ });
7922
+
7923
+ it('lists only registered ask-first tools as paid agent work', async () => {
7924
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
7925
+ const { tools } = await client.listTools();
7926
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
7927
+
7928
+ for (const name of PAID_WORK) {
7929
+ expect(byName.get(name)?.annotations?.destructiveHint, name).toBe(true);
7930
+ expect(byName.get(name)?.description, name).toMatch(/^Ask first \(paid agent work\)/);
7931
+ }
7932
+ });
7933
+
7934
+ it('says when the sweep overview could not be read, so an empty findings list is no all-clear', async () => {
7935
+ const { client } = await bootMcpPair({
7936
+ capabilities: ['products:read'],
7937
+ routes: { [`/v1/products/${PRODUCT_ID}/maintenance/findings`]: { data: [], nextCursor: null } },
7938
+ });
7939
+
7940
+ const findings = parseToolResult(
7941
+ await client.callTool({ name: 'taiga_list_findings', arguments: { productId: PRODUCT_ID } })
7942
+ );
7943
+
7944
+ expect(findings.sweep).toBeNull();
7945
+ expect(findings.sweepNote).toMatch(/not an all-clear/);
7946
+ });
7947
+
7948
+ it("says a waiting run's initiative was not checked when the key cannot read it", async () => {
7949
+ const { client } = await bootMcpPair({
7950
+ capabilities: ['products:read'],
7951
+ routes: {
7952
+ [`/v1/products/${PRODUCT_ID}/builds`]: {
7953
+ data: [
7954
+ {
7955
+ id: 'r1',
7956
+ productId: PRODUCT_ID,
7957
+ backlogItemId: ITEM_ID,
7958
+ status: 'needs_review',
7959
+ prNumber: 4,
7960
+ prUrl: 'https://x/4',
7961
+ },
7962
+ ],
7963
+ nextCursor: null,
7964
+ },
7965
+ },
7966
+ });
7967
+
7968
+ const runs = parseToolResult(
7969
+ await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
7970
+ ).data as Record<string, unknown>[];
7971
+
7972
+ expect(runs[0]!['disposition']).toBe('gated');
7973
+ expect(runs[0]!['outcome']).toMatch(/Whether its initiative still wants it was not checked\.$/);
7974
+ });
7975
+
7976
+ it('reads a control id only in a policy, not in other company documents', async () => {
7977
+ const { client } = await bootMcpPair({
7978
+ capabilities: ALL_CAPABILITIES,
7979
+ routes: {
7980
+ 'GET /v1/tenants/current/documents/design_standards': {
7981
+ data: {
7982
+ id: 'd',
7983
+ type: 'design_standards',
7984
+ version: 1,
7985
+ isDraft: false,
7986
+ data: { sections: { forms: { content: 'Store secrets per SEC-5.1.' } } },
7987
+ },
7988
+ },
7989
+ },
7990
+ });
7991
+
7992
+ const doc = parseToolResult(
7993
+ await client.callTool({
7994
+ name: 'taiga_get_document',
7995
+ arguments: { type: 'design_standards', sections: ['SEC-5.1'] },
7996
+ })
7997
+ );
7998
+ const overlay = parseToolResult(
7999
+ await client.callTool({
8000
+ name: 'taiga_get_document',
8001
+ arguments: { type: 'design_standards', sections: ['HC-FI-DATA-1.1'] },
8002
+ })
8003
+ );
8004
+
8005
+ expect(doc).not.toHaveProperty('controls');
8006
+ expect(doc.unknownSections).toEqual(['SEC-5.1']);
8007
+ expect(doc.note).toMatch(/^SEC-5\.1 is a control of policy_security: /);
8008
+ // An overlay family's control belongs to the policy its rightmost prefix names.
8009
+ expect(overlay.note).toMatch(/^HC-FI-DATA-1\.1 is a control of policy_data: /);
8010
+ });
8011
+
8012
+ it('leaves a run still executing as it is, whatever its initiative', async () => {
8013
+ const { client } = await bootMcpPair({
8014
+ capabilities: ['products:read', 'backlog:read'],
8015
+ routes: {
8016
+ [`/v1/products/${PRODUCT_ID}/builds`]: {
8017
+ data: [{ id: 'r1', productId: PRODUCT_ID, backlogItemId: ITEM_ID, status: 'implementing', prUrl: null }],
8018
+ nextCursor: null,
8019
+ },
8020
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: { id: ITEM_ID, status: 'canceled', state: 'canceled' } },
8021
+ },
8022
+ });
8023
+
8024
+ const runs = parseToolResult(
8025
+ await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
8026
+ ).data as Record<string, unknown>[];
8027
+
8028
+ expect(runs[0]!['disposition']).toBe('in_flight');
8029
+ });
8030
+
8031
+ it('splits the ask-first notice into paid agent work and what others see, in whoami too', async () => {
8032
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
8033
+
8034
+ const me = parseToolResult(await client.callTool({ name: 'taiga_whoami', arguments: {} }));
8035
+
8036
+ expect(me.tools.askFirstPaid).toContain('taiga_start_import');
8037
+ expect(me.tools.askFirstShared).toContain('taiga_rename_factory');
8038
+ expect(me.capabilities.some((capability: string) => /^(projects|teams):/.test(capability))).toBe(false);
8039
+ });
8040
+ });