@taiga-ai/mcp-server 0.17.0-dev.441 → 0.18.0-dev.443

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.17.0-dev.441",
3
+ "version": "0.18.0-dev.443",
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
@@ -434,6 +434,11 @@ export class TaigaClient {
434
434
  return this.request<{ data: unknown }>('GET', `/backlog/${seg(itemId)}`, undefined, { signal });
435
435
  }
436
436
 
437
+ /** The initiative's history, oldest first: every turn event and audited move, each with who did it. */
438
+ async listBacklogItemActivity(itemId: string) {
439
+ return this.request<{ data: unknown[] }>('GET', `/backlog/${seg(itemId)}/activity`);
440
+ }
441
+
437
442
  /** A person's move on the line board: the column dropped into, and what to follow there. */
438
443
  async transitionInitiative(
439
444
  itemId: string,
@@ -615,12 +620,16 @@ export class TaigaClient {
615
620
  productId?: string | undefined;
616
621
  category?: string | undefined;
617
622
  resolved?: boolean | undefined;
623
+ withTotal?: boolean | undefined;
618
624
  limit?: number | undefined;
619
625
  cursor?: string | undefined;
620
626
  }) {
621
627
  const { productId, ...rest } = opts;
622
628
  const path = productId ? `/products/${seg(productId)}/inbox` : '/inbox';
623
- return this.request<{ data: unknown[]; nextCursor: string | null }>('GET', `${path}${query({ ...rest })}`);
629
+ return this.request<{ data: unknown[]; nextCursor: string | null; totalCount?: number }>(
630
+ 'GET',
631
+ `${path}${query({ ...rest })}`
632
+ );
624
633
  }
625
634
 
