@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 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 listEnvironments(productId) {
217
- return this.request("GET", `/products/${seg(productId)}/environments`);
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
- const READ_ONLY$1 = {
333
- readOnlyHint: true,
334
- destructiveHint: false,
335
- idempotentHint: true,
336
- openWorldHint: false
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$1
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 the product you are working on so you can read its specification, architecture, and threat model. Can filter by factory; a status filter also exists but no longer distinguishes anything (see its description).",
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$1
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$1
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$1
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
- "Workflow: Start with taiga_list_factories to find your factory, then read its instructions.",
637
- "For product-specific work, use taiga_list_products to find the product,",
638
- "then read its specification and architecture.",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.5.0-rc.389",
3
+ "version": "0.6.0-dev.391",
4
4
  "description": "MCP server for Taiga -- gives AI assistants access to documents and products",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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.listEnvironments('e/1');
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/environments',
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