@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.
@@ -1,7 +1,9 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
3
  import { z } from 'zod';
3
4
  import { TaigaApiError, type TaigaClient } from '../client.js';
4
5
  import { READ_ONLY } from './annotations.js';
6
+ import { pagination, productId, remoteUrl, slug } from './inputs.js';
5
7
  import {
6
8
  ORIGIN_MEANING,
7
9
  PRODUCT_DOCUMENT_ORIGINS,
@@ -17,10 +19,10 @@ import {
17
19
  import { runTool, toolError, toolResult } from './errors.js';
18
20
  import { parseRemote } from './remote.js';
19
21
  import { stripProvenance } from './sections.js';
20
- import { pickFields } from './summarize.js';
22
+ import { page, pickFields } from './summarize.js';
21
23
 
22
24
  /** What a product row needs to say for the model to pick one and read it in full. */
23
- const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'kind', 'discoveryStep'] as const;
25
+ export const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'kind', 'discoveryStep'] as const;
24
26
 
25
27
  /**
26
28
  * What a document row needs to say for the model to pick one and read it in
@@ -48,11 +50,6 @@ const PAGE_SIZE = 100;
48
50
  */
49
51
  const RESOLVE_PAGE_LIMIT = 10;
50
52
 
51
- const productId = z
52
- .string()
53
- .uuid()
54
- .describe('Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products');
55
-
56
53
  const originInput = z
57
54
  .enum(PRODUCT_DOCUMENT_ORIGINS)
58
55
  .optional()
@@ -64,8 +61,9 @@ const originInput = z
64
61
 
65
62
  /**
66
63
  * Every product linked to the repository, page by page, until the API runs
67
- * out or the cap is hit. A page that fails fails the call, like every other
68
- * tool: a partial list marked truncated would read as a complete one.
64
+ * out or the cap is hit, in which case the list says it is truncated. A page
65
+ * that FAILS is different: it fails the call, like every other tool, rather
66
+ * than being folded into a truncated list that would read as a complete one.
69
67
  */