626
635
  async updateInboxItem(
@@ -633,12 +642,16 @@ export class TaigaClient {
633
642
  async listSetupTasks(opts: {
634
643
  productId?: string | undefined;
635
644
  openOnly?: boolean | undefined;
645
+ withTotal?: boolean | undefined;
636
646
  limit?: number | undefined;
637
647
  cursor?: string | undefined;
638
648
  }) {
639
649
  const { productId, ...rest } = opts;
640
650
  const path = productId ? `/products/${seg(productId)}/setup-tasks` : '/setup-tasks';
641
- return this.request<{ data: unknown[]; nextCursor: string | null }>('GET', `${path}${query({ ...rest })}`);
651
+ return this.request<{ data: unknown[]; nextCursor: string | null; totalCount?: number }>(
652
+ 'GET',
653
+ `${path}${query({ ...rest })}`
654
+ );
642
655
  }
643
656
 
644
657
  // ---------------------------------------------------------------------------
@@ -65,6 +65,7 @@ const ALL_TOOL_NAMES = [
65
65
  'taiga_list_backlog_items',
66
66
  'taiga_list_builds',
67
67
  'taiga_list_documents',
68
+ 'taiga_list_environments',
68
69
  'taiga_list_factories',
69
70
  'taiga_list_factory_documents',
70
71
  'taiga_list_findings',
@@ -446,6 +447,7 @@ describe('MCP server integration', () => {
446
447
  'taiga_list_action_required',
447
448
  'taiga_list_setup_tasks',
448
449
  'taiga_get_deployments',
450
+ 'taiga_list_environments',
449
451
  'taiga_get_delivery_metrics',
450
452
  'taiga_get_monitoring',
451
453
  'taiga_list_product_repositories',
@@ -1973,8 +1975,11 @@ describe('MCP server integration', () => {
1973
1975
  instructionsVersion: 2,
1974
1976
  });
1975
1977
  expect(bundle.documents.origin).toBe('plan');
1976
- expect(bundle.documents.specification.data.sections.vision).toEqual({ content: 'A web app' });
1977
- expect(bundle.documents.specification).not.toHaveProperty('derivedFrom');
1978
+ // The outline by default (PRO-2778): each section id with its size, no text.
1979
+ expect(bundle.documents.specification).toEqual({
1980
+ version: spec.version,
1981
+ sections: [{ id: 'vision', characters: 'A web app'.length }],
1982
+ });
1978
1983
  expect(bundle.documents.architecture).toBeNull();
1979
1984
  expect(bundle.documents.notes).toEqual([
1980
1985
  'No published plan architecture; taiga_list_product_documents shows what the product has.',
@@ -2141,17 +2146,20 @@ describe('MCP server integration', () => {
2141
2146
  expect(bundle.backlog.truncated).toBe(true);
2142
2147
  });
2143
2148
 
2144
- it('should carry the two documents as section ids only when asked for a small bundle', async () => {
2149
+ it('should carry the two documents as their outline when asked for it', async () => {
2145
2150
  const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
2146
2151
 
2147
2152
  const bundle = parseToolResult(
2148
2153
  await client.callTool({
2149
2154
  name: 'taiga_get_product_context',
2150
- arguments: { productId: PRODUCT_ID, fullDocuments: false },
2155
+ arguments: { productId: PRODUCT_ID, documents: 'outline' },
2151
2156
  })
2152
2157
  );
2153
2158
 
2154
- expect(bundle.documents.specification).toEqual({ version: 3, sections: ['vision'] });
2159
+ expect(bundle.documents.specification).toEqual({
2160
+ version: 3,
2161
+ sections: [{ id: 'vision', characters: 'A web app'.length }],
2162
+ });
2155
2163
  expect(bundle.documents.architecture).toBeNull();
2156
2164
  });
2157
2165
 
@@ -2169,7 +2177,7 @@ describe('MCP server integration', () => {
2169
2177
  const bundle = parseToolResult(
2170
2178
  await client.callTool({
2171
2179
  name: 'taiga_get_product_context',
2172
- arguments: { productId: PRODUCT_ID, fullDocuments: false },
2180
+ arguments: { productId: PRODUCT_ID, documents: 'outline' },
2173
2181
  })
2174
2182
  );
2175
2183
 
@@ -4717,7 +4725,10 @@ describe('MCP server integration', () => {
4717
4725
  it('should carry the whole sections that fit the budget, skipping one that does not, name the rest and say how large it is', async () => {
4718
4726
  const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(bigSpec) });
4719
4727
 
4720
- const raw = await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } });
4728
+ const raw = await client.callTool({
4729
+ name: 'taiga_get_product_context',
4730
+ arguments: { productId: PRODUCT_ID, documents: 'budget' },
4731
+ });
4721
4732
  const bundle = parseToolResult(raw);
4722
4733
 
4723
4734
  // The size it reports is the size of the text it hands over, the field included.
@@ -4732,7 +4743,7 @@ describe('MCP server integration', () => {
4732
4743
  expect(bundle.characters).toBeLessThan(20_000);
4733
4744
  });
4734
4745
 
4735
- it('should never cut a section: one past the budget alone is named, and fullDocuments carries everything', async () => {
4746
+ it('should never cut a section: one past the budget alone is named, and documents: full carries everything', async () => {
4736
4747
  // The order a jsonb column hands back: shorter ids first, whatever order they were written in.
4737
4748
  const huge = {
4738
4749
  ...bigSpec,
@@ -4741,12 +4752,15 @@ describe('MCP server integration', () => {
4741
4752
  const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(huge) });
4742
4753
 
4743
4754
  const cut = parseToolResult(
4744
- await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
4755
+ await client.callTool({
4756
+ name: 'taiga_get_product_context',
4757
+ arguments: { productId: PRODUCT_ID, documents: 'budget' },
4758
+ })
4745
4759
  ).documents.specification;
4746
4760
  const full = parseToolResult(
4747
4761
  await client.callTool({
4748
4762
  name: 'taiga_get_product_context',
4749
- arguments: { productId: PRODUCT_ID, fullDocuments: true },
4763
+ arguments: { productId: PRODUCT_ID, documents: 'full' },
4750
4764
  })
4751
4765
  ).documents.specification;
4752
4766
 
@@ -4756,7 +4770,7 @@ describe('MCP server integration', () => {
4756
4770
  expect(full).not.toHaveProperty('omittedSections');
4757
4771
  });
4758
4772
 
4759
- it("should leave each section's citations out of the bundle and say so, while fullDocuments keeps them", async () => {
4773
+ it("should leave each section's citations out of the budget bundle and say so, while documents: full keeps them", async () => {
4760
4774
  const cited = {
4761
4775
  ...bigSpec,
4762
4776
  data: { sections: { overview: { ...section('text'), citations: [{ path: 'src/a.rs', lines: '1-9' }] } } },
@@ -4764,12 +4778,15 @@ describe('MCP server integration', () => {
4764
4778
  const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(cited) });
4765
4779
 
4766
4780
  const lean = parseToolResult(
4767
- await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
4781
+ await client.callTool({
4782
+ name: 'taiga_get_product_context',
4783
+ arguments: { productId: PRODUCT_ID, documents: 'budget' },
4784
+ })
4768
4785
  ).documents;
4769
4786
  const full = parseToolResult(
4770
4787
  await client.callTool({
4771
4788
  name: 'taiga_get_product_context',
4772
- arguments: { productId: PRODUCT_ID, fullDocuments: true },
4789
+ arguments: { productId: PRODUCT_ID, documents: 'full' },
4773
4790
  })
4774
4791
  ).documents;
4775
4792
 
@@ -4795,10 +4812,37 @@ describe('MCP server integration', () => {
4795
4812
  // The document's own order, whatever order they were asked in.
4796
4813
  expect(Object.keys(doc.data.sections)).toEqual(['flows', 'risks']);
4797
4814
  expect(doc.omittedSections.map((s: { id: string }) => s.id)).toEqual(['overview', 'features']);
4798
- expect(miss.isError).toBe(true);
4799
- expect(parseToolResult(miss).message).toBe(
4800
- 'The plan specification has no section nope; its sections are overview, features, flows, risks.'
4815
+ // One shape whatever matched (PRO-2778): none right still answers, listing every real id with its size.
4816
+ expect(miss.isError).toBeFalsy();
4817
+ const missed = parseToolResult(miss);
4818
+ expect(missed.data.sections).toEqual({});
4819
+ expect(missed.unknownSections).toEqual(['nope']);
4820
+ expect(missed.omittedSections.map((s: { id: string }) => s.id)).toEqual([
4821
+ 'overview',
4822
+ 'features',
4823
+ 'flows',
4824
+ 'risks',
4825
+ ]);
4826
+ });
4827
+
4828
+ it('should read a product document as its outline with sections: []', async () => {
4829
+ const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(bigSpec) });
4830
+
4831
+ const outline = parseToolResult(
4832
+ await client.callTool({
4833
+ name: 'taiga_get_product_document',
4834
+ arguments: { productId: PRODUCT_ID, type: 'specification', origin: 'plan', sections: [] },
4835
+ })
4801
4836
  );
4837
+
4838
+ expect(outline.data.sections).toEqual({});
4839
+ expect(outline.omittedSections).toEqual([
4840
+ { id: 'overview', characters: 5_000 },
4841
+ { id: 'features', characters: expect.any(Number) },
4842
+ { id: 'flows', characters: 5_000 },
4843
+ { id: 'risks', characters: expect.any(Number) },
4844
+ ]);
4845
+ expect(outline).not.toHaveProperty('unknownSections');
4802
4846
  });
4803
4847
  });
4804
4848
 
@@ -5886,21 +5930,25 @@ describe('MCP server integration', () => {
5886
5930
  })
5887
5931
  );
5888
5932
 
5889
- expect(linked).toEqual([
5890
- {
5891
- id: LINK_ID,
5892
- provider: 'github',
5893
- repositoryName: 'acme/billing',
5894
- repositoryUrl: 'https://github.com/acme/billing',
5895
- repositoryDefaultBranch: 'main',
5896
- baseBranch: null,
5897
- effectiveBranch: 'main',
5898
- label: null,
5899
- buildable: true,
5900
- isManaged: false,
5901
- },
5902
- ]);
5903
- expect(branches.branches).toHaveLength(2);
5933
+ // data, as every list carries its rows (PRO-2778), not a bare array.
5934
+ expect(linked).toEqual({
5935
+ data: [
5936
+ {
5937
+ id: LINK_ID,
5938
+ provider: 'github',
5939
+ repositoryName: 'acme/billing',
5940
+ repositoryUrl: 'https://github.com/acme/billing',
5941
+ repositoryDefaultBranch: 'main',
5942
+ baseBranch: null,
5943
+ effectiveBranch: 'main',
5944
+ label: null,
5945
+ buildable: true,
5946
+ isManaged: false,
5947
+ },
5948
+ ],
5949
+ truncated: false,
5950
+ });
5951
+ expect(branches.data).toHaveLength(2);
5904
5952
  expect(found.accounts).toEqual([{ id: INSTALL_ID, accountLogin: 'acme', accountType: 'Organization' }]);
5905
5953
  expect(found.repositories).toEqual({
5906
5954
  data: [
@@ -5969,12 +6017,71 @@ describe('MCP server integration', () => {
5969
6017
  );
5970
6018
  // The limit holds across accounts, and the answer says it was cut.
5971
6019
  expect(one.repositories.data).toHaveLength(1);
5972
- expect(one.more).toMatch(/installationId/);
6020
+ expect(one.more).toMatch(/name an account/);
5973
6021
  expect(both.repositories.data.map((r: { fullName: string }) => r.fullName)).toEqual([
5974
6022
  'acme/billing',
5975
6023
  'acme-labs/billing-poc',
5976
6024
  ]);
5977
- expect(both.more).toMatch(/installationId/);
6025
+ expect(both.more).toMatch(/name an account/);
6026
+ });
6027
+
6028
+ // PRO-2778: the first account's page filled the answer and hid the others.
6029
+ it('should take rows from each account in turn, and read one account by its login', async () => {
6030
+ const three = [1, 2, 3].map((n) => ghRepo(`0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4d0${n}`, `acme/repo-${n}`));
6031
+ const { client, fetchSpy } = await bootMcpPair({
6032
+ capabilities: ['vcs:read'],
6033
+ routes: {
6034
+ 'GET /v1/github/installations': {
6035
+ data: [
6036
+ { id: INSTALL_ID, accountLogin: 'acme', accountType: 'Organization' },
6037
+ { id: INSTALL_ID_2, accountLogin: 'taiga-sandbox', accountType: 'Organization' },
6038
+ ],
6039
+ nextCursor: null,
6040
+ },
6041
+ [`GET /v1/github/installations/${INSTALL_ID}/repositories`]: { data: three, nextCursor: null },
6042
+ [`GET /v1/github/installations/${INSTALL_ID_2}/repositories`]: {
6043
+ data: [ghRepo(LINK_ID, 'taiga-sandbox/greenfield')],
6044
+ nextCursor: null,
6045
+ },
6046
+ },
6047
+ });
6048
+
6049
+ const spread = parseToolResult(
6050
+ await client.callTool({ name: 'taiga_list_linkable_repositories', arguments: { limit: 2 } })
6051
+ );
6052
+ expect(spread.repositories.data.map((r: { fullName: string }) => r.fullName)).toEqual([
6053
+ 'acme/repo-1',
6054
+ 'taiga-sandbox/greenfield',
6055
+ ]);
6056
+
6057
+ const before = calls(fetchSpy, 'GET').length;
6058
+ const sandbox = parseToolResult(
6059
+ await client.callTool({ name: 'taiga_list_linkable_repositories', arguments: { account: 'Taiga-Sandbox' } })
6060
+ );
6061
+ expect(sandbox.repositories.data.map((r: { fullName: string }) => r.fullName)).toEqual([
6062
+ 'taiga-sandbox/greenfield',
6063
+ ]);
6064
+ // Only the installations and that one account were read.
6065
+ const urls = calls(fetchSpy, 'GET')
6066
+ .slice(before)
6067
+ .map((u) => u.pathname);
6068
+ expect(urls.some((u) => u.includes(`/installations/${INSTALL_ID}/`))).toBe(false);
6069
+
6070
+ // owner/repo names its owner, a GitHub-length repository name included.
6071
+ const byRepo = parseToolResult(
6072
+ await client.callTool({
6073
+ name: 'taiga_list_linkable_repositories',
6074
+ arguments: { account: `taiga-sandbox/${'r'.repeat(100)}` },
6075
+ })
6076
+ );
6077
+ expect(byRepo.repositories.data).toHaveLength(1);
6078
+
6079
+ const missing = await client.callTool({
6080
+ name: 'taiga_list_linkable_repositories',
6081
+ arguments: { account: 'nope' },
6082
+ });
6083
+ expect(missing.isError).toBe(true);
6084
+ expect(parseToolResult(missing).details.accounts).toHaveLength(2);
5978
6085
  });
5979
6086
  });
5980
6087
 
@@ -6342,10 +6449,12 @@ describe('what the agent review found (PRO-2759)', () => {
6342
6449
  name: 'taiga_get_document',
6343
6450
  arguments: { type: 'policy_security', sections: ['secrets'] },
6344
6451
  });
6345
- expect(parseToolResult(none)).toEqual({
6346
- error: 'NOT_FOUND',
6347
- message: 'The policy_security has no section secrets; its sections are summary, scope.',
6348
- });
6452
+ // One shape whatever matched (PRO-2778): every real id with its size, the miss named.
6453
+ expect(none.isError).toBeFalsy();
6454
+ const missed = parseToolResult(none);
6455
+ expect(missed.data.sections).toEqual({});
6456
+ expect(missed.unknownSections).toEqual(['secrets']);
6457
+ expect(missed.omittedSections.map((s: { id: string }) => s.id)).toEqual(['summary', 'scope']);
6349
6458
  });
6350
6459
 
6351
6460
  it('says what a product in setup waits for, and warns of a half turned off with nothing said about it', async () => {
@@ -6533,3 +6642,261 @@ describe('what the agent review found (PRO-2759)', () => {
6533
6642
  });
6534
6643
  });
6535
6644
  });
6645
+
6646
+ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
6647
+ const ENV_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a80';
6648
+
6649
+ beforeEach(() => {
6650
+ vi.restoreAllMocks();
6651
+ });
6652
+
6653
+ it('says how many need a person on the first page, and asks for the count only there', async () => {
6654
+ const { client, fetchSpy } = await bootMcpPair({
6655
+ capabilities: ALL_CAPABILITIES,
6656
+ routes: {
6657
+ 'GET /v1/inbox': { data: [], nextCursor: 'c2', totalCount: 42 },
6658
+ 'GET /v1/setup-tasks': { data: [], nextCursor: null, totalCount: 3 },
6659
+ },
6660
+ });
6661
+
6662
+ const inbox = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
6663
+ const tasks = parseToolResult(await client.callTool({ name: 'taiga_list_setup_tasks', arguments: {} }));
6664
+ expect(inbox.totalCount).toBe(42);
6665
+ expect(tasks.totalCount).toBe(3);
6666
+ expect(sentUrl(fetchSpy, '/v1/inbox').searchParams.get('withTotal')).toBe('true');
6667
+ expect(sentUrl(fetchSpy, '/v1/setup-tasks').searchParams.get('withTotal')).toBe('true');
6668
+
6669
+ fetchSpy.mockClear();
6670
+ await client.callTool({ name: 'taiga_list_action_required', arguments: { cursor: 'c2' } });
6671
+ expect(sentUrl(fetchSpy, '/v1/inbox').searchParams.has('withTotal')).toBe(false);
6672
+ });
6673
+
6674
+ it('carries who cancelled a failed plan and when, and leaves the item as it was when the history is refused', async () => {
6675
+ const failed = { id: ITEM_ID, title: 'Login', planningStatus: 'failed', planFailureCause: 'cancelled' };
6676
+ const history = {
6677
+ data: [
6678
+ {
6679
+ kind: 'turn',
6680
+ trigger: 'plan_failed',
6681
+ actor: { type: 'agent', id: null, name: null },
6682
+ occurredAt: '2026-10-01T00:00:00.000Z',
6683
+ data: { cause: 'agent_error' },
6684
+ },
6685
+ {
6686
+ kind: 'audit',
6687
+ action: 'backlog.moved',
6688
+ actor: { type: 'user', id: 'u1', name: 'Ada' },
6689
+ occurredAt: '2026-10-02T00:00:00.000Z',
6690
+ data: null,
6691
+ },
6692
+ {
6693
+ kind: 'turn',
6694
+ trigger: 'plan_failed',
6695
+ actor: { type: 'user', id: 'u1', name: 'Ada' },
6696
+ occurredAt: '2026-10-03T00:00:00.000Z',
6697
+ data: { cause: 'cancelled' },
6698
+ },
6699
+ ],
6700
+ };
6701
+ const { client } = await bootMcpPair({
6702
+ capabilities: ALL_CAPABILITIES,
6703
+ routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: failed }, [`GET /v1/backlog/${ITEM_ID}/activity`]: history },
6704
+ });
6705
+
6706
+ const item = parseToolResult(
6707
+ await client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } })
6708
+ );
6709
+ expect(item.planFailure).toEqual({
6710
+ cause: 'cancelled',
6711
+ meaning: 'A person stopped the planning (by says who). Ask before planning it again.',
6712
+ at: '2026-10-03T00:00:00.000Z',
6713
+ by: 'Ada',
6714
+ });
6715
+
6716
+ const refused = await bootMcpPair({
6717
+ capabilities: ALL_CAPABILITIES,
6718
+ routes: {
6719
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: failed },
6720
+ [`GET /v1/backlog/${ITEM_ID}/activity`]: refusal(403, 'FORBIDDEN', 'No'),
6721
+ },
6722
+ });
6723
+ const bare = parseToolResult(
6724
+ await refused.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } })
6725
+ );
6726
+ expect(bare).toEqual(failed);
6727
+
6728
+ // A history whose last failure disagrees with the item is an earlier one: the cause stays, who and when do not.
6729
+ const stale = await bootMcpPair({
6730
+ capabilities: ALL_CAPABILITIES,
6731
+ routes: {
6732
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: { ...failed, planFailureCause: 'worker_lost' } },
6733
+ [`GET /v1/backlog/${ITEM_ID}/activity`]: history,
6734
+ },
6735
+ });
6736
+ const staleItem = parseToolResult(
6737
+ await stale.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } })
6738
+ );
6739
+ expect(staleItem.planFailure).toEqual({
6740
+ cause: 'worker_lost',
6741
+ meaning: 'Nobody was running the planning any more (the worker stopped). Planning again is the usual next step.',
6742
+ at: null,
6743
+ by: null,
6744
+ });
6745
+
6746
+ // A cancel before the latest plan_started is an earlier attempt's, same cause or not.
6747
+ const retried = await bootMcpPair({
6748
+ capabilities: ALL_CAPABILITIES,
6749
+ routes: {
6750
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: failed },
6751
+ [`GET /v1/backlog/${ITEM_ID}/activity`]: {
6752
+ data: [
6753
+ ...history.data,
6754
+ {
6755
+ kind: 'turn',
6756
+ trigger: 'plan_started',
6757
+ actor: { type: 'user', id: 'u2', name: 'Bo' },
6758
+ occurredAt: '2026-10-04T00:00:00.000Z',
6759
+ data: null,
6760
+ },
6761
+ ],
6762
+ },
6763
+ },
6764
+ });
6765
+ expect(
6766
+ parseToolResult(await retried.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } }))
6767
+ ).toEqual(failed);
6768
+
6769
+ // No failure in the history, or no history reachable: the item as it is.
6770
+ const silent = await bootMcpPair({
6771
+ capabilities: ALL_CAPABILITIES,
6772
+ routes: {
6773
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: failed },
6774
+ [`GET /v1/backlog/${ITEM_ID}/activity`]: { data: [history.data[1]] },
6775
+ },
6776
+ });
6777
+ expect(
6778
+ parseToolResult(await silent.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } }))
6779
+ ).toEqual(failed);
6780
+ const offline = await bootMcpPair({
6781
+ capabilities: ALL_CAPABILITIES,
6782
+ routes: {
6783
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: failed },
6784
+ [`GET /v1/backlog/${ITEM_ID}/activity`]: () =>
6785
+ Promise.reject(new TypeError('fetch failed', { cause: 'ECONNRESET' })),
6786
+ },
6787
+ });
6788
+ expect(
6789
+ parseToolResult(await offline.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } }))
6790
+ ).toEqual(failed);
6791
+
6792
+ // A plan that did not fail reads no history.
6793
+ const planned = await bootMcpPair({
6794
+ capabilities: ALL_CAPABILITIES,
6795
+ routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: { ...failed, planningStatus: 'planned' } } },
6796
+ });
6797
+ await planned.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } });
6798
+ expect(planned.fetchSpy.mock.calls.some((c: unknown[]) => String(c[0]).includes('/activity'))).toBe(false);
6799
+ });
6800
+
6801
+ it("lists a product's environments, and refuses a window the pages do not offer before asking the API", async () => {
6802
+ const { client, fetchSpy } = await bootMcpPair({
6803
+ capabilities: ALL_CAPABILITIES,
6804
+ routes: {
6805
+ [`GET /v1/products/${PRODUCT_ID}/environments`]: {
6806
+ data: [
6807
+ {
6808
+ id: ENV_ID,
6809
+ name: 'Production',
6810
+ slug: 'prod',
6811
+ type: 'production',
6812
+ ordinal: 2,
6813
+ status: 'active',
6814
+ isProtected: true,
6815
+ url: 'https://x.test',
6816
+ secret: 'no',
6817
+ },
6818
+ ],
6819
+ nextCursor: null,
6820
+ },
6821
+ },
6822
+ });
6823
+
6824
+ const envs = parseToolResult(
6825
+ await client.callTool({ name: 'taiga_list_environments', arguments: { productId: PRODUCT_ID } })
6826
+ );
6827
+ expect(envs.data).toEqual([
6828
+ {
6829
+ id: ENV_ID,
6830
+ name: 'Production',
6831
+ slug: 'prod',
6832
+ type: 'production',
6833
+ ordinal: 2,
6834
+ status: 'active',
6835
+ isProtected: true,
6836
+ url: 'https://x.test',
6837
+ },
6838
+ ]);
6839
+
6840
+ const odd = await client.callTool({
6841
+ name: 'taiga_get_delivery_metrics',
6842
+ arguments: { productId: PRODUCT_ID, environmentId: ENV_ID, windowDays: 45 },
6843
+ });
6844
+ expect(odd.isError).toBe(true);
6845
+ expect(fetchSpy.mock.calls.some((c: unknown[]) => String(c[0]).includes('/insights'))).toBe(false);
6846
+ const { tools } = await client.listTools();
6847
+ const metrics = tools.find((t) => t.name === 'taiga_get_delivery_metrics')!;
6848
+ const properties = metrics.inputSchema.properties as Record<string, { enum?: unknown }>;
6849
+ expect(properties.windowDays!.enum).toEqual([30, 60, 90]);
6850
+ });
6851
+
6852
+ it('opens every writing tool with what it does, and leaves the reads as they were', async () => {
6853
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
6854
+ const { tools } = await client.listTools();
6855
+
6856
+ for (const tool of tools) {
6857
+ const notes = tool.annotations ?? {};
6858
+ const description = tool.description ?? '';
6859
+ if (notes.readOnlyHint) expect(description, tool.name).not.toMatch(/^(Writes\.|Acts on paid)/);
6860
+ else if (notes.destructiveHint)
6861
+ expect(description, tool.name).toMatch(/^Acts on paid or shared work: ask the person first\. /);
6862
+ else expect(description, tool.name).toMatch(/^Writes\. /);
6863
+ }
6864
+ expect(tools.find((t) => t.name === 'taiga_queue_backlog_item')!.description).toMatch(/^Acts on paid/);
6865
+ expect(tools.find((t) => t.name === 'taiga_create_factory')!.description).toMatch(/^Writes\. /);
6866
+ });
6867
+
6868
+ it("carries each breached control's findings by tier", async () => {
6869
+ const tiers = { act_now: 0, urgent: 0, elevated: 0, routine: 19 };
6870
+ const { client } = await bootMcpPair({
6871
+ capabilities: ALL_CAPABILITIES,
6872
+ routes: {
6873
+ [`GET /v1/products/${PRODUCT_ID}/maintenance/policies`]: {
6874
+ data: {
6875
+ productId: PRODUCT_ID,
6876
+ hasPolicies: true,
6877
+ controls: [
6878
+ {
6879
+ controlId: 'SEC-3.4',
6880
+ policyType: 'policy_security',
6881
+ text: 'No secrets in code',
6882
+ state: 'breached',
6883
+ findingCount: 19,
6884
+ findingsByTier: tiers,
6885
+ divergenceCount: 0,
6886
+ workItems: [],
6887
+ plannedCount: 0,
6888
+ },
6889
+ ],
6890
+ divergences: [],
6891
+ resolvedDivergences: [],
6892
+ },
6893
+ },
6894
+ },
6895
+ });
6896
+
6897
+ const standing = parseToolResult(
6898
+ await client.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID } })
6899
+ );
6900
+ expect(standing.controls[0].findingsByTier).toEqual(tiers);
6901
+ });
6902
+ });
@@ -29,7 +29,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
29
29
  'product has room. autoMerge: false makes pull requests wait for a person to merge them; null follows ' +
30
30
  "the organization and factory above (autonomous merge needs the organization's switch on, and a factory " +
31
31
  'can turn it off for everything under it; neither can be opened from here). Defaults, not rules: an ' +
32
- 'initiative can decide otherwise for itself. Changes how work proceeds; ask the person first.',
32
+ 'initiative can decide otherwise for itself. Changes how work proceeds.',
33
33
  inputSchema: z.object({
34
34
  productId,
35
35
  autoBuild: z.boolean().optional().describe('Build landed plans without a review by default'),
@@ -70,7 +70,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
70
70
  "repository's own rules pass (required checks, reviews, a code reviewer you made Taiga wait for); false " +
71
71
  "makes it wait for a person; null follows the product's default. The final word within what the " +
72
72
  'organization and factories allow: where either has turned autonomous merge off, true changes nothing. ' +
73
- 'Taiga merges through your rules, never around them. Ask the person first.',
73
+ 'Taiga merges through your rules, never around them.',
74
74
  inputSchema: z.object({
75
75
  itemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it'),
76
76
  autoMerge: z