@taiga-ai/mcp-server 0.7.0-dev.395 → 0.8.0-dev.397

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/src/client.ts CHANGED
@@ -159,13 +159,21 @@ export class TaigaClient {
159
159
  return this.request<{ data: unknown }>('GET', `/products/${seg(id)}`);
160
160
  }
161
161
 
162
+ async listProductRepositories(productId: string) {
163
+ return this.request<{ data: unknown[]; nextCursor: string | null }>(
164
+ 'GET',
165
+ `/products/${seg(productId)}/repositories`
166
+ );
167
+ }
168
+
162
169
  // ---------------------------------------------------------------------------
163
170
  // Documents (tenant-level policies)
164
171
  // ---------------------------------------------------------------------------
165
172
 
166
- async listTenantDocuments(opts?: { limit?: number | undefined }) {
173
+ async listTenantDocuments(opts?: { limit?: number | undefined; cursor?: string | undefined }) {
167
174
  const params = new URLSearchParams();
168
175
  if (opts?.limit) params.set('limit', String(opts.limit));
176
+ if (opts?.cursor) params.set('cursor', opts.cursor);
169
177
  const qs = params.toString();
170
178
  return this.request<{ data: unknown[]; nextCursor: string | null }>(
171
179
  'GET',
@@ -194,9 +202,10 @@ export class TaigaClient {
194
202
  // Factory Documents
195
203
  // ---------------------------------------------------------------------------
196
204
 
197
- async listFactoryDocuments(factoryId: string, opts?: { limit?: number | undefined }) {
205
+ async listFactoryDocuments(factoryId: string, opts?: { limit?: number | undefined; cursor?: string | undefined }) {
198
206
  const params = new URLSearchParams();
199
207
  if (opts?.limit) params.set('limit', String(opts.limit));
208
+ if (opts?.cursor) params.set('cursor', opts.cursor);
200
209
  const qs = params.toString();
201
210
  return this.request<{ data: unknown[]; nextCursor: string | null }>(
202
211
  'GET',
@@ -264,6 +273,7 @@ export class TaigaClient {
264
273
  category?: string | undefined;
265
274
  includeArchived?: boolean | undefined;
266
275
  limit?: number | undefined;
276
+ cursor?: string | undefined;
267
277
  }
268
278
  ) {
269
279
  const params = new URLSearchParams();
@@ -273,6 +283,7 @@ export class TaigaClient {
273
283
  // The route reads the literal strings 'true' and 'false'.
274
284
  if (opts?.includeArchived) params.set('includeArchived', 'true');
275
285
  if (opts?.limit) params.set('limit', String(opts.limit));
286
+ if (opts?.cursor) params.set('cursor', opts.cursor);
276
287
  const qs = params.toString();
277
288
  return this.request<{ data: unknown[]; nextCursor: string | null }>(
278
289
  'GET',
@@ -288,10 +299,14 @@ export class TaigaClient {
288
299
  // Build runs
289
300
  // ---------------------------------------------------------------------------
290
301
 
291
- async listBuildRuns(productId: string, opts?: { backlogItemId?: string | undefined; limit?: number | undefined }) {
302
+ async listBuildRuns(
303
+ productId: string,
304
+ opts?: { backlogItemId?: string | undefined; limit?: number | undefined; cursor?: string | undefined }
305
+ ) {
292
306
  const params = new URLSearchParams();
293
307
  if (opts?.backlogItemId) params.set('backlogItemId', opts.backlogItemId);
294
308
  if (opts?.limit) params.set('limit', String(opts.limit));
309
+ if (opts?.cursor) params.set('cursor', opts.cursor);
295
310
  const qs = params.toString();
296
311
  return this.request<{ data: unknown[]; nextCursor: string | null }>(
297
312
  'GET',
@@ -315,9 +330,10 @@ export class TaigaClient {
315
330
  return this.request<{ data: unknown }>('GET', `/factories/${seg(id)}`);
316
331
  }
317
332
 
318
- async listFactories(opts?: { limit?: number | undefined }) {
333
+ async listFactories(opts?: { limit?: number | undefined; cursor?: string | undefined }) {
319
334
  const params = new URLSearchParams();
320
335
  if (opts?.limit) params.set('limit', String(opts.limit));
336
+ if (opts?.cursor) params.set('cursor', opts.cursor);
321
337
  const qs = params.toString();
322
338
  return this.request<{ data: unknown[]; nextCursor: string | null }>('GET', `/factories${qs ? `?${qs}` : ''}`);
323
339
  }
@@ -29,6 +29,7 @@ const ALL_TOOL_NAMES = [
29
29
  'taiga_get_document',
30
30
  'taiga_get_factory_document',
31
31
  'taiga_get_product',
32
+ 'taiga_get_product_context',
32
33
  'taiga_get_product_document',
33
34
  'taiga_list_backlog_items',
34
35
  'taiga_list_builds',
@@ -317,6 +318,7 @@ describe('MCP server integration', () => {
317
318
  'products:read': [
318
319
  'taiga_list_products',
319
320
  'taiga_get_product',
321
+ 'taiga_get_product_context',
320
322
  'taiga_resolve_product',
321
323
  'taiga_list_product_documents',
322
324
  'taiga_get_product_document',
@@ -367,7 +369,7 @@ describe('MCP server integration', () => {
367
369
  const result = await client.callTool({ name: 'taiga_list_factories', arguments: {} });
368
370
 
369
371
  expect(result.content).toHaveLength(1);
370
- expect(parseToolResult(result)).toEqual(factories);
372
+ expect(parseToolResult(result)).toEqual({ data: factories, nextCursor: null });
371
373
  });
372
374
 
373
375
  it('should list company documents as summaries without content or authorship', async () => {
@@ -386,7 +388,7 @@ describe('MCP server integration', () => {
386
388
 
387
389
  const result = await client.callTool({ name: 'taiga_list_documents', arguments: {} });
388
390
 
389
- expect(parseToolResult(result)).toEqual([
391
+ expect(parseToolResult(result).data).toEqual([
390
392
  {
391
393
  type: 'policy_security',
392
394
  name: 'Security Policy',
@@ -665,7 +667,8 @@ describe('MCP server integration', () => {
665
667
  text: async () => '',
666
668
  };
667
669
  }
668
- throw new TypeError('fetch failed');
670
+ // What undici throws: a TypeError whose cause is the socket error.
671
+ throw new TypeError('fetch failed', { cause: new Error('ECONNREFUSED') });
669
672
  });
670
673
  vi.stubGlobal('fetch', fetchSpy);
671
674
  const taigaClient = new TaigaClient({ baseUrl: 'https://api.test.com', apiKey: 'taiga_sk_live_test' });
@@ -679,7 +682,10 @@ describe('MCP server integration', () => {
679
682
  const result = await client.callTool({ name: 'taiga_list_factories', arguments: {} });
680
683
 
681
684
  expect(result.isError).toBe(true);
682
- expect(parseToolResult(result)).toMatchObject({ error: 'NETWORK_ERROR' });
685
+ expect(parseToolResult(result)).toMatchObject({
686
+ error: 'NETWORK_ERROR',
687
+ message: expect.stringContaining('fetch failed (ECONNREFUSED)'),
688
+ });
683
689
  });
684
690
 
685
691
  it('should edit the instructions section of factory instructions by default', async () => {
@@ -754,7 +760,7 @@ describe('MCP server integration', () => {
754
760
 
755
761
  const result = await client.callTool({ name: 'taiga_list_factories', arguments: {} });
756
762
 
757
- expect(parseToolResult(result)).toEqual(factories);
763
+ expect(parseToolResult(result)).toEqual({ data: factories, nextCursor: null });
758
764
  });
759
765
  });
760
766
 
@@ -945,7 +951,7 @@ describe('MCP server integration', () => {
945
951
 
946
952
  const result = await client.callTool({ name: 'taiga_list_products', arguments: {} });
947
953
 
948
- expect(parseToolResult(result)).toEqual([productSummary]);
954
+ expect(parseToolResult(result)).toEqual({ data: [productSummary], nextCursor: null });
949
955
  });
950
956
 
951
957
  it('should return one product in full', async () => {
@@ -970,7 +976,7 @@ describe('MCP server integration', () => {
970
976
  arguments: { productId: PRODUCT_ID, status: 'in_progress', category: 'feature', includeArchived: true },
971
977
  });
972
978
 
973
- const [row] = parseToolResult(result);
979
+ const [row] = parseToolResult(result).data;
974
980
  expect(row).toMatchObject({ id: ITEM_ID, title: 'Add login', state: 'build', planningStatus: 'planned' });
975
981
  expect(row).not.toHaveProperty('description');
976
982
  expect(row).not.toHaveProperty('acceptanceCriteria');
@@ -1002,7 +1008,7 @@ describe('MCP server integration', () => {
1002
1008
  arguments: { productId: PRODUCT_ID, backlogItemId: ITEM_ID },
1003
1009
  });
1004
1010
 
1005
- const [row] = parseToolResult(result);
1011
+ const [row] = parseToolResult(result).data;
1006
1012
  expect(row).toMatchObject({
1007
1013
  id: BUILD_ID,
1008
1014
  runNumber: 2,
@@ -1658,7 +1664,7 @@ describe('MCP server integration', () => {
1658
1664
  expect(result.isError).toBe(true);
1659
1665
  expect(parseToolResult(result)).toMatchObject({
1660
1666
  error: 'VALIDATION_FAILED',
1661
- message: expect.stringContaining('not both'),
1667
+ message: expect.stringContaining('exactly once'),
1662
1668
  });
1663
1669
  expect(fetchSpy.mock.calls.length).toBe(before);
1664
1670
  });
@@ -1675,6 +1681,439 @@ describe('MCP server integration', () => {
1675
1681
  });
1676
1682
  });
1677
1683
 
1684
+ // -------------------------------------------------------------------------
1685
+ // The composite (PRO-2572): one call, every part on its own footing.
1686
+ // -------------------------------------------------------------------------
1687
+
1688
+ describe('product context', () => {
1689
+ const product = {
1690
+ id: PRODUCT_ID,
1691
+ name: 'Web',
1692
+ slug: 'web',
1693
+ status: 'active',
1694
+ factoryId: FACTORY_ID,
1695
+ kind: 'application',
1696
+ settings: { autoMerge: true },
1697
+ };
1698
+ const factory = { id: FACTORY_ID, name: 'Platform', slug: 'platform', isDefault: true };
1699
+ const instructions = {
1700
+ id: 'fd-1',
1701
+ type: 'factory_instructions',
1702
+ version: 2,
1703
+ isDraft: false,
1704
+ data: {
1705
+ sections: {
1706
+ instructions: { content: 'Use pnpm. Ship behind flags.' },
1707
+ deploys: { content: ' Deploy on Tuesdays. ' },
1708
+ empty: { content: ' ' },
1709
+ },
1710
+ },
1711
+ };
1712
+ const spec = {
1713
+ id: 'doc-spec',
1714
+ type: 'specification',
1715
+ version: 3,
1716
+ isDraft: false,
1717
+ origin: 'plan',
1718
+ derivedFrom: { sources: [] },
1719
+ data: { sections: { vision: { content: 'A web app', derivedFrom: ['x'] } } },
1720
+ };
1721
+ const policy = policyDocument();
1722
+ const draftPolicy = policyDocument({ type: 'policy_ops', isDraft: true, version: null });
1723
+ const item = {
1724
+ id: ITEM_ID,
1725
+ title: 'Add login',
1726
+ status: 'in_progress',
1727
+ state: 'build',
1728
+ archivedAt: null,
1729
+ description: 'long',
1730
+ };
1731
+ const settled = { ...item, id: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60', title: 'Old', status: 'done' };
1732
+ const repo = {
1733
+ id: 'r1',
1734
+ provider: 'github',
1735
+ repositoryName: 'acme/web',
1736
+ repositoryUrl: 'https://github.com/acme/web',
1737
+ effectiveBranch: 'main',
1738
+ isManaged: false,
1739
+ languages: { TypeScript: 1 },
1740
+ };
1741
+ const run = { id: BUILD_ID, runNumber: 1, status: 'completed', metrics: { tokens: 1 }, targetTitle: 'Add login' };
1742
+ const allRoutes = {
1743
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: product },
1744
+ [`GET /v1/factories/${FACTORY_ID}`]: { data: factory },
1745
+ [`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions`]: { data: instructions },
1746
+ [`GET /v1/products/${PRODUCT_ID}/documents/specification?origin=plan`]: { data: spec },
1747
+ 'GET /v1/tenants/current/documents': { data: [policy, draftPolicy], nextCursor: null },
1748
+ [`GET /v1/products/${PRODUCT_ID}/backlog?status=backlog&limit=100`]: { data: [], nextCursor: null },
1749
+ // `settled` could not come back from a status=in_progress read; it is here
1750
+ // so the client-side check is seen to hold whatever the API returns.
1751
+ [`GET /v1/products/${PRODUCT_ID}/backlog?status=in_progress&limit=100`]: {
1752
+ data: [item, settled],
1753
+ nextCursor: null,
1754
+ },
1755
+ [`GET /v1/products/${PRODUCT_ID}/repositories`]: { data: [repo], nextCursor: null },
1756
+ [`GET /v1/products/${PRODUCT_ID}/builds?limit=10`]: { data: [run], nextCursor: null },
1757
+ };
1758
+
1759
+ it('should assemble every part in one call and name the one document that is not published', async () => {
1760
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
1761
+
1762
+ const result = await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } });
1763
+
1764
+ expect(result.isError).toBeFalsy();
1765
+ const bundle = parseToolResult(result);
1766
+ expect(bundle.product).toEqual({
1767
+ id: PRODUCT_ID,
1768
+ name: 'Web',
1769
+ slug: 'web',
1770
+ status: 'active',
1771
+ factoryId: FACTORY_ID,
1772
+ kind: 'application',
1773
+ projectType: 'greenfield',
1774
+ defaultOrigin: 'plan',
1775
+ });
1776
+ expect(bundle.factory).toEqual({
1777
+ id: FACTORY_ID,
1778
+ name: 'Platform',
1779
+ slug: 'platform',
1780
+ instructions: 'Use pnpm. Ship behind flags.\n\nDeploy on Tuesdays.',
1781
+ instructionsVersion: 2,
1782
+ });
1783
+ expect(bundle.documents.origin).toBe('plan');
1784
+ expect(bundle.documents.specification.data.sections.vision).toEqual({ content: 'A web app' });
1785
+ expect(bundle.documents.specification).not.toHaveProperty('derivedFrom');
1786
+ expect(bundle.documents.architecture).toBeNull();
1787
+ expect(bundle.documents.notes).toEqual([
1788
+ 'No published plan architecture; taiga_list_product_documents shows what the product has.',
1789
+ ]);
1790
+ expect(bundle.policies).toEqual([
1791
+ { type: 'policy_security', name: 'Security Policy', version: 3, sections: ['summary', 'scope'] },
1792
+ ]);
1793
+ expect(bundle.backlog.items.map((row: { id: string }) => row.id)).toEqual([ITEM_ID]);
1794
+ expect(bundle.backlog.items[0]).not.toHaveProperty('description');
1795
+ expect(bundle.backlog.truncated).toBe(false);
1796
+ expect(bundle.repositories).toEqual({
1797
+ items: [
1798
+ {
1799
+ id: 'r1',
1800
+ provider: 'github',
1801
+ repositoryName: 'acme/web',
1802
+ repositoryUrl: 'https://github.com/acme/web',
1803
+ effectiveBranch: 'main',
1804
+ isManaged: false,
1805
+ },
1806
+ ],
1807
+ truncated: false,
1808
+ });
1809
+ expect(bundle.builds).toEqual([{ id: BUILD_ID, runNumber: 1, status: 'completed', targetTitle: 'Add login' }]);
1810
+ expect(bundle.notGranted).toEqual([]);
1811
+ expect(bundle.unavailable).toEqual({});
1812
+ });
1813
+
1814
+ it('should name the parts the key cannot read instead of showing them empty', async () => {
1815
+ const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: allRoutes });
1816
+
1817
+ const bundle = parseToolResult(
1818
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
1819
+ );
1820
+
1821
+ expect(bundle.notGranted).toEqual([
1822
+ 'factory (needs factories:read)',
1823
+ 'policies (needs tenant:documents:read)',
1824
+ 'backlog (needs backlog:read)',
1825
+ ]);
1826
+ expect(bundle).not.toHaveProperty('factory');
1827
+ expect(bundle).not.toHaveProperty('policies');
1828
+ expect(bundle.repositories.items).toHaveLength(1);
1829
+ });
1830
+
1831
+ it('should name a part the API failed, with what it said, and keep the rest', async () => {
1832
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
1833
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
1834
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
1835
+ if (!new URL(url).pathname.endsWith('/backlog')) return router(url, init);
1836
+ const body = { error: 'Too Many Requests', code: 'RATE_LIMITED', message: 'Slow down' };
1837
+ return { ok: false, status: 429, json: async () => body, text: async () => JSON.stringify(body) };
1838
+ });
1839
+
1840
+ const bundle = parseToolResult(
1841
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
1842
+ );
1843
+
1844
+ expect(bundle.unavailable).toEqual({ backlog: '429 RATE_LIMITED: Slow down' });
1845
+ expect(bundle).not.toHaveProperty('backlog');
1846
+ expect(bundle.builds).toHaveLength(1);
1847
+ });
1848
+
1849
+ it('should read every backlog page, since the open items are the newest and the list is oldest first', async () => {
1850
+ const newest = { ...item, id: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a62', title: 'Newest' };
1851
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
1852
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
1853
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
1854
+ const parsed = new URL(url);
1855
+ if (!parsed.pathname.endsWith('/backlog')) return router(url, init);
1856
+ const status = parsed.searchParams.get('status');
1857
+ const body =
1858
+ status === 'backlog'
1859
+ ? { data: [], nextCursor: null }
1860
+ : parsed.searchParams.get('cursor') === null
1861
+ ? { data: [item], nextCursor: 'page-2' }
1862
+ : { data: [newest], nextCursor: null };
1863
+ return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
1864
+ });
1865
+
1866
+ const bundle = parseToolResult(
1867
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
1868
+ );
1869
+
1870
+ expect(bundle.backlog.items.map((row: { title: string }) => row.title)).toEqual(['Add login', 'Newest']);
1871
+ });
1872
+
1873
+ it('should list an item once when it moved between the two status reads', async () => {
1874
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
1875
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
1876
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
1877
+ if (!new URL(url).pathname.endsWith('/backlog')) return router(url, init);
1878
+ // The same item answers both status reads.
1879
+ const body = { data: [item], nextCursor: null };
1880
+ return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
1881
+ });
1882
+
1883
+ const bundle = parseToolResult(
1884
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
1885
+ );
1886
+
1887
+ expect(bundle.backlog.items).toHaveLength(1);
1888
+ expect(bundle.backlog.truncated).toBe(false);
1889
+ const statuses = fetchSpy.mock.calls
1890
+ .map((c: unknown[]) => new URL(c[0] as string))
1891
+ .filter((u: URL) => u.pathname.endsWith('/backlog'))
1892
+ .map((u: URL) => u.searchParams.get('status'));
1893
+ expect(new Set(statuses)).toEqual(new Set(['backlog', 'in_progress']));
1894
+ });
1895
+
1896
+ it('should stop after ten backlog pages and say the open list is truncated', async () => {
1897
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
1898
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
1899
+ let served = 0;
1900
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
1901
+ if (!new URL(url).pathname.endsWith('/backlog')) return router(url, init);
1902
+ // A distinct item per page, so the id-keyed set is not what caps the count.
1903
+ served += 1;
1904
+ const body = {
1905
+ data: [{ ...item, id: `0199a1b2-c3d4-7e5f-8a9b-${String(served).padStart(12, '0')}` }],
1906
+ nextCursor: 'more',
1907
+ };
1908
+ return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
1909
+ });
1910
+
1911
+ const bundle = parseToolResult(
1912
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
1913
+ );
1914
+
1915
+ // Ten pages per open status, two statuses.
1916
+ expect(bundle.backlog.items).toHaveLength(20);
1917
+ expect(bundle.backlog.truncated).toBe(true);
1918
+ });
1919
+
1920
+ it('should carry the two documents as section ids only when asked for a small bundle', async () => {
1921
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
1922
+
1923
+ const bundle = parseToolResult(
1924
+ await client.callTool({
1925
+ name: 'taiga_get_product_context',
1926
+ arguments: { productId: PRODUCT_ID, fullDocuments: false },
1927
+ })
1928
+ );
1929
+
1930
+ expect(bundle.documents.specification).toEqual({ version: 3, sections: ['vision'] });
1931
+ expect(bundle.documents.architecture).toBeNull();
1932
+ });
1933
+
1934
+ it('should note a document that is not made of sections in a small bundle', async () => {
1935
+ const { client } = await bootMcpPair({
1936
+ capabilities: ALL_CAPABILITIES,
1937
+ routes: {
1938
+ ...allRoutes,
1939
+ [`GET /v1/products/${PRODUCT_ID}/documents/specification?origin=plan`]: {
1940
+ data: { ...spec, data: { legacy: 'free text' } },
1941
+ },
1942
+ },
1943
+ });
1944
+
1945
+ const bundle = parseToolResult(
1946
+ await client.callTool({
1947
+ name: 'taiga_get_product_context',
1948
+ arguments: { productId: PRODUCT_ID, fullDocuments: false },
1949
+ })
1950
+ );
1951
+
1952
+ expect(bundle.documents.specification).toEqual({ version: 3, sections: null });
1953
+ expect(bundle.documents.notes).toEqual(
1954
+ expect.arrayContaining([expect.stringContaining('taiga_get_product_document')])
1955
+ );
1956
+ });
1957
+
1958
+ it('should say when the published instructions are not made of sections', async () => {
1959
+ const { client } = await bootMcpPair({
1960
+ capabilities: ALL_CAPABILITIES,
1961
+ routes: {
1962
+ ...allRoutes,
1963
+ [`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions`]: {
1964
+ data: { ...instructions, data: { legacy: 'free text' } },
1965
+ },
1966
+ },
1967
+ });
1968
+
1969
+ const bundle = parseToolResult(
1970
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
1971
+ );
1972
+
1973
+ expect(bundle.factory).toMatchObject({
1974
+ instructions: null,
1975
+ instructionsVersion: 2,
1976
+ note: expect.stringContaining('taiga_get_factory_document'),
1977
+ });
1978
+ });
1979
+
1980
+ it("should say when the repository list is cut at the route's one page", async () => {
1981
+ const { client } = await bootMcpPair({
1982
+ capabilities: ALL_CAPABILITIES,
1983
+ routes: { ...allRoutes, [`GET /v1/products/${PRODUCT_ID}/repositories`]: { data: [repo], nextCursor: 'more' } },
1984
+ });
1985
+
1986
+ const bundle = parseToolResult(
1987
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
1988
+ );
1989
+
1990
+ expect(bundle.repositories.truncated).toBe(true);
1991
+ });
1992
+
1993
+ it('should say when the factory has no instructions rather than fail the part', async () => {
1994
+ const routes: Record<string, unknown> = { ...allRoutes };
1995
+ delete routes[`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions`];
1996
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes });
1997
+
1998
+ const bundle = parseToolResult(
1999
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
2000
+ );
2001
+
2002
+ expect(bundle.factory).toMatchObject({
2003
+ instructions: null,
2004
+ note: 'This factory has not published instructions yet.',
2005
+ });
2006
+ });
2007
+
2008
+ it('should take the remote URL of a checkout and refuse an ambiguous one', async () => {
2009
+ const second = { ...product, id: ITEM_ID, name: 'Web Admin' };
2010
+ const { client } = await bootMcpPair({
2011
+ capabilities: ALL_CAPABILITIES,
2012
+ routes: { ...allRoutes, 'GET /v1/products': { data: [product], nextCursor: null } },
2013
+ });
2014
+
2015
+ const one = await client.callTool({
2016
+ name: 'taiga_get_product_context',
2017
+ arguments: { remoteUrl: 'git@github.com:acme/web.git' },
2018
+ });
2019
+ expect(parseToolResult(one).product.id).toBe(PRODUCT_ID);
2020
+
2021
+ const { client: other } = await bootMcpPair({
2022
+ capabilities: ALL_CAPABILITIES,
2023
+ routes: { ...allRoutes, 'GET /v1/products': { data: [product, second], nextCursor: null } },
2024
+ });
2025
+ const two = await other.callTool({
2026
+ name: 'taiga_get_product_context',
2027
+ arguments: { remoteUrl: 'git@github.com:acme/web.git' },
2028
+ });
2029
+ expect(two.isError).toBe(true);
2030
+ expect(parseToolResult(two)).toMatchObject({ error: 'AMBIGUOUS', message: expect.stringContaining('Web Admin') });
2031
+ });
2032
+
2033
+ it('should take a slug, and say NOT_FOUND for one nobody visible has', async () => {
2034
+ const { client } = await bootMcpPair({
2035
+ capabilities: ALL_CAPABILITIES,
2036
+ routes: {
2037
+ ...allRoutes,
2038
+ 'GET /v1/products?slug=web&limit=1': { data: [product], nextCursor: null },
2039
+ 'GET /v1/products?slug=nope&limit=1': { data: [], nextCursor: null },
2040
+ },
2041
+ });
2042
+
2043
+ const hit = await client.callTool({ name: 'taiga_get_product_context', arguments: { slug: 'web' } });
2044
+ const miss = await client.callTool({ name: 'taiga_get_product_context', arguments: { slug: 'nope' } });
2045
+
2046
+ expect(parseToolResult(hit).product.id).toBe(PRODUCT_ID);
2047
+ expect(miss.isError).toBe(true);
2048
+ expect(parseToolResult(miss)).toMatchObject({ error: 'NOT_FOUND', message: expect.stringContaining('"nope"') });
2049
+ });
2050
+
2051
+ it('should let a bug in a part fail the call rather than blame the network', async () => {
2052
+ // A TypeError with no cause is our code, not undici: it must surface.
2053
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
2054
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
2055
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
2056
+ if (!new URL(url).pathname.endsWith('/repositories')) return router(url, init);
2057
+ throw new TypeError('bug');
2058
+ });
2059
+
2060
+ // The SDK turns a thrown error into an error result carrying its
2061
+ // message; what matters is that it is not dressed up as NETWORK_ERROR.
2062
+ const result = await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } });
2063
+
2064
+ expect(result.isError).toBe(true);
2065
+ const text = (result.content as { text: string }[])[0]!.text;
2066
+ expect(text).toContain('bug');
2067
+ expect(text).not.toContain('NETWORK_ERROR');
2068
+ });
2069
+
2070
+ it('should refuse two names for the product at once, and none', async () => {
2071
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
2072
+ const before = fetchSpy.mock.calls.length;
2073
+
2074
+ for (const args of [{}, { productId: PRODUCT_ID, slug: 'web' }]) {
2075
+ const result = await client.callTool({ name: 'taiga_get_product_context', arguments: args });
2076
+ expect(result.isError).toBe(true);
2077
+ expect(parseToolResult(result).error).toBe('VALIDATION_FAILED');
2078
+ }
2079
+ expect(fetchSpy.mock.calls.length).toBe(before);
2080
+ });
2081
+ });
2082
+
2083
+ // -------------------------------------------------------------------------
2084
+ // Pagination (PRO-2565): every list tool pages, and says where the next
2085
+ // page starts.
2086
+ // -------------------------------------------------------------------------
2087
+
2088
+ describe('pagination', () => {
2089
+ it.each([
2090
+ ['taiga_list_factories', {}, '/v1/factories', ['factories:read']],
2091
+ ['taiga_list_products', {}, '/v1/products', ['products:read']],
2092
+ ['taiga_list_documents', {}, '/v1/tenants/current/documents', ['tenant:documents:read']],
2093
+ [
2094
+ 'taiga_list_factory_documents',
2095
+ { factoryId: FACTORY_ID },
2096
+ `/v1/factories/${FACTORY_ID}/documents`,
2097
+ ['factories:read'],
2098
+ ],
2099
+ ['taiga_list_backlog_items', { productId: PRODUCT_ID }, `/v1/products/${PRODUCT_ID}/backlog`, ['backlog:read']],
2100
+ ['taiga_list_builds', { productId: PRODUCT_ID }, `/v1/products/${PRODUCT_ID}/builds`, ['products:read']],
2101
+ ] as const)('%s passes the cursor on and returns where the next page starts', async (name, args, path, caps) => {
2102
+ const { client, fetchSpy } = await bootMcpPair({
2103
+ capabilities: [...caps],
2104
+ routes: { [path]: { data: [], nextCursor: 'page-3' } },
2105
+ });
2106
+
2107
+ const result = await client.callTool({ name, arguments: { ...args, cursor: 'page-2', limit: 5 } });
2108
+
2109
+ expect(result.isError).toBeFalsy();
2110
+ expect(parseToolResult(result)).toEqual({ data: [], nextCursor: 'page-3' });
2111
+ const url = sentUrl(fetchSpy, path);
2112
+ expect(url.searchParams.get('cursor')).toBe('page-2');
2113
+ expect(url.searchParams.get('limit')).toBe('5');
2114
+ });
2115
+ });
2116
+
1678
2117
  describe('error handling', () => {
1679
2118
  it('should surface API errors as structured MCP error content', async () => {
1680
2119
  // Introspect succeeds, but all other endpoints fail
@@ -2,11 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import type { TaigaClient } from '../client.js';
4
4
  import { READ_ONLY } from './annotations.js';
5
+ import { pagination, productId } from './inputs.js';
5
6
  import { runTool, toolResult } from './errors.js';
6
- import { pickFields } from './summarize.js';
7
+ import { page, pickFields } from './summarize.js';
7
8
 
8
9
  /** What a list row needs to say for the model to pick an item to read in full. */
9
- const BACKLOG_SUMMARY_FIELDS = [
10
+ export const BACKLOG_SUMMARY_FIELDS = [
10
11
  'id',
11
12
  'title',
12
13
  'category',
@@ -35,10 +36,7 @@ export function registerBacklogTools(server: McpServer, client: TaigaClient) {
35
36
  'read its acceptance criteria and technical context with taiga_get_backlog_item. ' +
36
37
  'Archived items are left out unless asked for.',
37
38
  inputSchema: {
38
- productId: z
39
- .string()
40
- .uuid()
41
- .describe('Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products'),
39
+ productId,
42
40
  status: z
43
41
  .enum(['backlog', 'in_progress', 'done', 'canceled'])
44
42
  .optional()
@@ -49,14 +47,16 @@ export function registerBacklogTools(server: McpServer, client: TaigaClient) {
49
47
  .describe('Only items whose plan is in this state'),
50
48
  category: z.enum(['feature', 'bug', 'tech_debt', 'improvement']).optional().describe('Only this category'),
51
49
  includeArchived: z.boolean().optional().describe('Include archived items (default false)'),
52
- limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
50
+ ...pagination,
53
51
  },
54
52
  annotations: READ_ONLY,
55
53
  },
56
54
  (params) =>
57
55
  runTool(async () => {
58
56
  const result = await client.listBacklogItems(params.productId, params);
59
- return toolResult(pickFields(result.data as Record<string, unknown>[], BACKLOG_SUMMARY_FIELDS));
57
+ return toolResult(
58
+ page(pickFields(result.data as Record<string, unknown>[], BACKLOG_SUMMARY_FIELDS), result.nextCursor)
59
+ );
60
60
  })
61
61
  );
62
62