@taiga-ai/mcp-server 0.21.0-dev.455 → 0.22.0-dev.457

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
@@ -584,7 +584,7 @@ const OPERATE_SETTING = {
584
584
  * "Invalid UUID" alone sends a model that passed a name to guess again.
585
585
  */
586
586
  function idInput(what, from) {
587
- return z.string().uuid({ message: `A ${what} ID is a UUID, not a name: ${from}` }).describe(`${what[0].toUpperCase()}${what.slice(1)} ID -- ${from}`);
587
+ return z.string().uuid({ message: `${/^[aeiou]/i.test(what) ? "An" : "A"} ${what} ID is a UUID, not a name: ${from}` }).describe(`${what[0].toUpperCase()}${what.slice(1)} ID -- ${from}`);
588
588
  }
589
589
  const productId = idInput("product", "taiga_list_products (query finds one by name), or taiga_get_product with a git remote URL, gives it");
590
590
  /** The page inputs every list tool takes; `nextCursor` of one page is the `cursor` of the next. */
@@ -2379,7 +2379,7 @@ function inProductWords(value) {
2379
2379
  }
2380
2380
  /** The API's 404 sentence in the product's words: "Backlog item not found" is an initiative not found. */
2381
2381
  function inProductWording(said) {
2382
- return said.replace(/^Backlog item not found/i, "Initiative not found").replace(/^Build run step not found/i, "Run step not found").replace(/^Build run not found/i, "Run not found").replace(/^Custom document not found/i, "Knowledge file not found");
2382
+ return said.replace(/^Backlog item not found/i, "Initiative not found").replace(/^Build run step not found/i, "Run step not found").replace(/^Build run not found/i, "Run not found").replace(/^Custom document not found/i, "Knowledge file not found").replace(/^This product already has a repository\. Change it instead of linking another\./i, "This product already has a repository. Pass replacesRepositoryId to swap it in one step, or taiga_unlink_repository first (taiga_list_product_repositories gives its ID).");
2383
2383
  }
2384
2384
  /** A successful tool result: one JSON text block the model reads. */
2385
2385
  function toolResult(value) {
@@ -2460,8 +2460,8 @@ async function runTool(handler) {
2460
2460
  } catch (error) {
2461
2461
  if (error instanceof TaigaApiError) {
2462
2462
  const code = error.code ?? CODE_BY_STATUS[error.status] ?? "API_ERROR";
2463
- const said = error.apiMessage ?? `Taiga API answered ${error.status}.`;
2464
- return toolError(code, error.status === 404 ? withNotFoundHint(inProductWording(said)) : said, error.details);
2463
+ const worded = inProductWording(error.apiMessage ?? `Taiga API answered ${error.status}.`);
2464
+ return toolError(code, error.status === 404 ? withNotFoundHint(worded) : worded, error.details);
2465
2465
  }
2466
2466
  if (isNetworkFailure(error)) return toolError("NETWORK_ERROR", `Could not reach the Taiga API: ${networkFailureDetail(error)}. Check TAIGA_API_URL.`);
2467
2467
  throw error;
@@ -3088,7 +3088,7 @@ async function resolveProductRef(client, ref, accepted, usage) {
3088
3088
  return { product: (await client.getProduct(ref.productId)).data };
3089
3089
  } catch (error) {
3090
3090
  if (!isMissOrRefusal(error)) throw error;
3091
- return refusal(apiFailureCode(error), `No product with that ID the key can see (${apiFailureText(error)}); taiga_list_products shows the ones it can.`);
3091
+ return refusal(apiFailureCode(error), error instanceof TaigaApiError && error.status === 404 ? "No product with that ID the key can see; taiga_list_products shows the ones it can." : `No product with that ID the key can see (${apiFailureText(error)}); taiga_list_products shows the ones it can.`);
3092
3092
  }
3093
3093
  if (ref.slug) {
3094
3094
  const [product] = (await client.listProducts({
@@ -3758,6 +3758,33 @@ function withInitiativeState(row, states) {
3758
3758
  outcome: `Its initiative was ${state}, so nobody is waiting on this run.${open}`
3759
3759
  };
3760
3760
  }
3761
+ /** A step that changed only prose: no code of its own could have broken a test. */
3762
+ const PROSE_ONLY = /(^|\/)(README|CHANGELOG|LICENSE|NOTICE|AUTHORS)$|\.(md|mdx|txt|rst|adoc)$/i;
3763
+ /**
3764
+ * What a check that failed after every step means (PRO-2800). Two reviews read
3765
+ * seven steps of "tests: failed" as seven breakages, while the headlines said
3766
+ * each step's own tests passed: a check that fails from the first step on,
3767
+ * docs-only steps included, is one the repository or the check environment
3768
+ * carries throughout. The check records no reason, so this says what the
3769
+ * pattern shows and no more.
3770
+ */
3771
+ function verificationNote(steps) {
3772
+ const checked = steps.filter((step) => Array.isArray(step["verification"]) && step["verification"].length > 0);
3773
+ if (checked.length < 2) return null;
3774
+ const failedOn = (step) => step["verification"].filter((check) => check.status === "failed").map((check) => String(check.check));
3775
+ const always = failedOn(checked[0]).filter((check) => checked.every((step) => failedOn(step).includes(check)));
3776
+ if (always.length === 0) return null;
3777
+ const prose = checked.find((step) => {
3778
+ const files = step["filesChanged"];
3779
+ return Array.isArray(files) && files.length > 0 && files.every((file) => PROSE_ONLY.test(String(file)));
3780
+ });
3781
+ if (!prose && checked.length < 3) return null;
3782
+ const reasons = [...new Set(checked.flatMap((step) => step["verification"].filter((check) => check.status === "failed" && always.includes(String(check.check))).map((check) => check.reason).filter((reason) => typeof reason === "string" && reason.length > 0)))];
3783
+ const first = checked[0]["stepOrder"];
3784
+ const last = checked[checked.length - 1]["stepOrder"];
3785
+ const proseSaid = prose ? `, including step ${String(prose["stepOrder"])}, which changed only documentation` : "";
3786
+ return `Taiga's ${always.join(" and ")} check failed after every checked step (${String(first)} to ${String(last)})${proseSaid}. A check failing from the first step on is usually one the repository or the check environment fails throughout, not one each step broke. ` + (reasons.length > 0 ? `Its reason${reasons.length === 1 ? "" : "s"}: ${reasons.slice(0, 3).join("; ")}.` : "It records no reason; each step's headline says what its own run found.");
3787
+ }
3761
3788
  /** readableRun for a list row. */
3762
3789
  const readableRow = (run) => readableRun(run, { row: true });
3763
3790
  /** How many products the org-wide list names; past this a row keeps its productId only. */
@@ -3860,14 +3887,18 @@ function registerBuildTools(server, client) {
3860
3887
  const stop = where ? { stoppedIn: where } : {};
3861
3888
  const states = await initiativeStates(client, [runRow]);
3862
3889
  const readable = withInitiativeState(readableRun(runRow), states);
3890
+ const note = verificationNote(rows);
3891
+ const verification = note ? { verificationNote: note } : {};
3863
3892
  if (params.full) return toolResult({
3864
3893
  ...readable,
3865
3894
  ...stop,
3895
+ ...verification,
3866
3896
  steps: steps.data
3867
3897
  });
3868
3898
  return toolResult({
3869
3899
  ...readable,
3870
3900
  ...stop,
3901
+ ...verification,
3871
3902
  steps: pickFields(rows, STEP_SUMMARY_FIELDS).map((step, index) => ({
3872
3903
  ...step,
3873
3904
  headline: summaryHeadline(rows[index].summary)
@@ -4272,16 +4303,18 @@ function registerBacklogTools(server, client) {
4272
4303
  function registerBacklogReads(server, client) {
4273
4304
  server.registerTool("taiga_list_initiatives", {
4274
4305
  title: "List initiatives",
4275
- description: "List a product's initiatives: title, category, status, place on the line (backlog, todo, queue, plan, build, done), situation (where it stands and what moves it on) and criteria (acceptance criteria met, confirmed with evidence or checked off by a person; null before planning, so a done initiative can show fewer met than it has). query finds one by title; taiga_get_initiative reads it in full. Archived ones are left out unless asked for.",
4306
+ description: "List a product's open initiatives (not done or canceled; status: 'all' for every one): title, category, status, place on the line (backlog, todo, queue, plan, build, done), situation (where it stands and what moves it on) and criteria (acceptance criteria met, confirmed with evidence or checked off by a person; null before planning, so a done initiative can show fewer met than it has). query finds one by title, state one board column; taiga_get_initiative reads it in full. Archived ones are left out unless asked for.",
4276
4307
  inputSchema: z.object({
4277
4308
  productId,
4278
4309
  query: z.string().trim().min(1).max(200).optional().describe("Part of the title, any case: only the items that contain it"),
4279
4310
  status: z.enum([
4311
+ "open",
4312
+ "all",
4280
4313
  "backlog",
4281
4314
  "in_progress",
4282
4315
  "done",
4283
4316
  "canceled"
4284
- ]).optional().describe("Only items with this status; in_progress covers everything between backlog and done"),
4317
+ ]).optional().describe("open (default without state): not done or canceled; all: every one; in_progress covers everything between backlog and done"),
4285
4318
  planningStatus: z.enum([
4286
4319
  "unplanned",
4287
4320
  "planning",
@@ -4300,32 +4333,25 @@ function registerBacklogReads(server, client) {
4300
4333
  }),
4301
4334
  annotations: READ_ONLY
4302
4335
  }, (params) => runTool(async () => {
4303
- const { query, state, ...filters } = params;
4304
- if (state !== void 0) {
4305
- if (params.cursor !== void 0) return toolError("VALIDATION_ERROR", "A board-column filter answers one page; narrow it with status or query.");
4306
- const found = await searchPages((cursor) => client.listBacklogItems(params.productId, {
4307
- ...filters,
4308
- limit: 100,
4309
- cursor
4310
- }), query ?? "", (row) => [row.title], params.limit ?? 25, (row) => row.state === state);
4311
- return toolResult({
4312
- ...page(situatedRows(found.rows), null),
4313
- ...found.more ? { more: "More items may be in that column: narrow it with status or query, or raise the limit." } : {}
4314
- });
4315
- }
4316
- if (query === void 0) {
4317
- const result = await client.listBacklogItems(params.productId, filters);
4336
+ const { query, state, status = state === void 0 ? "open" : "all", ...filters } = params;
4337
+ const apiFilters = {
4338
+ ...filters,
4339
+ status: status === "open" || status === "all" ? void 0 : status
4340
+ };
4341
+ const open = status === "open";
4342
+ if (query === void 0 && state === void 0 && !open) {
4343
+ const result = await client.listBacklogItems(params.productId, apiFilters);
4318
4344
  return toolResult(page(situatedRows(result.data), result.nextCursor));
4319
4345
  }
4320
- if (params.cursor !== void 0) return toolError("VALIDATION_ERROR", SEARCH_WITH_CURSOR);
4346
+ if (params.cursor !== void 0) return toolError("VALIDATION_ERROR", "Open items, a board column or a title search answer one page; narrow them, or pass status: 'all' (or one status) to page through every item.");
4321
4347
  const found = await searchPages((cursor) => client.listBacklogItems(params.productId, {
4322
- ...filters,
4348
+ ...apiFilters,
4323
4349
  limit: 100,
4324
4350
  cursor
4325
- }), query, (row) => [row.title], params.limit ?? 25);
4351
+ }), query ?? "", (row) => [row.title], params.limit ?? 25, (row) => (state === void 0 || row.state === state) && (!open || !["done", "canceled"].includes(row.status)));
4326
4352
  return toolResult({
4327
4353
  ...page(situatedRows(found.rows), null),
4328
- ...found.more ? { more: SEARCH_NOTE } : {}
4354
+ ...found.more ? { more: "There may be more than the limit carries: narrow it with state or query, or list one status (backlog, in_progress, done or canceled) without them, which pages." } : {}
4329
4355
  });
4330
4356
  }));
4331
4357
  server.registerTool("taiga_get_initiative", {
@@ -4564,6 +4590,44 @@ const FINDING_ROW_FIELDS = [
4564
4590
  "ignoreReason",
4565
4591
  "ignoreExpiresAt"
4566
4592
  ];
4593
+ /**
4594
+ * Whether taiga_fix_finding would take a finding, said on the row (PRO-2800): three reviews found a dependency
4595
+ * marked remediableBy coding_agent with no fixed version, which the API refuses to fix. The API's own refusals,
4596
+ * read off the row; remediableBy stays as the sweep recorded it.
4597
+ */
4598
+ function withFixability(row) {
4599
+ if ((row["kind"] === "vulnerable_dependency" || row["kind"] === "outdated_dependency") && !row["recommendedVersion"] && row["remediableBy"] === "coding_agent") return {
4600
+ ...row,
4601
+ fixable: false,
4602
+ fixNote: "No fixed version is known yet: taiga_fix_finding refuses it until one is published."
4603
+ };
4604
+ return row;
4605
+ }
4606
+ /** The parts of an advisory record an agent reads; the rest (every affected range, every reference) is full: true. */
4607
+ const ADVISORY_KEPT = [
4608
+ "id",
4609
+ "aliases",
4610
+ "summary",
4611
+ "details",
4612
+ "severity",
4613
+ "published"
4614
+ ];
4615
+ const ADVISORY_TEXT_LIMIT = 1500;
4616
+ function leanAdvisory(data) {
4617
+ if (typeof data !== "object" || data === null || !("affected" in data || "references" in data)) return data;
4618
+ const record = data;
4619
+ const kept = Object.fromEntries(ADVISORY_KEPT.filter((key) => key in record).map((key) => [key, record[key]]));
4620
+ const details = kept["details"];
4621
+ if (typeof details === "string" && details.length > ADVISORY_TEXT_LIMIT) kept["details"] = `${details.slice(0, ADVISORY_TEXT_LIMIT)}... [cut at ${ADVISORY_TEXT_LIMIT} of ${details.length} characters; full: true reads it whole]`;
4622
+ const references = Array.isArray(record["references"]) ? record["references"] : [];
4623
+ return {
4624
+ ...kept,
4625
+ ...references.length > 0 ? {
4626
+ references: references.slice(0, 3),
4627
+ ...references.length > 3 ? { referencesNote: `3 of ${references.length}; full: true has every one.` } : {}
4628
+ } : {}
4629
+ };
4630
+ }
4567
4631
  /** A sweep as the record of when the product was last checked, and whether that check held. */
4568
4632
  const SWEEP_FIELDS = [
4569
4633
  "id",
@@ -4662,17 +4726,24 @@ function registerMaintenanceTools(server, client) {
4662
4726
  sweep: null,
4663
4727
  sweepNote: "The sweep overview could not be read: an empty list here is not an all-clear."
4664
4728
  } : sweep ? { sweep } : {},
4665
- ...page(pickFields(result.data, FINDING_ROW_FIELDS), result.nextCursor),
4729
+ ...page(pickFields(result.data, FINDING_ROW_FIELDS).map(withFixability), result.nextCursor),
4666
4730
  ...typeof result.totalCount === "number" ? { totalCount: result.totalCount } : {}
4667
4731
  });
4668
4732
  }));
4669
4733
  server.registerTool("taiga_get_finding", {
4670
4734
  title: "Get a maintenance finding",
4671
- description: "Read one maintenance finding in full: exploit likelihood (EPSS, known-exploited list), advisory and fixed version, code location, breached policy controls, why it ranks where it does, history (reintroduced, previously resolved) and the remediation under way. A secret's value is never stored, only the rule and location.",
4672
- inputSchema: z.object({ findingId }),
4735
+ description: "Read one maintenance finding: exploit likelihood (EPSS, known-exploited list), the advisory summarized (full: true for its whole record: every affected range and reference) and fixed version, code location, breached policy controls, why it ranks where it does, history (reintroduced, previously resolved) and the remediation under way. fixable: false with fixNote says taiga_fix_finding would refuse it. A secret's value is never stored, only the rule and location.",
4736
+ inputSchema: z.object({
4737
+ findingId,
4738
+ full: z.boolean().optional().describe("Carry the advisory's whole record: every affected range and reference (default false)")
4739
+ }),
4673
4740
  annotations: READ_ONLY
4674
4741
  }, (params) => runTool(async () => {
4675
- const { runId, ...finding } = (await client.getFinding(params.findingId)).data;
4742
+ const { runId, ...raw } = (await client.getFinding(params.findingId)).data;
4743
+ const finding = withFixability(params.full ? raw : {
4744
+ ...raw,
4745
+ data: leanAdvisory(raw["data"])
4746
+ });
4676
4747
  return toolResult(runId === void 0 ? finding : {
4677
4748
  sweepId: runId,
4678
4749
  ...finding
@@ -5011,7 +5082,7 @@ async function documentsPart(client, product, depth) {
5011
5082
  notes
5012
5083
  };
5013
5084
  }
5014
- /** Every published policy as its section ids, so the model knows which one to read with taiga_get_document. */
5085
+ /** Every published policy with its section count, so the model knows which one to read with taiga_get_document. */
5015
5086
  async function policiesPart(client) {
5016
5087
  return (await client.listTenantDocuments({ limit: 100 })).data.filter((row) => POLICY_TYPES.includes(String(row["type"])) && row["isDraft"] === false).map((row) => ({
5017
5088
  type: row["type"],
@@ -5183,7 +5254,7 @@ function registerContextTools(server, client) {
5183
5254
  if (!client.hasCapability("products:read")) return;
5184
5255
  server.registerTool("taiga_get_product_context", {
5185
5256
  title: "Get product context",
5186
- description: `Read where a product stands in one call; the first read for any product question. Name it by ID, slug or git remote URL. Returns product (in setup, readiness and next move), factory with its instructions, specification and architecture outlines (section ids, sizes), policies with section counts, open initiatives (taiga_list_initiatives has the done and canceled), repositories, the latest ten runs, and counts for Action Required, open findings and policy violations. A part that failed is under unavailable, one the key cannot read under notGranted; neither shows as empty. documents: budget returns whole sections up to about ${DOCUMENT_BUDGET / 1e3}k characters each, full returns all.`,
5257
+ description: `Read where a product stands in one call; the first read for any product question. Name it by ID, slug or git remote URL. Returns product (in setup, readiness and next move), factory with its instructions, specification and architecture outlines (section ids, sizes), policies with section counts, open initiatives (taiga_list_initiatives status: 'all' has the done and canceled), repositories, the latest ten runs, and counts for Action Required, open findings and policy violations. A part that failed is under unavailable, one the key cannot read under notGranted; neither shows as empty. documents: budget returns whole sections up to about ${DOCUMENT_BUDGET / 1e3}k characters each, full returns all.`,
5187
5258
  inputSchema: z.object({
5188
5259
  productId: productId.optional(),
5189
5260
  slug: slug.optional(),
@@ -5289,8 +5360,12 @@ function itemRow(row, now, full) {
5289
5360
  const { snoozedUntil, resolvedAt, ...rest } = pickFields([row], ITEM_FIELDS)[0];
5290
5361
  const metadata = row["metadata"] ?? {};
5291
5362
  const created = typeof row["createdAt"] === "string" ? Date.parse(row["createdAt"]) : NaN;
5363
+ const { summary, ...lean } = rest;
5292
5364
  return {
5293
- ...rest,
5365
+ ...row["kind"] === "review_pr" && !full ? lean : {
5366
+ ...lean,
5367
+ summary
5368
+ },
5294
5369
  ...snoozedUntil ? { snoozedUntil } : {},
5295
5370
  ...resolvedAt ? { resolvedAt } : {},
5296
5371
  ...typeof metadata["prUrl"] === "string" ? { prUrl: metadata["prUrl"] } : {},
@@ -5298,7 +5373,10 @@ function itemRow(row, now, full) {
5298
5373
  ...full ? { metadata: row["metadata"] } : {}
5299
5374
  };
5300
5375
  }
5301
- /** What is open, counted by product and kind: the answer to "what needs attention" without reading every row. */
5376
+ /**
5377
+ * What is open, counted by product and kind: the answer to "what needs attention" without reading every row. An item
5378
+ * that no longer stands is counted under stale, not open or byKind, so nobody subtracts by hand (PRO-2800).
5379
+ */
5302
5380
  function summaryOf(rows, stale) {
5303
5381
  const byProduct = /* @__PURE__ */ new Map();
5304
5382
  for (const row of rows) {
@@ -5313,9 +5391,11 @@ function summaryOf(rows, stale) {
5313
5391
  open: 0,
5314
5392
  byKind: {}
5315
5393
  });
5316
- entry.open += 1;
5317
- entry.byKind[String(row["kind"])] = (entry.byKind[String(row["kind"])] ?? 0) + 1;
5318
5394
  if (stale.has(String(row["id"]))) entry.stale = (entry.stale ?? 0) + 1;
5395
+ else {
5396
+ entry.open += 1;
5397
+ entry.byKind[String(row["kind"])] = (entry.byKind[String(row["kind"])] ?? 0) + 1;
5398
+ }
5319
5399
  byProduct.set(key, entry);
5320
5400
  }
5321
5401
  return [...byProduct.values()].sort((a, b) => b.open - a.open);
@@ -5350,11 +5430,42 @@ async function staleReviews(client, rows) {
5350
5430
  unchecked += 1;
5351
5431
  }
5352
5432
  }));
5433
+ unchecked += await staleBacklogReviews(client, rows, out);
5353
5434
  return {
5354
5435
  stale: out,
5355
5436
  unchecked
5356
5437
  };
5357
5438
  }
5439
+ /**
5440
+ * "Review Initiatives" items whose product has no open initiative left (PRO-2800): two reviews found one
5441
+ * waiting 15 days on a product whose initiatives had all moved on, with nothing an agent could act on. Best effort,
5442
+ * a handful of products; returns how many could not be checked.
5443
+ */
5444
+ async function staleBacklogReviews(client, rows, out) {
5445
+ const asks = [...new Map(rows.filter((row) => row["kind"] === "review_backlog" && typeof row["productId"] === "string").map((row) => [String(row["id"]), row])).values()];
5446
+ if (asks.length === 0) return 0;
5447
+ if (!client.hasCapability("backlog:read")) return asks.length;
5448
+ const checked = [...new Set(asks.map((row) => String(row["productId"])))].slice(0, STALE_CHECKS);
5449
+ let unchecked = asks.filter((row) => !checked.includes(String(row["productId"]))).length;
5450
+ const empty = /* @__PURE__ */ new Set();
5451
+ await Promise.all(checked.map(async (productId) => {
5452
+ try {
5453
+ const [waiting, started] = await Promise.all([client.listBacklogItems(productId, {
5454
+ status: "backlog",
5455
+ limit: 1
5456
+ }), client.listBacklogItems(productId, {
5457
+ status: "in_progress",
5458
+ limit: 1
5459
+ })]);
5460
+ if (waiting.data.length === 0 && started.data.length === 0) empty.add(productId);
5461
+ } catch (error) {
5462
+ if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
5463
+ unchecked += asks.filter((row) => row["productId"] === productId).length;
5464
+ }
5465
+ }));
5466
+ for (const row of asks) if (empty.has(String(row["productId"]))) out.set(String(row["id"]), "no initiative is open now: the ones it asked about are all done or canceled");
5467
+ return unchecked;
5468
+ }
5358
5469
  /** Pages read for the summary; past this it says it counted only those. */
5359
5470
  const SUMMARY_PAGE = 100;
5360
5471
  const SETUP_TASK_FIELDS = [
@@ -5480,7 +5591,7 @@ function registerInboxTools(server, client) {
5480
5591
  }), result.nextCursor),
5481
5592
  ...totalOf(result),
5482
5593
  ...hidden > 0 ? { snoozedHidden: hidden } : {},
5483
- ...checked.unchecked > 0 ? { staleNote: `${checked.unchecked} pull request review${checked.unchecked === 1 ? " was" : "s were"} not checked for staleness (past the cap, or the read was refused).` } : {}
5594
+ ...checked.unchecked > 0 ? { staleNote: `${checked.unchecked} review${checked.unchecked === 1 ? " was" : "s were"} not checked for staleness (past the cap, or the read was refused).` } : {}
5484
5595
  });
5485
5596
  }));
5486
5597
  server.registerTool("taiga_list_setup_tasks", {
@@ -20767,6 +20878,20 @@ function interleave(lists) {
20767
20878
  for (let index = 0; index < longest; index++) for (const list of lists) if (index < list.length) out.push(list[index]);
20768
20879
  return out;
20769
20880
  }
20881
+ /**
20882
+ * A product in setup says what it still needs after a repository write, as create_product and get_product do
20883
+ * (PRO-2800): a reviewer had to read the product again to learn that the import came next. Best effort: the write
20884
+ * is done, so a failed read leaves readiness out rather than failing it.
20885
+ */
20886
+ async function readinessAfter(client, productId) {
20887
+ try {
20888
+ const readiness = await setupReadiness(client, (await client.getProduct(productId)).data);
20889
+ return readiness ? { readiness } : {};
20890
+ } catch (error) {
20891
+ if (error instanceof TaigaApiError || isNetworkFailure(error)) return {};
20892
+ throw error;
20893
+ }
20894
+ }
20770
20895
  function registerRepositoryTools(server, client) {
20771
20896
  if (client.hasCapability("products:read")) {
20772
20897
  server.registerTool("taiga_list_product_repositories", {
@@ -20854,10 +20979,13 @@ function registerRepositoryTools(server, client) {
20854
20979
  }),
20855
20980
  annotations: OPERATE
20856
20981
  }, (params) => runTool(async () => {
20857
- return toolResult(pickFields([(await client.linkRepository(params.productId, {
20858
- githubRepositoryId: params.githubRepositoryId,
20859
- ...params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}
20860
- })).data], LINK_FIELDS)[0]);
20982
+ return toolResult({
20983
+ ...pickFields([(await client.linkRepository(params.productId, {
20984
+ githubRepositoryId: params.githubRepositoryId,
20985
+ ...params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}
20986
+ })).data], LINK_FIELDS)[0],
20987
+ ...await readinessAfter(client, params.productId)
20988
+ });
20861
20989
  }));
20862
20990
  server.registerTool("taiga_set_repository_branch", {
20863
20991
  title: "Choose the branch Taiga works on",
@@ -20877,7 +21005,8 @@ function registerRepositoryTools(server, client) {
20877
21005
  })).data;
20878
21006
  return toolResult({
20879
21007
  ...pickFields([updated], LINK_FIELDS)[0],
20880
- baseBranchProtection: updated.baseBranchProtection ?? null
21008
+ baseBranchProtection: updated.baseBranchProtection ?? null,
21009
+ ...await readinessAfter(client, params.productId)
20881
21010
  });
20882
21011
  }));
20883
21012
  server.registerTool("taiga_unlink_repository", {
@@ -21082,8 +21211,13 @@ async function firstNamed(name, read) {
21082
21211
  }
21083
21212
  return null;
21084
21213
  }
21085
- function duplicateName(kind, existing) {
21086
- return toolError("DUPLICATE_NAME", `A ${kind} named "${existing.name}" already exists (${existing.id}). Use it, choose another name, or pass allowDuplicateName: true if the person wants a second one.`, {
21214
+ /**
21215
+ * The refusal names the one that exists and leaves the choice to the person: another session may have made it, so
21216
+ * "use it" would hand the agent someone else's work (PRO-2800, review 6).
21217
+ */
21218
+ function duplicateName(kind, existing, where) {
21219
+ const made = typeof existing.createdAt === "string" ? `, created ${existing.createdAt}` : "";
21220
+ return toolError("DUPLICATE_NAME", `A ${kind} named "${existing.name}" already exists ${where} (${existing.id}${made}). It may not be this person's: ask whether they meant that one, want another name, or want a second (allowDuplicateName: true).`, {
21087
21221
  existingId: existing.id,
21088
21222
  ...existing.slug ? { existingSlug: existing.slug } : {}
21089
21223
  });
@@ -21131,7 +21265,7 @@ function registerSetupTools(server, client) {
21131
21265
  limit: 100,
21132
21266
  cursor
21133
21267
  }));
21134
- if (same) return duplicateName("factory", same);
21268
+ if (same) return duplicateName("factory", same, "in this organization");
21135
21269
  }
21136
21270
  return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
21137
21271
  }));
@@ -21185,7 +21319,7 @@ function registerSetupTools(server, client) {
21185
21319
  if (!client.hasCapability("products:write")) return;
21186
21320
  server.registerTool("taiga_create_product", {
21187
21321
  title: "Create a product",
21188
- description: "Create a product in a factory, as New product does, and return it. Choose now how it starts; it cannot change later: scratch (projectType greenfield), where Discovery is a conversation in the web that writes the specification, or import (brownfield), where Taiga drafts the documents from a repository: taiga_link_repository (owners and admins only), then taiga_start_import. Infrastructure code and CI/CD pipelines follow the organization unless set here; when one is off, deploymentNotes says how that half is done today. Add source material first with taiga_add_knowledge (productId): Discovery writes from it.",
21322
+ description: "Create a product in a factory and return it with readiness (what it needs next). start cannot change later. import (brownfield): Taiga drafts the documents from existing code; link it with taiga_link_repository (owners and admins only), then taiga_start_import. scratch (greenfield): Discovery is a conversation in the web that writes the specification. Infrastructure code and CI/CD pipelines follow the organization unless set here; when one is off, deploymentNotes says how that half is done today. Add source material first with taiga_add_knowledge (productId): Discovery writes from it.",
21189
21323
  inputSchema: z.object({
21190
21324
  factoryId,
21191
21325
  name: z.string().trim().min(1).max(255).describe("The product name"),
@@ -21200,10 +21334,11 @@ function registerSetupTools(server, client) {
21200
21334
  if (contradiction) return contradiction;
21201
21335
  if (!params.allowDuplicateName) {
21202
21336
  const same = await firstNamed(params.name, async (cursor) => client.listProducts({
21337
+ factoryId: params.factoryId,
21203
21338
  limit: 100,
21204
21339
  cursor
21205
21340
  }));
21206
- if (same) return duplicateName("product", same);
21341
+ if (same) return duplicateName("product", same, "in this factory");
21207
21342
  }
21208
21343
  const product = (await client.createProduct({
21209
21344
  name: params.name,
@@ -21632,14 +21767,14 @@ function registerWaitTools(server, client, options) {
21632
21767
  * model learns the tool's real shape. Applied here, once, so no tool can be
21633
21768
  * registered without it.
21634
21769
  */
21635
- function withStrictInputs(server, registered) {
21770
+ function withStrictInputs(server, client, registered) {
21636
21771
  const register = server.registerTool.bind(server);
21637
21772
  server.registerTool = ((name, config, callback) => {
21638
21773
  registered.set(name, config.annotations);
21639
21774
  const schema = config.inputSchema;
21640
21775
  if (schema !== void 0 && !(schema instanceof z.ZodObject)) throw new Error(`Tool ${name}: inputSchema must be a z.object() so unknown arguments can be refused`);
21641
21776
  const handler = callback;
21642
- const withHint = (args, ctx) => notFoundHints.run(notFoundHint(name, args), () => handler(args, ctx));
21777
+ const withHint = async (args, ctx) => withIdKind(client, args, await notFoundHints.run(notFoundHint(name, args), () => handler(args, ctx)));
21643
21778
  const described = {
21644
21779
  ...config,
21645
21780
  description: `${writeNotice(name, config.annotations)}${config.description ?? ""}`,
@@ -21653,6 +21788,42 @@ function withStrictInputs(server, registered) {
21653
21788
  return server;
21654
21789
  }
21655
21790
  /**
21791
+ * A product the API does not know may be a factory's ID passed as one (PRO-2800): the hint can only say a product
21792
+ * ID is wanted, so on that miss alone one read names what the ID is.
21793
+ */
21794
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21795
+ async function withIdKind(client, args, result) {
21796
+ const productId = typeof args === "object" && args !== null ? args["productId"] : null;
21797
+ const answer = result;
21798
+ const first = answer?.isError ? answer.content?.[0] : void 0;
21799
+ if (typeof productId !== "string" || first?.type !== "text") return result;
21800
+ let body;
21801
+ try {
21802
+ body = JSON.parse(first.text);
21803
+ } catch {
21804
+ return result;
21805
+ }
21806
+ if (body.error !== "NOT_FOUND" || !/\b(product|project) not found|No product with that ID/i.test(String(body.message))) return result;
21807
+ if (!UUID.test(productId) || !client.hasCapability("factories:read")) return result;
21808
+ try {
21809
+ const factory = (await client.getFactory(productId)).data;
21810
+ const message = `${String(body.message)} That ID is the factory "${factory.name}", not a product: taiga_list_products with factoryId lists its products.`;
21811
+ return {
21812
+ ...answer,
21813
+ content: [{
21814
+ type: "text",
21815
+ text: JSON.stringify({
21816
+ ...body,
21817
+ message
21818
+ })
21819
+ }, ...answer.content.slice(1)]
21820
+ };
21821
+ } catch (error) {
21822
+ if (error instanceof TaigaApiError || isNetworkFailure(error)) return result;
21823
+ throw error;
21824
+ }
21825
+ }
21826
+ /**
21656
21827
  * The entry points, loaded with their descriptions at session start
21657
21828
  * (PRO-2800). Claude Code defers every other MCP tool behind tool search and
21658
21829
  * shows the model only its name until it searches, so an agent meeting Taiga
@@ -21712,7 +21883,7 @@ const PAID_WORK = /* @__PURE__ */ new Set([
21712
21883
  */
21713
21884
  function registerAllTools(server, client, options = {}) {
21714
21885
  const registered = /* @__PURE__ */ new Map();
21715
- withStrictInputs(server, registered);
21886
+ withStrictInputs(server, client, registered);
21716
21887
  registerProductTools(server, client);
21717
21888
  registerDocumentTools(server, client);
21718
21889
  registerBacklogTools(server, client);
@@ -21783,7 +21954,7 @@ const SERVER_INSTRUCTIONS = [
21783
21954
  "**Which read.** Where a product stands: taiga_get_product_context (by git remote URL, slug or ID). What waits on",
21784
21955
  "people: taiga_list_action_required. A policy: taiga_get_document (sections: [] for the outline, or a control id",
21785
21956
  "such as SEC-5.1). Findings: taiga_list_findings. One initiative or run: taiga_get_initiative, taiga_get_run.",
21786
- "This key's role and tools: taiga_whoami.",
21957
+ "This key's role and which tools write: taiga_whoami.",
21787
21958
  "",
21788
21959
  "**Words.** An initiative is a unit of work; its board columns are backlog, todo, queue, plan, build, done, canceled. The",
21789
21960
  "line is the product's queue, worked one initiative at a time through plan and build. A run is one attempt by the",
@@ -21795,8 +21966,9 @@ const SERVER_INSTRUCTIONS = [
21795
21966
  "(derived from the code). outcome, situation and nextStep are one-sentence summaries of their record.",
21796
21967
  "",
21797
21968
  "**Writing.** A tool that writes opens with \"Writes.\", \"Ask first (paid agent work).\" or \"Ask first (others see",
21798
- "it).\"; before an ask-first tool, tell the person what you will do and wait for their yes. A taken factory or",
21799
- "product name is refused unless allowDuplicateName.",
21969
+ "it).\"; before an ask-first tool, tell the person what you will do and wait for their yes. Others share this",
21970
+ "organization: a taken factory name (or product name in its factory) is refused unless allowDuplicateName, and",
21971
+ "the one that exists may not be this person's.",
21800
21972
  "",
21801
21973
  "**Only in the web:** connecting GitHub, members and roles, the Discovery conversation of a product started from",
21802
21974
  "scratch, stamping checks, starting a run over, and deleting factories and products."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.21.0-dev.455",
3
+ "version": "0.22.0-dev.457",
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",