@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/dist/index.js +383 -33
- package/package.json +1 -1
- package/src/client.ts +20 -4
- package/src/server.integration.test.ts +448 -9
- package/src/tools/backlogs.ts +8 -8
- package/src/tools/builds.ts +8 -8
- package/src/tools/context.ts +280 -0
- package/src/tools/documents.ts +7 -6
- package/src/tools/errors.ts +20 -3
- package/src/tools/index.ts +2 -0
- package/src/tools/inputs.ts +32 -0
- package/src/tools/instructions.ts +6 -2
- package/src/tools/products.ts +88 -40
- package/src/tools/sections.test.ts +18 -1
- package/src/tools/sections.ts +17 -0
- package/src/tools/summarize.ts +5 -0
- package/taiga-ai-mcp-server-0.8.0-dev.397.tgz +0 -0
- package/taiga-ai-mcp-server-0.7.0-dev.395.tgz +0 -0
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(
|
|
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
|
-
|
|
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({
|
|
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('
|
|
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
|
package/src/tools/backlogs.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
57
|
+
return toolResult(
|
|
58
|
+
page(pickFields(result.data as Record<string, unknown>[], BACKLOG_SUMMARY_FIELDS), result.nextCursor)
|
|
59
|
+
);
|
|
60
60
|
})
|
|
61
61
|
);
|
|
62
62
|
|