70
68
  async function linkedProducts(client: TaigaClient, repository: string, repositoryHost: string) {
71
69
  const products: Record<string, unknown>[] = [];
@@ -201,6 +199,75 @@ function describeMiss(
201
199
  return parts.join(' ');
202
200
  }
203
201
 
202
+ /**
203
+ * One product from whichever name the caller has: its ID, its slug (through
204
+ * the list's slug filter) or the git remote of a checkout (through the
205
+ * repository filter). Exactly one of the three; a remote linked to several
206
+ * products is ambiguous and says so.
207
+ */
208
+ export async function resolveProductRef(
209
+ client: TaigaClient,
210
+ ref: { productId?: string | undefined; slug?: string | undefined; remoteUrl?: string | undefined },
211
+ /** The names the calling tool accepts, for the message when none or several were given. */
212
+ accepted: readonly string[]
213
+ ): Promise<{ product: Record<string, unknown> } | { error: CallToolResult }> {
214
+ const given = [ref.productId, ref.slug, ref.remoteUrl].filter((v) => v !== undefined).length;
215
+ if (given !== 1) {
216
+ return {
217
+ error: toolError(
218
+ 'VALIDATION_FAILED',
219
+ `Name the product exactly once: ${accepted.join(' or ')}. Two names could mean two products.`
220
+ ),
221
+ };
222
+ }
223
+ if (ref.productId) return { product: (await client.getProduct(ref.productId)).data as Record<string, unknown> };
224
+ if (ref.slug) {
225
+ const [product] = (await client.listProducts({ slug: ref.slug, limit: 1 })).data as Record<string, unknown>[];
226
+ if (!product) {
227
+ return {
228
+ error: toolError(
229
+ 'NOT_FOUND',
230
+ `No product you can see has the slug "${ref.slug}". taiga_list_products shows the ones you can.`
231
+ ),
232
+ };
233
+ }
234
+ return { product };
235
+ }
236
+ const remote = parseRemote(ref.remoteUrl!);
237
+ if (!remote) {
238
+ return {
239
+ error: toolError(
240
+ 'INVALID_REMOTE',
241
+ `"${ref.remoteUrl}" is not a remote of the form host/owner/repo. Pass the URL git remote get-url origin prints.`
242
+ ),
243
+ };
244
+ }
245
+ // `truncated` is dropped on purpose: past the cap the answer is AMBIGUOUS anyway.
246
+ const { products } = await linkedProducts(client, remote.repository, remote.host);
247
+ if (products.length === 0) {
248
+ return {
249
+ error: toolError(
250
+ 'NOT_FOUND',
251
+ `No product you can see is linked to ${remote.repository} on ${remote.host}. ` +
252
+ 'Link the repository to its product in Taiga, or find the product with taiga_list_products.'
253
+ ),
254
+ };
255
+ }
256
+ if (products.length > 1) {
257
+ const names = pickFields(products, PRODUCT_SUMMARY_FIELDS)
258
+ .map((p) => `${String(p.name)} (${String(p.id)})`)
259
+ .join(', ');
260
+ return {
261
+ error: toolError(
262
+ 'AMBIGUOUS',
263
+ `${remote.repository} is linked to several products: ${names}. Call again with the productId of the one you mean.`
264
+ ),
265
+ };
266
+ }
267
+ // The list route returns full product rows, the same shape the single read does.
268
+ return { product: products[0]! };
269
+ }
270
+
204
271
  export function registerProductTools(server: McpServer, client: TaigaClient) {
205
272
  if (client.hasCapability('factories:read')) {
206
273
  server.registerTool(
@@ -213,14 +280,14 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
213
280
  "Factory instructions carry the factory's standing guidance: domain context, tech preferences, " +
214
281
  'and the conventions its products follow.',
215
282
  inputSchema: {
216
- limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
283
+ ...pagination,
217
284
  },
218
285
  annotations: READ_ONLY,
219
286
  },
220
287
  (params) =>
221
288
  runTool(async () => {
222
289
  const result = await client.listFactories(params);
223
- return toolResult(result.data);
290
+ return toolResult(page(result.data, result.nextCursor));
224
291
  })
225
292
  );
226
293
  }
@@ -247,14 +314,16 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
247
314
  'Filter by status: draft until the product finishes setup in Taiga, active after that, ' +
248
315
  'archived when retired.'
249
316
  ),
250
- limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
317
+ ...pagination,
251
318
  },
252
319
  annotations: READ_ONLY,
253
320
  },
254
321
  (params) =>
255
322
  runTool(async () => {
256
323
  const result = await client.listProducts(params);
257
- return toolResult(pickFields(result.data as Record<string, unknown>[], PRODUCT_SUMMARY_FIELDS));
324
+ return toolResult(
325
+ page(pickFields(result.data as Record<string, unknown>[], PRODUCT_SUMMARY_FIELDS), result.nextCursor)
326
+ );
258
327
  })
259
328
  );
260
329
 
@@ -269,31 +338,14 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
269
338
  'taiga_list_backlog_items for its work and taiga_list_builds for what the coding agent did.',
270
339
  inputSchema: {
271
340
  productId: productId.optional(),
272
- slug: z
273
- .string()
274
- .max(63)
275
- .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'A slug is lowercase words joined by hyphens')
276
- .optional()
277
- .describe('Product slug, as it appears in Taiga URLs -- an alternative to productId'),
341
+ slug: slug.optional(),
278
342
  },
279
343
  annotations: READ_ONLY,
280
344
  },
281
345
  (params) =>
282
346
  runTool(async () => {
283
- if (params.productId && params.slug) {
284
- return toolError('VALIDATION_FAILED', 'Pass productId or slug, not both: they could name two products.');
285
- }
286
- if (params.productId) return toolResult((await client.getProduct(params.productId)).data);
287
- if (!params.slug) return toolError('VALIDATION_FAILED', 'Pass productId or slug.');
288
- const result = await client.listProducts({ slug: params.slug, limit: 1 });
289
- const [product] = result.data;
290
- if (!product) {
291
- return toolError(
292
- 'NOT_FOUND',
293
- `No product you can see has the slug "${params.slug}". taiga_list_products shows the ones you can.`
294
- );
295
- }
296
- return toolResult(product);
347
+ const resolved = await resolveProductRef(client, params, ['productId', 'slug']);
348
+ return 'error' in resolved ? resolved.error : toolResult(resolved.product);
297
349
  })
