@taiga-ai/mcp-server 0.5.0-rc.389 → 0.6.0-dev.391

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
@@ -137,12 +137,18 @@ export class TaigaClient {
137
137
  async listProducts(opts?: {
138
138
  factoryId?: string | undefined;
139
139
  status?: string | undefined;
140
+ repository?: string | undefined;
141
+ repositoryHost?: string | undefined;
140
142
  limit?: number | undefined;
143
+ cursor?: string | undefined;
141
144
  }) {
142
145
  const params = new URLSearchParams();
143
146
  if (opts?.factoryId) params.set('factoryId', opts.factoryId);
144
147
  if (opts?.status) params.set('status', opts.status);
148
+ if (opts?.repository) params.set('repository', opts.repository);
149
+ if (opts?.repositoryHost) params.set('repositoryHost', opts.repositoryHost);
145
150
  if (opts?.limit) params.set('limit', String(opts.limit));
151
+ if (opts?.cursor) params.set('cursor', opts.cursor);
146
152
  const qs = params.toString();
147
153
  return this.request<{ data: unknown[]; nextCursor: string | null }>('GET', `/products${qs ? `?${qs}` : ''}`);
148
154
  }
@@ -242,83 +248,26 @@ export class TaigaClient {
242
248
  // Factory Documents
243
249
  // ---------------------------------------------------------------------------
244
250
 
245
- /**
246
- * Fetch the full context for a product in parallel: product metadata,
247
- * its factory's instructions and the product documents.
248
- * Returns a structured bundle optimized for coding agent consumption.
249
- */
250
- async getProductContext(productId: string): Promise<Record<string, unknown>> {
251
- // First fetch the product to get its factory
252
- const product = await this.request<{ data: Record<string, unknown> }>('GET', `/products/${seg(productId)}`);
253
- const factoryId = product.data.factoryId as string | undefined;
254
-
255
- // Fetch the document lists in parallel.
256
- const [productDocs, factoryDocs] = await Promise.all([
257
- this.listProductDocuments(productId, { limit: 20 }).catch(() => ({ data: [] })),
258
- factoryId
259
- ? this.listFactoryDocuments(factoryId, { limit: 20 }).catch(() => ({ data: [] }))
260
- : Promise.resolve({ data: [] }),
261
- ]);
262
-
263
- // Fetch full content for key documents (in parallel)
264
- const keyProductDocTypes = ['specification', 'architecture', 'technology_decisions'];
265
- // The factory instructions document.
266
- const keyFactoryDocTypes = ['factory_instructions'];
267
-
268
- const docFetches: Promise<{ type: string; scope: string; data: unknown }>[] = [];
269
-
270
- for (const docType of keyProductDocTypes) {
271
- const exists = (productDocs.data as Record<string, unknown>[]).some((d) => d.type === docType && !d.isDraft);
272
- if (exists) {
273
- docFetches.push(
274
- this.getProductDocument(productId, docType)
275
- .then((r) => ({ type: docType, scope: 'product', data: r.data }))
276
- .catch(() => ({ type: docType, scope: 'product', data: null }))
277
- );
278
- }
279
- }
280
-
281
- if (factoryId) {
282
- for (const docType of keyFactoryDocTypes) {
283
- const exists = (factoryDocs.data as Record<string, unknown>[]).some((d) => d.type === docType && !d.isDraft);
284
- if (exists) {
285
- docFetches.push(
286
- this.getFactoryDocument(factoryId, docType)
287
- .then((r) => ({ type: docType, scope: 'factory', data: r.data }))
288
- .catch(() => ({ type: docType, scope: 'factory', data: null }))
289
- );
290
- }
291
- }
292
- }
293
-
294
- const fetchedDocs = await Promise.all(docFetches);
295
- const summarize = (docs: Record<string, unknown>[]) =>
296
- docs.map((d) => ({ type: d.type, name: d.name, version: d.version, isDraft: d.isDraft }));
297
-
298
- // Assemble context bundle
299
- return {
300
- product: product.data,
301
- documents: {
302
- product: Object.fromEntries(
303
- fetchedDocs.filter((d) => d.scope === 'product' && d.data).map((d) => [d.type, d.data])
304
- ),
305
- factory: Object.fromEntries(
306
- fetchedDocs.filter((d) => d.scope === 'factory' && d.data).map((d) => [d.type, d.data])
307
- ),
308
- },
309
- available_documents: {
310
- product: summarize(productDocs.data as Record<string, unknown>[]),
311
- factory: summarize(factoryDocs.data as Record<string, unknown>[]),
312
- },
313
- };
314
- }
315
-
316
251
  // ---------------------------------------------------------------------------
317
252
  // Backlog
318
253
  // ---------------------------------------------------------------------------
319
254
 
320
- async listBacklogItems(productId: string, opts?: { limit?: number | undefined }) {
255
+ async listBacklogItems(
256
+ productId: string,
257
+ opts?: {
258
+ status?: string | undefined;
259
+ planningStatus?: string | undefined;
260
+ category?: string | undefined;
261
+ includeArchived?: boolean | undefined;
262
+ limit?: number | undefined;
263
+ }
264
+ ) {
321
265
  const params = new URLSearchParams();
266
+ if (opts?.status) params.set('status', opts.status);
267
+ if (opts?.planningStatus) params.set('planningStatus', opts.planningStatus);
268
+ if (opts?.category) params.set('category', opts.category);
269
+ // The route reads the literal strings 'true' and 'false'.
270
+ if (opts?.includeArchived) params.set('includeArchived', 'true');
322
271
  if (opts?.limit) params.set('limit', String(opts.limit));
323
272
  const qs = params.toString();
324
273
  return this.request<{ data: unknown[]; nextCursor: string | null }>(
@@ -332,11 +281,26 @@ export class TaigaClient {
332
281
  }
333
282
 
334
283
  // ---------------------------------------------------------------------------
335
- // Environments
284
+ // Build runs
336
285
  // ---------------------------------------------------------------------------
337
286
 
338
- async listEnvironments(productId: string) {
339
- return this.request<{ data: unknown[] }>('GET', `/products/${seg(productId)}/environments`);
287
+ async listBuildRuns(productId: string, opts?: { backlogItemId?: string | undefined; limit?: number | undefined }) {
288
+ const params = new URLSearchParams();
289
+ if (opts?.backlogItemId) params.set('backlogItemId', opts.backlogItemId);
290
+ if (opts?.limit) params.set('limit', String(opts.limit));
291
+ const qs = params.toString();
292
+ return this.request<{ data: unknown[]; nextCursor: string | null }>(
293
+ 'GET',
294
+ `/products/${seg(productId)}/builds${qs ? `?${qs}` : ''}`
295
+ );
296
+ }
297
+
298
+ async getBuildRun(id: string) {
299
+ return this.request<{ data: unknown }>('GET', `/builds/${seg(id)}`);
300
+ }
301
+
302
+ async listBuildRunSteps(id: string) {
303
+ return this.request<{ data: unknown[] }>('GET', `/builds/${seg(id)}/steps`);
340
304
  }
341
305
 
342
306
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -48,9 +48,13 @@ async function main() {
48
48
  "Specification (what to build), architecture (how it's designed), threat model (security risks),",
49
49
  'technology decisions, and data flow. Read these to understand the specific product.',
50
50
  '',
51
- 'Workflow: Start with taiga_list_factories to find your factory, then read its instructions.',
52
- 'For product-specific work, use taiga_list_products to find the product,',
53
- 'then read its specification and architecture.',
51
+ '**Backlog and runs** (taiga_list_backlog_items / taiga_get_backlog_item / taiga_list_builds / taiga_get_build):',
52
+ "The product's initiatives with their acceptance criteria, and what Taiga's coding agent already built",
53
+ 'for them: runs, steps, commits and pull requests.',
54
+ '',
55
+ 'Workflow: Inside a git checkout, start with taiga_resolve_product on the remote URL to find the product.',
56
+ 'Otherwise use taiga_list_factories and taiga_list_products. Then read the factory instructions,',
57
+ "the product's specification and architecture, and the backlog item you are working on.",
54
58
  '',
55
59
  'When you notice a policy or factory instructions are outdated, fix the section with',
56
60
  'taiga_update_document or taiga_update_factory_document. That saves a draft nobody follows yet;',
@@ -24,9 +24,14 @@ const ALL_CAPABILITIES = [
24
24
  * sorted for deterministic comparison.
25
25
  */
26
26
  const ALL_TOOL_NAMES = [
27
+ 'taiga_get_backlog_item',
28
+ 'taiga_get_build',
27
29
  'taiga_get_document',
28
30
  'taiga_get_factory_document',
31
+ 'taiga_get_product',
29
32
  'taiga_get_product_document',
33
+ 'taiga_list_backlog_items',
34
+ 'taiga_list_builds',
30
35
  'taiga_list_documents',
31
36
  'taiga_list_factories',
32
37
  'taiga_list_factory_documents',
@@ -34,10 +39,23 @@ const ALL_TOOL_NAMES = [
34
39
  'taiga_list_products',
35
40
  'taiga_publish_document',
36
41
  'taiga_publish_factory_document',
42
+ 'taiga_resolve_product',
37
43
  'taiga_update_document',
38
44
  'taiga_update_factory_document',
39
45
  ];
40
46
 
47
+ const PRODUCT_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5c';
48
+ const PRODUCT_ID_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5f';
49
+ const ITEM_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5d';
50
+ const BUILD_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5e';
51
+
52
+ /** The URL the fetch mock received for the one request whose path contains `pathPart`. */
53
+ function sentUrl(fetchSpy: ReturnType<typeof vi.fn>, pathPart: string) {
54
+ const call = fetchSpy.mock.calls.find((c: unknown[]) => (c[0] as string).includes(pathPart));
55
+ expect(call, `${pathPart} was not requested`).toBeDefined();
56
+ return new URL(call![0] as string);
57
+ }
58
+
41
59
  const FACTORY_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b';
42
60
 
43
61
  /** A policy as the API returns it: sections carry metadata beside their text. */
@@ -296,7 +314,16 @@ describe('MCP server integration', () => {
296
314
 
297
315
  describe('capability-tool contract', () => {
298
316
  const CONTRACT: Record<string, string[]> = {
299
- 'products:read': ['taiga_list_products', 'taiga_list_product_documents', 'taiga_get_product_document'],
317
+ 'products:read': [
318
+ 'taiga_list_products',
319
+ 'taiga_get_product',
320
+ 'taiga_resolve_product',
321
+ 'taiga_list_product_documents',
322
+ 'taiga_get_product_document',
323
+ 'taiga_list_builds',
324
+ 'taiga_get_build',
325
+ ],
326
+ 'backlog:read': ['taiga_list_backlog_items', 'taiga_get_backlog_item'],
300
327
  'factories:read': ['taiga_list_factories', 'taiga_list_factory_documents', 'taiga_get_factory_document'],
301
328
  'tenant:documents:read': ['taiga_list_documents', 'taiga_get_document'],
302
329
  };
@@ -720,6 +747,299 @@ describe('MCP server integration', () => {
720
747
  // clear error, not a silent success or cryptic crash.
721
748
  // -------------------------------------------------------------------------
722
749
 
750
+ // -------------------------------------------------------------------------
751
+ // Developer-in-a-checkout tools (PRO-2564): resolve the product from the
752
+ // remote, then read its backlog and the coding agent's runs.
753
+ // -------------------------------------------------------------------------
754
+
755
+ describe('backlog and build reads', () => {
756
+ const product = { id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_ID, settings: { big: true } };
757
+ const productSummary = { id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_ID };
758
+ const item = {
759
+ id: ITEM_ID,
760
+ productId: PRODUCT_ID,
761
+ title: 'Add login',
762
+ description: 'A long description the list must not carry',
763
+ category: 'feature',
764
+ status: 'in_progress',
765
+ state: 'build',
766
+ planningStatus: 'planned',
767
+ priority: 1,
768
+ estimate: 3,
769
+ origin: 'user',
770
+ needsHumanCause: null,
771
+ acceptanceCriteria: [{ criterion: 'Users can log in', met: false }],
772
+ technicalContext: { patterns: ['hexagonal'], constraints: [], references: [] },
773
+ };
774
+ const run = {
775
+ id: BUILD_ID,
776
+ runNumber: 2,
777
+ backlogItemId: ITEM_ID,
778
+ status: 'completed',
779
+ failedReason: null,
780
+ branch: 'taiga/add-login',
781
+ prUrl: 'https://github.com/acme/web/pull/7',
782
+ prState: 'open',
783
+ completedSteps: 3,
784
+ totalSteps: 3,
785
+ targetTitle: 'Add login',
786
+ error: 'a long agent error the list must not carry',
787
+ metrics: { tokens: 12345 },
788
+ hardeningResult: { findings: [] },
789
+ };
790
+ const step = { id: 'step-1', runId: BUILD_ID, stepOrder: 1, title: 'Add the route', status: 'completed' };
791
+
792
+ it('should resolve the product from a git remote through the repository filter', async () => {
793
+ const { client, fetchSpy } = await bootMcpPair({
794
+ capabilities: ['products:read'],
795
+ routes: { '/v1/products': { data: [product], nextCursor: null } },
796
+ });
797
+
798
+ const result = await client.callTool({
799
+ name: 'taiga_resolve_product',
800
+ arguments: { remoteUrl: 'git@github.com:Acme/Web.git' },
801
+ });
802
+
803
+ expect(result.isError).toBeFalsy();
804
+ expect(parseToolResult(result)).toEqual({
805
+ repository: 'Acme/Web',
806
+ host: 'https://github.com',
807
+ products: [productSummary],
808
+ truncated: false,
809
+ });
810
+ const url = sentUrl(fetchSpy, '/v1/products');
811
+ expect(url.searchParams.get('repository')).toBe('Acme/Web');
812
+ expect(url.searchParams.get('repositoryHost')).toBe('https://github.com');
813
+ expect(url.searchParams.get('limit')).toBe('100');
814
+ });
815
+
816
+ /**
817
+ * The fetch router keys on the path, so product pages are served here by
818
+ * cursor instead; every other path still goes to the router.
819
+ */
820
+ function serveProductPages(fetchSpy: ReturnType<typeof vi.fn>, page: (cursor: string | null) => unknown) {
821
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
822
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
823
+ const parsed = new URL(url);
824
+ if (parsed.pathname !== '/v1/products') return router(url, init);
825
+ const body = page(parsed.searchParams.get('cursor'));
826
+ return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
827
+ });
828
+ }
829
+
830
+ const productPageCursors = (fetchSpy: ReturnType<typeof vi.fn>) =>
831
+ fetchSpy.mock.calls
832
+ .map((c: unknown[]) => new URL(c[0] as string))
833
+ .filter((u: URL) => u.pathname === '/v1/products')
834
+ .map((u: URL) => u.searchParams.get('cursor'));
835
+
836
+ it('should follow the pages of a shared repository rather than stop at the first', async () => {
837
+ const second = { ...product, id: PRODUCT_ID_2, name: 'Web Admin' };
838
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
839
+ serveProductPages(fetchSpy, (cursor) =>
840
+ cursor === null ? { data: [product], nextCursor: 'page-2' } : { data: [second], nextCursor: null }
841
+ );
842
+
843
+ const result = await client.callTool({
844
+ name: 'taiga_resolve_product',
845
+ arguments: { remoteUrl: 'git@github.com:acme/web.git' },
846
+ });
847
+
848
+ const parsed = parseToolResult(result);
849
+ expect(parsed.products.map((p: { id: string }) => p.id)).toEqual([PRODUCT_ID, PRODUCT_ID_2]);
850
+ expect(parsed.truncated).toBe(false);
851
+ expect(productPageCursors(fetchSpy)).toEqual([null, 'page-2']);
852
+ });
853
+
854
+ it('should stop after ten pages and say the list is truncated', async () => {
855
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
856
+ serveProductPages(fetchSpy, () => ({ data: [product], nextCursor: 'more' }));
857
+
858
+ const result = await client.callTool({
859
+ name: 'taiga_resolve_product',
860
+ arguments: { remoteUrl: 'git@github.com:acme/web.git' },
861
+ });
862
+
863
+ const parsed = parseToolResult(result);
864
+ expect(parsed.products).toHaveLength(10);
865
+ expect(parsed.truncated).toBe(true);
866
+ expect(productPageCursors(fetchSpy)).toHaveLength(10);
867
+ });
868
+
869
+ it('should fail the whole resolution when a later page fails, not hand back a partial list', async () => {
870
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
871
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
872
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
873
+ const parsed = new URL(url);
874
+ if (parsed.pathname !== '/v1/products') return router(url, init);
875
+ if (parsed.searchParams.get('cursor') === null) {
876
+ const body = { data: [product], nextCursor: 'page-2' };
877
+ return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
878
+ }
879
+ const body = { error: 'Too Many Requests', code: 'RATE_LIMITED', message: 'Slow down' };
880
+ return { ok: false, status: 429, json: async () => body, text: async () => JSON.stringify(body) };
881
+ });
882
+
883
+ const result = await client.callTool({
884
+ name: 'taiga_resolve_product',
885
+ arguments: { remoteUrl: 'git@github.com:acme/web.git' },
886
+ });
887
+
888
+ expect(result.isError).toBe(true);
889
+ expect(parseToolResult(result)).toEqual({ error: 'RATE_LIMITED', message: 'Slow down' });
890
+ });
891
+
892
+ it('should say NOT_FOUND when no visible product is linked to the remote', async () => {
893
+ const { client } = await bootMcpPair({
894
+ capabilities: ['products:read'],
895
+ routes: { '/v1/products': { data: [], nextCursor: null } },
896
+ });
897
+
898
+ const result = await client.callTool({
899
+ name: 'taiga_resolve_product',
900
+ arguments: { remoteUrl: 'https://github.com/acme/web.git' },
901
+ });
902
+
903
+ expect(result.isError).toBe(true);
904
+ expect(parseToolResult(result)).toMatchObject({
905
+ error: 'NOT_FOUND',
906
+ message: expect.stringContaining('acme/web'),
907
+ });
908
+ });
909
+
910
+ it('should refuse a remote it cannot read before any request is made', async () => {
911
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
912
+ const before = fetchSpy.mock.calls.length;
913
+
914
+ const result = await client.callTool({
915
+ name: 'taiga_resolve_product',
916
+ arguments: { remoteUrl: '/Users/dev/web' },
917
+ });
918
+
919
+ expect(result.isError).toBe(true);
920
+ expect(parseToolResult(result).error).toBe('INVALID_REMOTE');
921
+ expect(fetchSpy.mock.calls.length).toBe(before);
922
+ });
923
+
924
+ it('should list products as summaries now that taiga_get_product carries the rest', async () => {
925
+ const { client } = await bootMcpPair({
926
+ capabilities: ['products:read'],
927
+ routes: { '/v1/products': { data: [product], nextCursor: null } },
928
+ });
929
+
930
+ const result = await client.callTool({ name: 'taiga_list_products', arguments: {} });
931
+
932
+ expect(parseToolResult(result)).toEqual([productSummary]);
933
+ });
934
+
935
+ it('should return one product in full', async () => {
936
+ const { client } = await bootMcpPair({
937
+ capabilities: ['products:read'],
938
+ routes: { [`/v1/products/${PRODUCT_ID}`]: { data: product } },
939
+ });
940
+
941
+ const result = await client.callTool({ name: 'taiga_get_product', arguments: { productId: PRODUCT_ID } });
942
+
943
+ expect(parseToolResult(result)).toEqual(product);
944
+ });
945
+
946
+ it('should list backlog items as summaries and pass the filters through', async () => {
947
+ const { client, fetchSpy } = await bootMcpPair({
948
+ capabilities: ['backlog:read'],
949
+ routes: { [`/v1/products/${PRODUCT_ID}/backlog`]: { data: [item], nextCursor: null } },
950
+ });
951
+
952
+ const result = await client.callTool({
953
+ name: 'taiga_list_backlog_items',
954
+ arguments: { productId: PRODUCT_ID, status: 'in_progress', category: 'feature', includeArchived: true },
955
+ });
956
+
957
+ const [row] = parseToolResult(result);
958
+ expect(row).toMatchObject({ id: ITEM_ID, title: 'Add login', state: 'build', planningStatus: 'planned' });
959
+ expect(row).not.toHaveProperty('description');
960
+ expect(row).not.toHaveProperty('acceptanceCriteria');
961
+ const url = sentUrl(fetchSpy, '/backlog');
962
+ expect(url.searchParams.get('status')).toBe('in_progress');
963
+ expect(url.searchParams.get('category')).toBe('feature');
964
+ expect(url.searchParams.get('includeArchived')).toBe('true');
965
+ });
966
+
967
+ it('should return a backlog item in full, acceptance criteria included', async () => {
968
+ const { client } = await bootMcpPair({
969
+ capabilities: ['backlog:read'],
970
+ routes: { [`/v1/backlog/${ITEM_ID}`]: { data: item } },
971
+ });
972
+
973
+ const result = await client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } });
974
+
975
+ expect(parseToolResult(result)).toEqual(item);
976
+ });
977
+
978
+ it('should list runs as summaries, filtered to one backlog item', async () => {
979
+ const { client, fetchSpy } = await bootMcpPair({
980
+ capabilities: ['products:read'],
981
+ routes: { [`/v1/products/${PRODUCT_ID}/builds`]: { data: [run], nextCursor: null } },
982
+ });
983
+
984
+ const result = await client.callTool({
985
+ name: 'taiga_list_builds',
986
+ arguments: { productId: PRODUCT_ID, backlogItemId: ITEM_ID },
987
+ });
988
+
989
+ const [row] = parseToolResult(result);
990
+ expect(row).toMatchObject({
991
+ id: BUILD_ID,
992
+ runNumber: 2,
993
+ status: 'completed',
994
+ prUrl: run.prUrl,
995
+ targetTitle: 'Add login',
996
+ });
997
+ expect(row).not.toHaveProperty('error');
998
+ expect(row).not.toHaveProperty('metrics');
999
+ expect(row).not.toHaveProperty('hardeningResult');
1000
+ expect(sentUrl(fetchSpy, '/builds').searchParams.get('backlogItemId')).toBe(ITEM_ID);
1001
+ });
1002
+
1003
+ it('should return a run with its steps from two routes', async () => {
1004
+ const { client } = await bootMcpPair({
1005
+ capabilities: ['products:read'],
1006
+ routes: {
1007
+ [`/v1/builds/${BUILD_ID}`]: { data: run },
1008
+ [`/v1/builds/${BUILD_ID}/steps`]: { data: [step] },
1009
+ },
1010
+ });
1011
+
1012
+ const result = await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } });
1013
+
1014
+ expect(result.isError).toBeFalsy();
1015
+ expect(parseToolResult(result)).toEqual({ ...run, steps: [step] });
1016
+ });
1017
+
1018
+ it('should refuse a fractional limit before any request, as the API would after one', async () => {
1019
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ['backlog:read', 'products:read'] });
1020
+ const before = fetchSpy.mock.calls.length;
1021
+
1022
+ for (const [name, args] of [
1023
+ ['taiga_list_backlog_items', { productId: PRODUCT_ID, limit: 2.5 }],
1024
+ ['taiga_list_builds', { productId: PRODUCT_ID, limit: 2.5 }],
1025
+ ['taiga_list_products', { limit: 2.5 }],
1026
+ ] as const) {
1027
+ const result = await client.callTool({ name, arguments: { ...args } });
1028
+ expect(result.isError, name).toBe(true);
1029
+ }
1030
+ expect(fetchSpy.mock.calls.length).toBe(before);
1031
+ });
1032
+
1033
+ it('should not expose backlog reads to a products:read-only key', async () => {
1034
+ const { client } = await bootMcpPair({ capabilities: ['products:read'] });
1035
+
1036
+ const names = toolNames((await client.listTools()).tools);
1037
+
1038
+ expect(names).toContain('taiga_list_builds');
1039
+ expect(names).not.toContain('taiga_list_backlog_items');
1040
+ });
1041
+ });
1042
+
723
1043
  describe('error handling', () => {
724
1044
  it('should surface API errors as structured MCP error content', async () => {
725
1045
  // Introspect succeeds, but all other endpoints fail
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Tool annotations shared by every read tool. Hints for the client, not a
3
+ * control: the API enforces what a key may do.
4
+ */
5
+ export const READ_ONLY = {
6
+ readOnlyHint: true,
7
+ destructiveHint: false,
8
+ idempotentHint: true,
9
+ openWorldHint: false,
10
+ } as const;
@@ -1,10 +1,82 @@
1
- // Backlog tools are reserved for post-beta.
2
- // The beta MCP server focuses on the knowledge base (policies, factory instructions).
3
-
4
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
5
3
  import type { TaigaClient } from '../client.js';
4
+ import { READ_ONLY } from './annotations.js';
5
+ import { runTool, toolResult } from './errors.js';
6
+ import { pickFields } from './summarize.js';
7
+
8
+ /** What a list row needs to say for the model to pick an item to read in full. */
9
+ const BACKLOG_SUMMARY_FIELDS = [
10
+ 'id',
11
+ 'title',
12
+ 'category',
13
+ 'status',
14
+ 'state',
15
+ 'planningStatus',
16
+ 'priority',
17
+ 'estimate',
18
+ 'origin',
19
+ 'needsHumanCause',
20
+ 'archivedAt',
21
+ 'createdAt',
22
+ 'updatedAt',
23
+ ] as const;
24
+
25
+ export function registerBacklogTools(server: McpServer, client: TaigaClient) {
26
+ if (!client.hasCapability('backlog:read')) return;
27
+
28
+ server.registerTool(
29
+ 'taiga_list_backlog_items',
30
+ {
31
+ title: 'List backlog items',
32
+ description:
33
+ "List a product's backlog items (initiatives): titles, category, status and where each one is on the " +
34
+ 'line (backlog, todo, queue, plan, build, done). Use this to find the item you are working on, then ' +
35
+ 'read its acceptance criteria and technical context with taiga_get_backlog_item. ' +
36
+ 'Archived items are left out unless asked for.',
37
+ 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'),
42
+ status: z
43
+ .enum(['backlog', 'in_progress', 'done', 'canceled'])
44
+ .optional()
45
+ .describe('Only items with this status; in_progress covers everything between backlog and done'),
46
+ planningStatus: z
47
+ .enum(['unplanned', 'planning', 'planned', 'failed'])
48
+ .optional()
49
+ .describe('Only items whose plan is in this state'),
50
+ category: z.enum(['feature', 'bug', 'tech_debt', 'improvement']).optional().describe('Only this category'),
51
+ 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)'),
53
+ },
54
+ annotations: READ_ONLY,
55
+ },
56
+ (params) =>
57
+ runTool(async () => {
58
+ const result = await client.listBacklogItems(params.productId, params);
59
+ return toolResult(pickFields(result.data as Record<string, unknown>[], BACKLOG_SUMMARY_FIELDS));
60
+ })
61
+ );
6
62
 
7
- export function registerBacklogTools(_server: McpServer, _client: TaigaClient) {
8
- // No backlog tools in beta -- will be added when the MCP server
9
- // expands beyond the knowledge base use case.
63
+ server.registerTool(
64
+ 'taiga_get_backlog_item',
65
+ {
66
+ title: 'Get backlog item',
67
+ description:
68
+ 'Get one backlog item in full: description, acceptance criteria (and which are met), technical ' +
69
+ 'context (patterns, constraints, references), dependencies and source documents. Read this before ' +
70
+ 'implementing an item, and read its runs with taiga_list_builds to see what the coding agent already did.',
71
+ inputSchema: {
72
+ itemId: z.string().uuid().describe('Backlog item ID -- get from taiga_list_backlog_items'),
73
+ },
74
+ annotations: READ_ONLY,
75
+ },
76
+ (params) =>
77
+ runTool(async () => {
78
+ const result = await client.getBacklogItem(params.itemId);
79
+ return toolResult(result.data);
80
+ })
81
+ );
10
82
  }