@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/dist/index.js +306 -86
- package/package.json +1 -1
- package/src/client.test.ts +51 -10
- package/src/client.ts +39 -75
- package/src/index.ts +7 -3
- package/src/server.integration.test.ts +321 -1
- package/src/tools/annotations.ts +10 -0
- package/src/tools/backlogs.ts +78 -6
- package/src/tools/builds.ts +89 -0
- package/src/tools/documents.ts +3 -3
- package/src/tools/index.ts +2 -3
- package/src/tools/products.ts +106 -8
- package/src/tools/remote.test.ts +44 -0
- package/src/tools/remote.ts +107 -0
- package/src/tools/summarize.ts +18 -0
- package/taiga-ai-mcp-server-0.6.0-dev.391.tgz +0 -0
- package/taiga-ai-mcp-server-0.5.0-rc.389.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -95,7 +95,10 @@ var TaigaClient = class {
|
|
|
95
95
|
const params = new URLSearchParams();
|
|
96
96
|
if (opts?.factoryId) params.set("factoryId", opts.factoryId);
|
|
97
97
|
if (opts?.status) params.set("status", opts.status);
|
|
98
|
+
if (opts?.repository) params.set("repository", opts.repository);
|
|
99
|
+
if (opts?.repositoryHost) params.set("repositoryHost", opts.repositoryHost);
|
|
98
100
|
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
101
|
+
if (opts?.cursor) params.set("cursor", opts.cursor);
|
|
99
102
|
const qs = params.toString();
|
|
100
103
|
return this.request("GET", `/products${qs ? `?${qs}` : ""}`);
|
|
101
104
|
}
|
|
@@ -149,63 +152,12 @@ var TaigaClient = class {
|
|
|
149
152
|
async getProductDocument(productId, type) {
|
|
150
153
|
return this.request("GET", `/products/${seg(productId)}/documents/${seg(type)}`);
|
|
151
154
|
}
|
|
152
|
-
/**
|
|
153
|
-
* Fetch the full context for a product in parallel: product metadata,
|
|
154
|
-
* its factory's instructions and the product documents.
|
|
155
|
-
* Returns a structured bundle optimized for coding agent consumption.
|
|
156
|
-
*/
|
|
157
|
-
async getProductContext(productId) {
|
|
158
|
-
const product = await this.request("GET", `/products/${seg(productId)}`);
|
|
159
|
-
const factoryId = product.data.factoryId;
|
|
160
|
-
const [productDocs, factoryDocs] = await Promise.all([this.listProductDocuments(productId, { limit: 20 }).catch(() => ({ data: [] })), factoryId ? this.listFactoryDocuments(factoryId, { limit: 20 }).catch(() => ({ data: [] })) : Promise.resolve({ data: [] })]);
|
|
161
|
-
const keyProductDocTypes = [
|
|
162
|
-
"specification",
|
|
163
|
-
"architecture",
|
|
164
|
-
"technology_decisions"
|
|
165
|
-
];
|
|
166
|
-
const keyFactoryDocTypes = ["factory_instructions"];
|
|
167
|
-
const docFetches = [];
|
|
168
|
-
for (const docType of keyProductDocTypes) if (productDocs.data.some((d) => d.type === docType && !d.isDraft)) docFetches.push(this.getProductDocument(productId, docType).then((r) => ({
|
|
169
|
-
type: docType,
|
|
170
|
-
scope: "product",
|
|
171
|
-
data: r.data
|
|
172
|
-
})).catch(() => ({
|
|
173
|
-
type: docType,
|
|
174
|
-
scope: "product",
|
|
175
|
-
data: null
|
|
176
|
-
})));
|
|
177
|
-
if (factoryId) {
|
|
178
|
-
for (const docType of keyFactoryDocTypes) if (factoryDocs.data.some((d) => d.type === docType && !d.isDraft)) docFetches.push(this.getFactoryDocument(factoryId, docType).then((r) => ({
|
|
179
|
-
type: docType,
|
|
180
|
-
scope: "factory",
|
|
181
|
-
data: r.data
|
|
182
|
-
})).catch(() => ({
|
|
183
|
-
type: docType,
|
|
184
|
-
scope: "factory",
|
|
185
|
-
data: null
|
|
186
|
-
})));
|
|
187
|
-
}
|
|
188
|
-
const fetchedDocs = await Promise.all(docFetches);
|
|
189
|
-
const summarize = (docs) => docs.map((d) => ({
|
|
190
|
-
type: d.type,
|
|
191
|
-
name: d.name,
|
|
192
|
-
version: d.version,
|
|
193
|
-
isDraft: d.isDraft
|
|
194
|
-
}));
|
|
195
|
-
return {
|
|
196
|
-
product: product.data,
|
|
197
|
-
documents: {
|
|
198
|
-
product: Object.fromEntries(fetchedDocs.filter((d) => d.scope === "product" && d.data).map((d) => [d.type, d.data])),
|
|
199
|
-
factory: Object.fromEntries(fetchedDocs.filter((d) => d.scope === "factory" && d.data).map((d) => [d.type, d.data]))
|
|
200
|
-
},
|
|
201
|
-
available_documents: {
|
|
202
|
-
product: summarize(productDocs.data),
|
|
203
|
-
factory: summarize(factoryDocs.data)
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
155
|
async listBacklogItems(productId, opts) {
|
|
208
156
|
const params = new URLSearchParams();
|
|
157
|
+
if (opts?.status) params.set("status", opts.status);
|
|
158
|
+
if (opts?.planningStatus) params.set("planningStatus", opts.planningStatus);
|
|
159
|
+
if (opts?.category) params.set("category", opts.category);
|
|
160
|
+
if (opts?.includeArchived) params.set("includeArchived", "true");
|
|
209
161
|
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
210
162
|
const qs = params.toString();
|
|
211
163
|
return this.request("GET", `/products/${seg(productId)}/backlog${qs ? `?${qs}` : ""}`);
|
|
@@ -213,8 +165,18 @@ var TaigaClient = class {
|
|
|
213
165
|
async getBacklogItem(itemId) {
|
|
214
166
|
return this.request("GET", `/backlog/${seg(itemId)}`);
|
|
215
167
|
}
|
|
216
|
-
async
|
|
217
|
-
|
|
168
|
+
async listBuildRuns(productId, opts) {
|
|
169
|
+
const params = new URLSearchParams();
|
|
170
|
+
if (opts?.backlogItemId) params.set("backlogItemId", opts.backlogItemId);
|
|
171
|
+
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
172
|
+
const qs = params.toString();
|
|
173
|
+
return this.request("GET", `/products/${seg(productId)}/builds${qs ? `?${qs}` : ""}`);
|
|
174
|
+
}
|
|
175
|
+
async getBuildRun(id) {
|
|
176
|
+
return this.request("GET", `/builds/${seg(id)}`);
|
|
177
|
+
}
|
|
178
|
+
async listBuildRunSteps(id) {
|
|
179
|
+
return this.request("GET", `/builds/${seg(id)}/steps`);
|
|
218
180
|
}
|
|
219
181
|
async listFactories(opts) {
|
|
220
182
|
const params = new URLSearchParams();
|
|
@@ -239,6 +201,18 @@ function readConfig() {
|
|
|
239
201
|
};
|
|
240
202
|
}
|
|
241
203
|
//#endregion
|
|
204
|
+
//#region src/tools/annotations.ts
|
|
205
|
+
/**
|
|
206
|
+
* Tool annotations shared by every read tool. Hints for the client, not a
|
|
207
|
+
* control: the API enforces what a key may do.
|
|
208
|
+
*/
|
|
209
|
+
const READ_ONLY = {
|
|
210
|
+
readOnlyHint: true,
|
|
211
|
+
destructiveHint: false,
|
|
212
|
+
idempotentHint: true,
|
|
213
|
+
openWorldHint: false
|
|
214
|
+
};
|
|
215
|
+
//#endregion
|
|
242
216
|
//#region src/tools/errors.ts
|
|
243
217
|
/** A successful tool result: one JSON text block the model reads. */
|
|
244
218
|
function toolResult(value) {
|
|
@@ -300,6 +274,67 @@ async function runTool(handler) {
|
|
|
300
274
|
*/
|
|
301
275
|
const documentType = z.string().regex(/^[a-z][a-z0-9_]{0,63}$/, "A document type is a lowercase identifier such as policy_security");
|
|
302
276
|
//#endregion
|
|
277
|
+
//#region src/tools/remote.ts
|
|
278
|
+
const SCP_LIKE = /^(?:[^@\s/]+@)?([^:\s/]+):([^\s]+)$/;
|
|
279
|
+
function parseRemote(remote) {
|
|
280
|
+
const trimmed = remote.trim();
|
|
281
|
+
if (trimmed === "") return null;
|
|
282
|
+
let host;
|
|
283
|
+
let path;
|
|
284
|
+
let scheme = "https";
|
|
285
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
286
|
+
if (hasDotSegment(trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, ""))) return null;
|
|
287
|
+
let url;
|
|
288
|
+
try {
|
|
289
|
+
url = new URL(trimmed);
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
const protocol = url.protocol.replace(/:$/, "").toLowerCase();
|
|
294
|
+
if (protocol === "http" || protocol === "https") {
|
|
295
|
+
scheme = protocol;
|
|
296
|
+
host = url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
|
297
|
+
} else if (protocol === "ssh" || protocol === "git+ssh" || protocol === "git") host = url.hostname;
|
|
298
|
+
else return null;
|
|
299
|
+
path = url.pathname;
|
|
300
|
+
} else {
|
|
301
|
+
const match = SCP_LIKE.exec(trimmed);
|
|
302
|
+
if (!match) return null;
|
|
303
|
+
host = match[1];
|
|
304
|
+
path = match[2];
|
|
305
|
+
if (host.length < 2) return null;
|
|
306
|
+
}
|
|
307
|
+
host = host.toLowerCase();
|
|
308
|
+
if (host === "") return null;
|
|
309
|
+
const segments = path.replace(/\/+$/, "").replace(/\.git$/i, "").split("/").filter((segment) => segment !== "");
|
|
310
|
+
if (segments.length !== 2) return null;
|
|
311
|
+
if (hasDotSegment(path) || segments.some((segment) => segment.includes(":"))) return null;
|
|
312
|
+
const origin = webOrigin(`${scheme}://${host}`);
|
|
313
|
+
if (origin === null) return null;
|
|
314
|
+
return {
|
|
315
|
+
host: origin,
|
|
316
|
+
repository: `${segments[0]}/${segments[1]}`
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Whether any segment of the path is `.` or `..`, percent-encoded or not. */
|
|
320
|
+
function hasDotSegment(path) {
|
|
321
|
+
return path.split("/").some((segment) => {
|
|
322
|
+
let decoded = segment;
|
|
323
|
+
try {
|
|
324
|
+
decoded = decodeURIComponent(segment);
|
|
325
|
+
} catch {}
|
|
326
|
+
return decoded === "." || decoded === "..";
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
function webOrigin(url) {
|
|
330
|
+
try {
|
|
331
|
+
const origin = new URL(url).origin;
|
|
332
|
+
return origin === "null" ? null : origin;
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
//#endregion
|
|
303
338
|
//#region src/tools/summarize.ts
|
|
304
339
|
/**
|
|
305
340
|
* Strip heavy content from list responses to avoid context window bloat.
|
|
@@ -327,27 +362,78 @@ function summarizeList(items) {
|
|
|
327
362
|
return summary;
|
|
328
363
|
});
|
|
329
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* Keep only the named fields of each item. For lists whose rows carry prose
|
|
367
|
+
* (a backlog item's description, a run's metrics) that `summarizeList`'s
|
|
368
|
+
* fixed field names would not catch.
|
|
369
|
+
*/
|
|
370
|
+
function pickFields(items, fields) {
|
|
371
|
+
return items.map((item) => {
|
|
372
|
+
const picked = {};
|
|
373
|
+
for (const field of fields) if (field in item) picked[field] = item[field];
|
|
374
|
+
return picked;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
330
377
|
//#endregion
|
|
331
378
|
//#region src/tools/products.ts
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
379
|
+
/** What a product row needs to say for the model to pick one and read it in full. */
|
|
380
|
+
const PRODUCT_SUMMARY_FIELDS = [
|
|
381
|
+
"id",
|
|
382
|
+
"name",
|
|
383
|
+
"slug",
|
|
384
|
+
"status",
|
|
385
|
+
"factoryId",
|
|
386
|
+
"kind",
|
|
387
|
+
"discoveryStep"
|
|
388
|
+
];
|
|
389
|
+
/** The API's page ceiling (paginationSchema in apps/api). */
|
|
390
|
+
const PAGE_SIZE = 100;
|
|
391
|
+
/**
|
|
392
|
+
* Pages the resolver follows before it stops and says so. A repository is
|
|
393
|
+
* linked to one product, or a handful where sharing is allowed; a thousand
|
|
394
|
+
* matches is not a resolution the model can act on anyway.
|
|
395
|
+
*/
|
|
396
|
+
const RESOLVE_PAGE_LIMIT = 10;
|
|
397
|
+
/**
|
|
398
|
+
* Every product linked to the repository, page by page, until the API runs
|
|
399
|
+
* out or the cap is hit. A page that fails fails the call, like every other
|
|
400
|
+
* tool: a partial list marked truncated would read as a complete one.
|
|
401
|
+
*/
|
|
402
|
+
async function linkedProducts(client, repository, repositoryHost) {
|
|
403
|
+
const products = [];
|
|
404
|
+
let cursor;
|
|
405
|
+
for (let page = 0; page < RESOLVE_PAGE_LIMIT; page += 1) {
|
|
406
|
+
const result = await client.listProducts({
|
|
407
|
+
repository,
|
|
408
|
+
repositoryHost,
|
|
409
|
+
limit: PAGE_SIZE,
|
|
410
|
+
cursor
|
|
411
|
+
});
|
|
412
|
+
products.push(...result.data);
|
|
413
|
+
if (!result.nextCursor) return {
|
|
414
|
+
products,
|
|
415
|
+
truncated: false
|
|
416
|
+
};
|
|
417
|
+
cursor = result.nextCursor;
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
products,
|
|
421
|
+
truncated: true
|
|
422
|
+
};
|
|
423
|
+
}
|
|
338
424
|
function registerProductTools(server, client) {
|
|
339
425
|
if (client.hasCapability("factories:read")) server.registerTool("taiga_list_factories", {
|
|
340
426
|
title: "List factories",
|
|
341
427
|
description: "List all factories in the organization. A factory is the group that owns a set of products. Use this to find factory IDs for reading factory instructions. Factory instructions carry the factory's standing guidance: domain context, tech preferences, and the conventions its products follow.",
|
|
342
|
-
inputSchema: { limit: z.number().min(1).max(100).optional().describe("Max results (default 25)") },
|
|
343
|
-
annotations: READ_ONLY
|
|
428
|
+
inputSchema: { limit: z.number().int().min(1).max(100).optional().describe("Max results (default 25)") },
|
|
429
|
+
annotations: READ_ONLY
|
|
344
430
|
}, (params) => runTool(async () => {
|
|
345
431
|
return toolResult((await client.listFactories(params)).data);
|
|
346
432
|
}));
|
|
347
433
|
if (client.hasCapability("products:read")) {
|
|
348
434
|
server.registerTool("taiga_list_products", {
|
|
349
435
|
title: "List products",
|
|
350
|
-
description: "List products in the organization. Use this to find
|
|
436
|
+
description: "List products in the organization. Use this to find a product by name; inside a git checkout, taiga_resolve_product finds it from the remote URL instead. Can filter by factory; a status filter also exists but no longer distinguishes anything (see its description).",
|
|
351
437
|
inputSchema: {
|
|
352
438
|
factoryId: z.string().uuid().optional().describe("Filter by factory ID -- get factory IDs from taiga_list_factories"),
|
|
353
439
|
status: z.enum([
|
|
@@ -355,20 +441,45 @@ function registerProductTools(server, client) {
|
|
|
355
441
|
"active",
|
|
356
442
|
"archived"
|
|
357
443
|
]).optional().describe("Filter by status. Taiga no longer surfaces product status in its UI (PRO-543), so every product is currently \"draft\" and filtering by \"active\" or \"archived\" returns nothing. Omit this unless you are working with data set through the API directly."),
|
|
358
|
-
limit: z.number().min(1).max(100).optional().describe("Max results (default 25)")
|
|
444
|
+
limit: z.number().int().min(1).max(100).optional().describe("Max results (default 25)")
|
|
359
445
|
},
|
|
360
|
-
annotations: READ_ONLY
|
|
446
|
+
annotations: READ_ONLY
|
|
361
447
|
}, (params) => runTool(async () => {
|
|
362
|
-
return toolResult((await client.listProducts(params)).data);
|
|
448
|
+
return toolResult(pickFields((await client.listProducts(params)).data, PRODUCT_SUMMARY_FIELDS));
|
|
449
|
+
}));
|
|
450
|
+
server.registerTool("taiga_get_product", {
|
|
451
|
+
title: "Get product",
|
|
452
|
+
description: "Get one product in full: name, slug, factory, what Taiga owns for it (application, infrastructure, deployment) and its settings; the list and resolve tools carry summaries. Use taiga_list_product_documents for its specification and design, taiga_list_backlog_items for its work and taiga_list_builds for what the coding agent did.",
|
|
453
|
+
inputSchema: { productId: z.string().uuid().describe("Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products") },
|
|
454
|
+
annotations: READ_ONLY
|
|
455
|
+
}, (params) => runTool(async () => {
|
|
456
|
+
return toolResult((await client.getProduct(params.productId)).data);
|
|
457
|
+
}));
|
|
458
|
+
server.registerTool("taiga_resolve_product", {
|
|
459
|
+
title: "Resolve product from git remote",
|
|
460
|
+
description: "Find the Taiga product a git checkout belongs to from its remote URL (the output of `git remote get-url origin`). Call this first when working inside a repository, then use the product ID with every other tool. Returns every product the repository is linked to (several when the organization shares repositories; `truncated` is true past 1000); NOT_FOUND means nobody linked it, or the key cannot see the product.",
|
|
461
|
+
inputSchema: { remoteUrl: z.string().min(1).max(2048).describe("The remote URL: https://github.com/owner/repo.git, git@github.com:owner/repo.git or ssh://...") },
|
|
462
|
+
annotations: READ_ONLY
|
|
463
|
+
}, (params) => runTool(async () => {
|
|
464
|
+
const remote = parseRemote(params.remoteUrl);
|
|
465
|
+
if (!remote) return toolError("INVALID_REMOTE", `"${params.remoteUrl}" is not a remote of the form host/owner/repo. Pass the URL git remote get-url origin prints.`);
|
|
466
|
+
const { products, truncated } = await linkedProducts(client, remote.repository, remote.host);
|
|
467
|
+
if (products.length === 0) return toolError("NOT_FOUND", `No product you can see is linked to ${remote.repository} on ${remote.host}. Link the repository to its product in Taiga, or find the product with taiga_list_products.`);
|
|
468
|
+
return toolResult({
|
|
469
|
+
repository: remote.repository,
|
|
470
|
+
host: remote.host,
|
|
471
|
+
products: pickFields(products, PRODUCT_SUMMARY_FIELDS),
|
|
472
|
+
truncated
|
|
473
|
+
});
|
|
363
474
|
}));
|
|
364
475
|
server.registerTool("taiga_list_product_documents", {
|
|
365
476
|
title: "List product documents",
|
|
366
477
|
description: "List documents for a specific product (names and types only). Product documents are generated during intake: specification (requirements), architecture (system design), technology decisions, data flow, privacy assessment, threat model (security risks), risk register, user flow, and service blueprint. Use taiga_get_product_document to read the full content.",
|
|
367
478
|
inputSchema: {
|
|
368
479
|
productId: z.string().uuid().describe("Product ID -- get from taiga_list_products"),
|
|
369
|
-
limit: z.number().min(1).max(100).optional().describe("Max results (default 25)")
|
|
480
|
+
limit: z.number().int().min(1).max(100).optional().describe("Max results (default 25)")
|
|
370
481
|
},
|
|
371
|
-
annotations: READ_ONLY
|
|
482
|
+
annotations: READ_ONLY
|
|
372
483
|
}, (params) => runTool(async () => {
|
|
373
484
|
return toolResult(summarizeList((await client.listProductDocuments(params.productId, { limit: params.limit })).data));
|
|
374
485
|
}));
|
|
@@ -379,7 +490,7 @@ function registerProductTools(server, client) {
|
|
|
379
490
|
productId: z.string().uuid().describe("Product ID -- get from taiga_list_products"),
|
|
380
491
|
type: documentType.describe("Document type -- get available types from taiga_list_product_documents. Common: specification, architecture, threat_model, technology_decisions")
|
|
381
492
|
},
|
|
382
|
-
annotations: READ_ONLY
|
|
493
|
+
annotations: READ_ONLY
|
|
383
494
|
}, (params) => runTool(async () => {
|
|
384
495
|
return toolResult((await client.getProductDocument(params.productId, params.type)).data);
|
|
385
496
|
}));
|
|
@@ -414,12 +525,6 @@ function applySectionEdit(sections, sectionId, content) {
|
|
|
414
525
|
}
|
|
415
526
|
//#endregion
|
|
416
527
|
//#region src/tools/documents.ts
|
|
417
|
-
const READ_ONLY = {
|
|
418
|
-
readOnlyHint: true,
|
|
419
|
-
destructiveHint: false,
|
|
420
|
-
idempotentHint: true,
|
|
421
|
-
openWorldHint: false
|
|
422
|
-
};
|
|
423
528
|
const DRAFT_WRITE = {
|
|
424
529
|
readOnlyHint: false,
|
|
425
530
|
destructiveHint: false,
|
|
@@ -488,7 +593,7 @@ function registerDocumentTools(server, client) {
|
|
|
488
593
|
server.registerTool("taiga_list_documents", {
|
|
489
594
|
title: "List company documents",
|
|
490
595
|
description: "List company-level policy documents (names and types only). Policies define security, data privacy, SDLC, operations, architecture, and compliance rules that apply across all factories and products. Use taiga_get_document with the document type to read the full policy content.",
|
|
491
|
-
inputSchema: { limit: z.number().min(1).max(100).optional().describe("Max results (default 25)") },
|
|
596
|
+
inputSchema: { limit: z.number().int().min(1).max(100).optional().describe("Max results (default 25)") },
|
|
492
597
|
annotations: READ_ONLY
|
|
493
598
|
}, (params) => runTool(async () => {
|
|
494
599
|
return toolResult(summarizeList((await client.listTenantDocuments(params)).data));
|
|
@@ -532,7 +637,7 @@ function registerDocumentTools(server, client) {
|
|
|
532
637
|
description: "List documents for a specific factory (names and types only). A factory has one document: its instructions, the factory's free-form standing guidance (domain context, tech preferences, conventions) that applies to every product it owns. Use taiga_get_factory_document to read the full content.",
|
|
533
638
|
inputSchema: {
|
|
534
639
|
factoryId: z.string().uuid().describe("Factory ID -- get from taiga_list_factories"),
|
|
535
|
-
limit: z.number().min(1).max(100).optional().describe("Max results (default 25)")
|
|
640
|
+
limit: z.number().int().min(1).max(100).optional().describe("Max results (default 25)")
|
|
536
641
|
},
|
|
537
642
|
annotations: READ_ONLY
|
|
538
643
|
}, (params) => runTool(async () => {
|
|
@@ -582,18 +687,129 @@ function registerDocumentTools(server, client) {
|
|
|
582
687
|
}
|
|
583
688
|
}
|
|
584
689
|
//#endregion
|
|
690
|
+
//#region src/tools/backlogs.ts
|
|
691
|
+
/** What a list row needs to say for the model to pick an item to read in full. */
|
|
692
|
+
const BACKLOG_SUMMARY_FIELDS = [
|
|
693
|
+
"id",
|
|
694
|
+
"title",
|
|
695
|
+
"category",
|
|
696
|
+
"status",
|
|
697
|
+
"state",
|
|
698
|
+
"planningStatus",
|
|
699
|
+
"priority",
|
|
700
|
+
"estimate",
|
|
701
|
+
"origin",
|
|
702
|
+
"needsHumanCause",
|
|
703
|
+
"archivedAt",
|
|
704
|
+
"createdAt",
|
|
705
|
+
"updatedAt"
|
|
706
|
+
];
|
|
707
|
+
function registerBacklogTools(server, client) {
|
|
708
|
+
if (!client.hasCapability("backlog:read")) return;
|
|
709
|
+
server.registerTool("taiga_list_backlog_items", {
|
|
710
|
+
title: "List backlog items",
|
|
711
|
+
description: "List a product's backlog items (initiatives): titles, category, status and where each one is on the line (backlog, todo, queue, plan, build, done). Use this to find the item you are working on, then read its acceptance criteria and technical context with taiga_get_backlog_item. Archived items are left out unless asked for.",
|
|
712
|
+
inputSchema: {
|
|
713
|
+
productId: z.string().uuid().describe("Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products"),
|
|
714
|
+
status: z.enum([
|
|
715
|
+
"backlog",
|
|
716
|
+
"in_progress",
|
|
717
|
+
"done",
|
|
718
|
+
"canceled"
|
|
719
|
+
]).optional().describe("Only items with this status; in_progress covers everything between backlog and done"),
|
|
720
|
+
planningStatus: z.enum([
|
|
721
|
+
"unplanned",
|
|
722
|
+
"planning",
|
|
723
|
+
"planned",
|
|
724
|
+
"failed"
|
|
725
|
+
]).optional().describe("Only items whose plan is in this state"),
|
|
726
|
+
category: z.enum([
|
|
727
|
+
"feature",
|
|
728
|
+
"bug",
|
|
729
|
+
"tech_debt",
|
|
730
|
+
"improvement"
|
|
731
|
+
]).optional().describe("Only this category"),
|
|
732
|
+
includeArchived: z.boolean().optional().describe("Include archived items (default false)"),
|
|
733
|
+
limit: z.number().int().min(1).max(100).optional().describe("Max results (default 25)")
|
|
734
|
+
},
|
|
735
|
+
annotations: READ_ONLY
|
|
736
|
+
}, (params) => runTool(async () => {
|
|
737
|
+
return toolResult(pickFields((await client.listBacklogItems(params.productId, params)).data, BACKLOG_SUMMARY_FIELDS));
|
|
738
|
+
}));
|
|
739
|
+
server.registerTool("taiga_get_backlog_item", {
|
|
740
|
+
title: "Get backlog item",
|
|
741
|
+
description: "Get one backlog item in full: description, acceptance criteria (and which are met), technical context (patterns, constraints, references), dependencies and source documents. Read this before implementing an item, and read its runs with taiga_list_builds to see what the coding agent already did.",
|
|
742
|
+
inputSchema: { itemId: z.string().uuid().describe("Backlog item ID -- get from taiga_list_backlog_items") },
|
|
743
|
+
annotations: READ_ONLY
|
|
744
|
+
}, (params) => runTool(async () => {
|
|
745
|
+
return toolResult((await client.getBacklogItem(params.itemId)).data);
|
|
746
|
+
}));
|
|
747
|
+
}
|
|
748
|
+
//#endregion
|
|
749
|
+
//#region src/tools/builds.ts
|
|
750
|
+
/** What a list row needs to say for the model to pick a run to read in full. */
|
|
751
|
+
const BUILD_SUMMARY_FIELDS = [
|
|
752
|
+
"id",
|
|
753
|
+
"runNumber",
|
|
754
|
+
"backlogItemId",
|
|
755
|
+
"targetTitle",
|
|
756
|
+
"planId",
|
|
757
|
+
"status",
|
|
758
|
+
"failedReason",
|
|
759
|
+
"runtime",
|
|
760
|
+
"repositoryFullName",
|
|
761
|
+
"branch",
|
|
762
|
+
"baseBranch",
|
|
763
|
+
"completedSteps",
|
|
764
|
+
"totalSteps",
|
|
765
|
+
"prNumber",
|
|
766
|
+
"prUrl",
|
|
767
|
+
"prState",
|
|
768
|
+
"prDraft",
|
|
769
|
+
"prMerged",
|
|
770
|
+
"createdAt",
|
|
771
|
+
"startedAt",
|
|
772
|
+
"completedAt"
|
|
773
|
+
];
|
|
774
|
+
function registerBuildTools(server, client) {
|
|
775
|
+
if (!client.hasCapability("products:read")) return;
|
|
776
|
+
server.registerTool("taiga_list_builds", {
|
|
777
|
+
title: "List build runs",
|
|
778
|
+
description: "List the coding agent's runs for a product, newest first: status, branch, pull request and step progress. Use this to see what Taiga already built or is building, then read one run with taiga_get_build. Filter by backlog item to follow a single initiative.",
|
|
779
|
+
inputSchema: {
|
|
780
|
+
productId: z.string().uuid().describe("Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products"),
|
|
781
|
+
backlogItemId: z.string().uuid().optional().describe("Only this backlog item's runs -- get from taiga_list_backlog_items"),
|
|
782
|
+
limit: z.number().int().min(1).max(100).optional().describe("Max results (default 25)")
|
|
783
|
+
},
|
|
784
|
+
annotations: READ_ONLY
|
|
785
|
+
}, (params) => runTool(async () => {
|
|
786
|
+
return toolResult(pickFields((await client.listBuildRuns(params.productId, params)).data, BUILD_SUMMARY_FIELDS));
|
|
787
|
+
}));
|
|
788
|
+
server.registerTool("taiga_get_build", {
|
|
789
|
+
title: "Get build run",
|
|
790
|
+
description: "Get one run in full with its steps: what each step was meant to change, what it did change (commit, files), its status and any error. Use this to pick up where the coding agent stopped, or to understand the pull request it opened.",
|
|
791
|
+
inputSchema: { buildId: z.string().uuid().describe("Build run ID -- get from taiga_list_builds") },
|
|
792
|
+
annotations: READ_ONLY
|
|
793
|
+
}, (params) => runTool(async () => {
|
|
794
|
+
const [run, steps] = await Promise.all([client.getBuildRun(params.buildId), client.listBuildRunSteps(params.buildId)]);
|
|
795
|
+
return toolResult({
|
|
796
|
+
...run.data,
|
|
797
|
+
steps: steps.data
|
|
798
|
+
});
|
|
799
|
+
}));
|
|
800
|
+
}
|
|
801
|
+
//#endregion
|
|
585
802
|
//#region src/tools/index.ts
|
|
586
803
|
/**
|
|
587
804
|
* Register all MCP tools based on the API key's effective capabilities.
|
|
588
805
|
* Only tools matching the key's scopes are registered -- the AI assistant
|
|
589
806
|
* never sees tools it can't use.
|
|
590
|
-
*
|
|
591
|
-
* Beta scope: Knowledge base access (policies and factory instructions).
|
|
592
|
-
* Post-beta: Products, backlog items, environments, composite tools.
|
|
593
807
|
*/
|
|
594
808
|
function registerAllTools(server, client) {
|
|
595
809
|
registerProductTools(server, client);
|
|
596
810
|
registerDocumentTools(server, client);
|
|
811
|
+
registerBacklogTools(server, client);
|
|
812
|
+
registerBuildTools(server, client);
|
|
597
813
|
}
|
|
598
814
|
//#endregion
|
|
599
815
|
//#region src/index.ts
|
|
@@ -633,9 +849,13 @@ async function main() {
|
|
|
633
849
|
"Specification (what to build), architecture (how it's designed), threat model (security risks),",
|
|
634
850
|
"technology decisions, and data flow. Read these to understand the specific product.",
|
|
635
851
|
"",
|
|
636
|
-
"
|
|
637
|
-
"
|
|
638
|
-
"
|
|
852
|
+
"**Backlog and runs** (taiga_list_backlog_items / taiga_get_backlog_item / taiga_list_builds / taiga_get_build):",
|
|
853
|
+
"The product's initiatives with their acceptance criteria, and what Taiga's coding agent already built",
|
|
854
|
+
"for them: runs, steps, commits and pull requests.",
|
|
855
|
+
"",
|
|
856
|
+
"Workflow: Inside a git checkout, start with taiga_resolve_product on the remote URL to find the product.",
|
|
857
|
+
"Otherwise use taiga_list_factories and taiga_list_products. Then read the factory instructions,",
|
|
858
|
+
"the product's specification and architecture, and the backlog item you are working on.",
|
|
639
859
|
"",
|
|
640
860
|
"When you notice a policy or factory instructions are outdated, fix the section with",
|
|
641
861
|
"taiga_update_document or taiga_update_factory_document. That saves a draft nobody follows yet;",
|
package/package.json
CHANGED
package/src/client.test.ts
CHANGED
|
@@ -144,14 +144,6 @@ describe('TaigaClient', () => {
|
|
|
144
144
|
expect(url).toContain('/v1/products/proj-1/backlog');
|
|
145
145
|
expect(url).toContain('limit=20');
|
|
146
146
|
});
|
|
147
|
-
|
|
148
|
-
it('should nest environments under the product path', async () => {
|
|
149
|
-
const spy = stubFetch(200, { data: [] });
|
|
150
|
-
|
|
151
|
-
await makeClient().listEnvironments('proj-1');
|
|
152
|
-
|
|
153
|
-
expect(spy.mock.calls[0]![0]).toBe('https://api.test.com/v1/products/proj-1/environments');
|
|
154
|
-
});
|
|
155
147
|
});
|
|
156
148
|
|
|
157
149
|
// -------------------------------------------------------------------------
|
|
@@ -244,7 +236,7 @@ describe('TaigaClient', () => {
|
|
|
244
236
|
await client.getFactoryDocument('f/1', 'a b');
|
|
245
237
|
await client.getProduct('p/1');
|
|
246
238
|
await client.getBacklogItem('b/1');
|
|
247
|
-
await client.
|
|
239
|
+
await client.listBacklogItems('e/1');
|
|
248
240
|
await client.getProductDocument('p/1', 'spec/x');
|
|
249
241
|
|
|
250
242
|
const paths = spy.mock.calls.map((c) => new URL(c[0] as string).pathname);
|
|
@@ -253,7 +245,7 @@ describe('TaigaClient', () => {
|
|
|
253
245
|
'/v1/factories/f%2F1/documents/a%20b',
|
|
254
246
|
'/v1/products/p%2F1',
|
|
255
247
|
'/v1/backlog/b%2F1',
|
|
256
|
-
'/v1/products/e%2F1/
|
|
248
|
+
'/v1/products/e%2F1/backlog',
|
|
257
249
|
'/v1/products/p%2F1/documents/spec%2Fx',
|
|
258
250
|
]);
|
|
259
251
|
});
|
|
@@ -297,6 +289,55 @@ describe('TaigaClient', () => {
|
|
|
297
289
|
});
|
|
298
290
|
});
|
|
299
291
|
|
|
292
|
+
it('should send the backlog filters the route reads, with includeArchived as the literal true', async () => {
|
|
293
|
+
const spy = stubFetch(200, { data: [], nextCursor: null });
|
|
294
|
+
|
|
295
|
+
await makeClient().listBacklogItems('p1', { status: 'done', planningStatus: 'planned', includeArchived: true });
|
|
296
|
+
|
|
297
|
+
const url = new URL(spy.mock.calls[0]![0] as string);
|
|
298
|
+
expect(url.pathname).toBe('/v1/products/p1/backlog');
|
|
299
|
+
expect(url.searchParams.get('status')).toBe('done');
|
|
300
|
+
expect(url.searchParams.get('planningStatus')).toBe('planned');
|
|
301
|
+
expect(url.searchParams.get('includeArchived')).toBe('true');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should leave includeArchived out when false, since the route treats any value but true as false', async () => {
|
|
305
|
+
const spy = stubFetch(200, { data: [], nextCursor: null });
|
|
306
|
+
|
|
307
|
+
await makeClient().listBacklogItems('p1', { includeArchived: false });
|
|
308
|
+
|
|
309
|
+
expect(spy.mock.calls[0]![0]).toBe('https://api.test.com/v1/products/p1/backlog');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should GET runs under the product and a run with its steps by id', async () => {
|
|
313
|
+
const spy = stubFetch(200, { data: [] });
|
|
314
|
+
const client = makeClient();
|
|
315
|
+
|
|
316
|
+
await client.listBuildRuns('p1', { backlogItemId: 'i1', limit: 5 });
|
|
317
|
+
await client.getBuildRun('b/1');
|
|
318
|
+
await client.listBuildRunSteps('b/1');
|
|
319
|
+
|
|
320
|
+
const paths = spy.mock.calls.map((c) => {
|
|
321
|
+
const url = new URL(c[0] as string);
|
|
322
|
+
return url.pathname + url.search;
|
|
323
|
+
});
|
|
324
|
+
expect(paths).toEqual([
|
|
325
|
+
'/v1/products/p1/builds?backlogItemId=i1&limit=5',
|
|
326
|
+
'/v1/builds/b%2F1',
|
|
327
|
+
'/v1/builds/b%2F1/steps',
|
|
328
|
+
]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should send the repository filter and host when resolving a product', async () => {
|
|
332
|
+
const spy = stubFetch(200, { data: [], nextCursor: null });
|
|
333
|
+
|
|
334
|
+
await makeClient().listProducts({ repository: 'acme/web', repositoryHost: 'https://github.com' });
|
|
335
|
+
|
|
336
|
+
const url = new URL(spy.mock.calls[0]![0] as string);
|
|
337
|
+
expect(url.searchParams.get('repository')).toBe('acme/web');
|
|
338
|
+
expect(url.searchParams.get('repositoryHost')).toBe('https://github.com');
|
|
339
|
+
});
|
|
340
|
+
|
|
300
341
|
it('should GET a single backlog item by id (flat path)', async () => {
|
|
301
342
|
const spy = stubFetch(200, { data: { id: 'item-1' } });
|
|
302
343
|
|