298
350
  );
299
351
 
@@ -303,16 +355,12 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
303
355
  title: 'Resolve product from git remote',
304
356
  description:
305
357
  'Find the Taiga product a git checkout belongs to from its remote URL (the output of ' +
306
- '`git remote get-url origin`). Call this first when working inside a repository, then use the ' +
307
- 'product ID with every other tool. Returns every product the repository is linked to (several ' +
358
+ '`git remote get-url origin`). taiga_get_product_context takes the same URL and returns the whole ' +
359
+ 'context at once; use this when you only need the ID. Returns every product the repository is linked to (several ' +
308
360
  `when the organization shares repositories; \`truncated\` is true past ${PAGE_SIZE * RESOLVE_PAGE_LIMIT}); ` +
309
361
  'NOT_FOUND means nobody linked it, or the key cannot see the product.',
310
362
  inputSchema: {
311
- remoteUrl: z
312
- .string()
313
- .min(1)
314
- .max(2048)
315
- .describe('The remote URL: https://github.com/owner/repo.git, git@github.com:owner/repo.git or ssh://...'),
363
+ remoteUrl,
316
364
  },
317
365
  annotations: READ_ONLY,
318
366
  },
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { applySectionEdit, sectionContent, sectionsOf, stripProvenance } from './sections.js';
2
+ import { applySectionEdit, instructionsText, sectionContent, sectionsOf, stripProvenance } from './sections.js';
3
3
 
4
4
  describe('sectionsOf', () => {
5
5
  it('returns the sections map of a section-shaped document', () => {
@@ -57,6 +57,23 @@ describe('applySectionEdit', () => {
57
57
  });
58
58
  });
59
59
 
60
+ describe('instructionsText', () => {
61
+ it('joins every non-empty object section and skips bare strings, as the API extractor does', () => {
62
+ expect(
63
+ instructionsText({
64
+ instructions: { content: ' Use pnpm. ' },
65
+ legacy: 'bare text the agents never receive',
66
+ empty: { content: ' ' },
67
+ deploys: { content: 'Deploy on Tuesdays.' },
68
+ })
69
+ ).toBe('Use pnpm.\n\nDeploy on Tuesdays.');
70
+ });
71
+
72
+ it('is null when nothing is readable', () => {
73
+ expect(instructionsText({ empty: { content: '' }, legacy: 'x' })).toBeNull();
74
+ });
75
+ });
76
+
60
77
  describe('stripProvenance', () => {
61
78
  it('drops the derived-from tree and outdated sources at the top and in every section', () => {
62
79
  const doc = {
@@ -58,3 +58,20 @@ export function stripProvenance(doc: Record<string, unknown>): Record<string, un
58
58
  );
59
59
  return { ...rest, data: { ...rest.data, sections: stripped } };
60
60
  }
61
+
62
+ /**
63
+ * The free-form text of an instructions document, the way the API hands it
64
+ * to the agents (factory-instructions.ts: every non-empty section, in order,
65
+ * joined by a blank line). Null when there is nothing to read.
66
+ */
67
+ export function instructionsText(sections: Sections): string | null {
68
+ const parts: string[] = [];
69
+ for (const section of Object.values(sections)) {
70
+ // Object sections only, as the API's extractor reads them: a bare-string
71
+ // row the agents never receive is not handed to the model here either.
72
+ const content = isRecord(section) ? section.content : undefined;
73
+ if (typeof content === 'string' && content.trim()) parts.push(content.trim());
74
+ }
75
+ const text = parts.join('\n\n').trim();
76
+ return text || null;
77
+ }
@@ -15,3 +15,8 @@ export function pickFields<T extends Record<string, unknown>>(
15
15
  return picked;
16
16
  });
17
17
  }
18
+
19
+ /** The page a list tool returns: the rows the model asked for and where the next page starts. */
20
+ export function page<T>(data: T[], nextCursor: string | null | undefined): { data: T[]; nextCursor: string | null } {
21
+ return { data, nextCursor: nextCursor ?? null };
22
+ }