@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/tools/products.ts
CHANGED
|
@@ -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
|
|
68
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
284
|
-
|
|
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`).
|
|
307
|
-
'
|
|
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
|
|
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 = {
|
package/src/tools/sections.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/summarize.ts
CHANGED
|
@@ -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
|
+
}
|
|
Binary file
|
|
Binary file
|