@taiga-ai/mcp-server 0.25.0-rc.464 → 0.25.0-rc.474

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
@@ -356,11 +356,11 @@ var TaigaClient = class {
356
356
  async getPolicyStanding(productId) {
357
357
  return this.request("GET", `/products/${seg(productId)}/maintenance/policies`);
358
358
  }
359
- async remediatePolicyControl(productId, controlId) {
360
- return this.request("POST", `/products/${seg(productId)}/maintenance/policies/controls/${seg(controlId)}/remediate`, {});
359
+ async remediatePolicyControl(productId, controlId, body = {}) {
360
+ return this.request("POST", `/products/${seg(productId)}/maintenance/policies/controls/${seg(controlId)}/remediate`, body);
361
361
  }
362
- async remediatePolicyViolation(productId, divergenceId) {
363
- return this.request("POST", `/products/${seg(productId)}/maintenance/policies/divergences/${seg(divergenceId)}/remediate`, {});
362
+ async remediatePolicyViolation(productId, divergenceId, body = {}) {
363
+ return this.request("POST", `/products/${seg(productId)}/maintenance/policies/divergences/${seg(divergenceId)}/remediate`, body);
364
364
  }
365
365
  async listInbox(opts) {
366
366
  const { productId, ...rest } = opts;
@@ -670,6 +670,29 @@ const POLICY_TYPE_BY_CONTROL_PREFIX = {
670
670
  SCS: "policy_supply_chain",
671
671
  CLD: "policy_cloud_governance"
672
672
  };
673
+ /** The policy document a control id belongs to, or undefined for an unknown prefix. */
674
+ function policyTypeForControl(controlId) {
675
+ return POLICY_TYPE_BY_CONTROL_PREFIX[controlId.split("-")[0] ?? ""];
676
+ }
677
+ /**
678
+ * The policy a control id points a reader to (PRO-2809): its rightmost base
679
+ * prefix. An overlay family names its host policy last: TEL-TIC-1.1 is a
680
+ * telecom control in policy_tic, HC-FI-DATA-1.1 a Finnish healthcare control in
681
+ * policy_data, ARC-AWS-5 an AWS preset in policy_architecture. A regulatory
682
+ * family with no base prefix (GDPR, NIS2, AIACT) spans several policies and
683
+ * names none.
684
+ *
685
+ * For saying where to read a control, not for deciding who holds it:
686
+ * {@link policyTypeForControl} keeps an overlay control with the document it is
687
+ * written in, whatever its id says.
688
+ */
689
+ function policyNamedByControl(controlId) {
690
+ const prefixes = controlId.split("-").filter((part) => /^[A-Z]/.test(part));
691
+ for (let at = prefixes.length - 1; at >= 0; at -= 1) {
692
+ const owner = POLICY_TYPE_BY_CONTROL_PREFIX[prefixes[at]];
693
+ if (owner) return owner;
694
+ }
695
+ }
673
696
  /**
674
697
  * The shape of a control id in a policy's control table: `SEC-3.4`, and the
675
698
  * overlay families too (`TEL-TIC-1.1`, `HC-FI-DATA-1.1`, `GDPR-1.1`). One
@@ -2663,17 +2686,6 @@ const sectionsInput = z.array(z.string().min(1).max(200)).max(50).optional().des
2663
2686
  /** The sections input of a company document, where a policy control id also names the sections that state it. */
2664
2687
  const policySectionsInput = sectionsInput.describe("Only these sections, by id, or a policy control id (SEC-5.1) for the sections that state it; [] for the outline (under outline: every section id and its size, no text); omit for the whole document");
2665
2688
  /**
2666
- * The policy that states a control: the rightmost policy prefix in its id, so an overlay family's
2667
- * TEL-TIC-1.1 is policy_tic and HC-FI-DATA-1.1 policy_data, as the overlays file them.
2668
- */
2669
- function policyOfControl(id) {
2670
- const prefixes = id.split("-").filter((part) => /^[A-Z]/.test(part));
2671
- for (const prefix of prefixes.reverse()) {
2672
- const policy = POLICY_TYPE_BY_CONTROL_PREFIX[prefix];
2673
- if (policy) return policy;
2674
- }
2675
- }
2676
- /**
2677
2689
  * The sections that state each control id asked for (PRO-2800): policy
2678
2690
  * standing cites SEC-5.1, and the section holding it has an id of its own
2679
2691
  * (secretsManagement) an agent could only guess. A control id is found in
@@ -2699,7 +2711,7 @@ function withControls(outline, sections, policyType) {
2699
2711
  return outline.map((entry) => {
2700
2712
  const text = sectionContent(sections, entry.id) ?? "";
2701
2713
  const ids = [...new Set([...text.matchAll(pattern)].filter((match) => !/^_?Reserved_?/i.test(match[2] ?? "")).map((match) => match[1]).filter((id) => {
2702
- const owner = POLICY_TYPE_BY_CONTROL_PREFIX[id.split("-")[0] ?? ""];
2714
+ const owner = policyTypeForControl(id);
2703
2715
  return owner === void 0 || owner === policyType;
2704
2716
  }))];
2705
2717
  return ids.length > 0 ? {
@@ -2727,7 +2739,7 @@ function sectionReadResult(doc, asked, label, options = {}) {
2727
2739
  const known = ids.filter((id) => all.includes(id));
2728
2740
  const found = Object.keys(controls).length > 0 ? { controls } : {};
2729
2741
  const asControl = options.controlIds ? void 0 : unknownSections.find((id) => isControlId(id));
2730
- const policy = asControl ? policyOfControl(asControl) : void 0;
2742
+ const policy = asControl ? policyNamedByControl(asControl) : void 0;
2731
2743
  const controlNote = asControl ? { note: policy ? `${asControl} is a control of ${policy}: read it with taiga_get_document (type: ${policy}, sections: ["${asControl}"]).` : `${asControl} looks like a policy control id, which only a policy resolves: taiga_get_document without type lists them.` } : {};
2732
2744
  if (known.length === 0) {
2733
2745
  const { data: _data, ...meta } = cut.doc;
@@ -3729,6 +3741,22 @@ function runOutcome(run) {
3729
3741
  };
3730
3742
  }
3731
3743
  /**
3744
+ * What hardening left blocking, in a sentence: the findings the run names (PRO-2817), or, for a run reported before
3745
+ * it recorded them, how many.
3746
+ */
3747
+ function hardeningSaid(blocking, named) {
3748
+ const count = `Hardening ended with ${blocking} blocking finding${blocking === 1 ? "" : "s"} it could not fix`;
3749
+ if (named.length === 0) return `${count}; the run records how many, not which.`;
3750
+ const shown = named.slice(0, 3).map((finding) => {
3751
+ const message = String(finding["message"]);
3752
+ const where = finding["line"] ? `${String(finding["path"])}:${String(finding["line"])}` : String(finding["path"]);
3753
+ return `${message} (${String(finding["tool"])}, ${where})`;
3754
+ });
3755
+ const rest = blocking - shown.length;
3756
+ const recorded = named.length > shown.length ? `, ${named.length} recorded in taiga_get_run` : "";
3757
+ return `${count}: ${shown.join("; ")}${rest > 0 ? `; and ${rest} more${recorded}` : ""}.`;
3758
+ }
3759
+ /**
3732
3760
  * What each failedReason means for the next move (PRO-2800): reviewers met dispatch_rejected and agent_reported as
3733
3761
  * bare enums. As @taiga/types documents them.
3734
3762
  */
@@ -3753,10 +3781,11 @@ function readableRun(run, opts = {}) {
3753
3781
  const means = FAILED_REASON_MEANS[String(run["failedReason"])];
3754
3782
  const hardening = run["hardeningResult"];
3755
3783
  const blocking = typeof hardening?.blockingFindings === "number" ? hardening.blockingFindings : 0;
3784
+ const named = Array.isArray(hardening?.blocking) ? hardening.blocking : [];
3756
3785
  return {
3757
3786
  ...rest,
3758
3787
  ...means ? { failedReasonMeans: means } : {},
3759
- ...blocking > 0 ? { hardeningNote: `Hardening ended with ${blocking} blocking finding${blocking === 1 ? "" : "s"} it could not fix; the run records how many, not which.` } : {},
3788
+ ...blocking > 0 ? { hardeningNote: hardeningSaid(blocking, named) } : {},
3760
3789
  ...opts.row || error === void 0 ? {} : { error },
3761
3790
  ...opts.row || reviewEscalation === void 0 ? {} : { reviewEscalation },
3762
3791
  ...typeof rest["prNumber"] === "number" || rest["prUrl"] ? { prDraft } : {},
@@ -3769,20 +3798,27 @@ const INITIATIVE_CHECKS = 10;
3769
3798
  * The status of the initiative behind each run still waiting on its pull
3770
3799
  * request (PRO-2800). A reviewing agent read a run of a canceled initiative as
3771
3800
  * "waiting for a person to review and merge" while Action Required called the
3772
- * same review stale. A 404 is 'deleted'. Best effort: an initiative past the
3773
- * cap, one whose read failed, and every one without backlog:read is
3774
- * 'unchecked', which withInitiativeState adds to the row's outcome.
3801
+ * same review stale.
3802
+ *
3803
+ * The run says it itself since PRO-2808 (initiativeClosed: done, canceled,
3804
+ * archived, deleted, or null while live), for every key that can read it.
3805
+ * Against an API from before that, each initiative is read: a 404 is
3806
+ * 'deleted', and an initiative past the cap, one whose read failed, and every
3807
+ * one without backlog:read is 'unchecked', which withInitiativeState adds to
3808
+ * the row's outcome.
3775
3809
  */
3776
3810
  async function initiativeStates(client, rows) {
3777
3811
  const states = /* @__PURE__ */ new Map();
3778
3812
  const waiting = rows.filter((row) => ["needs_review", "completed"].includes(String(row["status"])) && row["prMerged"] !== true);
3779
- const all = [...new Set(waiting.map((row) => row["backlogItemId"]).filter((id) => typeof id === "string"))];
3813
+ const told = waiting.filter((row) => "initiativeClosed" in row);
3814
+ for (const row of told) if (typeof row["backlogItemId"] === "string") states.set(row["backlogItemId"], typeof row["initiativeClosed"] === "string" ? row["initiativeClosed"] : "live");
3815
+ const all = [...new Set(waiting.filter((row) => !("initiativeClosed" in row)).map((row) => row["backlogItemId"]).filter((id) => typeof id === "string" && !states.has(id)))];
3780
3816
  const ids = client.hasCapability("backlog:read") ? all.slice(0, INITIATIVE_CHECKS) : [];
3781
3817
  for (const id of all) if (!ids.includes(id)) states.set(id, "unchecked");
3782
3818
  await Promise.all(ids.map(async (id) => {
3783
3819
  try {
3784
3820
  const item = (await client.getBacklogItem(id)).data;
3785
- states.set(id, String(item["status"] ?? item["state"]));
3821
+ states.set(id, item["archivedAt"] ? "archived" : String(item["status"] ?? item["state"]));
3786
3822
  } catch (error) {
3787
3823
  if (error instanceof TaigaApiError && error.status === 404) return void states.set(id, "deleted");
3788
3824
  if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
@@ -3791,7 +3827,18 @@ async function initiativeStates(client, rows) {
3791
3827
  }));
3792
3828
  return states;
3793
3829
  }
3794
- /** A readable run row that also says when its initiative was canceled or deleted while its pull request waited. */
3830
+ /**
3831
+ * How each way an initiative ends reads on a run it left waiting: the same set
3832
+ * Action Required closes a run's review for (PRO-2806). A done one's waiting
3833
+ * run is not the one that landed it: that run is merged and never waiting.
3834
+ */
3835
+ const INITIATIVE_ENDED = {
3836
+ done: "was marked done",
3837
+ canceled: "was canceled",
3838
+ archived: "was archived",
3839
+ deleted: "was deleted"
3840
+ };
3841
+ /** A readable run row that also says when its initiative ended while its pull request waited. */
3795
3842
  function withInitiativeState(row, states) {
3796
3843
  const state = states.get(String(row["backlogItemId"]));
3797
3844
  if (!["gated", "unmerged"].includes(String(row["disposition"]))) return row;
@@ -3799,14 +3846,15 @@ function withInitiativeState(row, states) {
3799
3846
  ...row,
3800
3847
  outcome: `${String(row["outcome"])} Whether its initiative still wants it was not checked.`
3801
3848
  };
3802
- if (state !== "canceled" && state !== "deleted") return row;
3849
+ const ended = state ? INITIATIVE_ENDED[state] : void 0;
3850
+ if (!ended) return row;
3803
3851
  const pr = typeof row["prNumber"] === "number" ? `PR #${row["prNumber"]}` : "its pull request";
3804
3852
  const open = row["prState"] === "open" ? ` ${pr} is still open on the forge.` : "";
3805
3853
  return {
3806
3854
  ...row,
3807
3855
  disposition: "abandoned",
3808
3856
  actions: [],
3809
- outcome: `Its initiative was ${state}, so nobody is waiting on this run.${open}`
3857
+ outcome: `Its initiative ${ended}, so nobody is waiting on this run.${open}`
3810
3858
  };
3811
3859
  }
3812
3860
  /** A step that changed only prose: no code of its own could have broken a test. */
@@ -3913,7 +3961,7 @@ function registerBuildTools(server, client) {
3913
3961
  if (!client.hasCapability("products:read")) return;
3914
3962
  server.registerTool("taiga_list_runs", {
3915
3963
  title: "List runs",
3916
- description: "List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (initiative canceled or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run reads one; carry work on from the initiative (taiga_resume_initiative).",
3964
+ description: "List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (its initiative ended: done, canceled, archived or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run reads one; carry work on from the initiative (taiga_resume_initiative).",
3917
3965
  inputSchema: z.object({
3918
3966
  productId: productId.optional(),
3919
3967
  initiativeId: idInput("initiative", "taiga_list_initiatives (query finds one by title) gives it").optional().describe("Only this initiative's runs -- get from taiga_list_initiatives; needs productId"),
@@ -4236,6 +4284,7 @@ function situationOf(item) {
4236
4284
  const handback = handbackSaid(build.reviewEscalation);
4237
4285
  if (handback) stage += `, handed back because ${handback}`;
4238
4286
  }
4287
+ if (life.stage === "done" || life.stage === "canceled") stage += endingSaid(item);
4239
4288
  parts.push(`${stage}.`);
4240
4289
  if (typeof item["needsHumanCause"] === "string") {
4241
4290
  const why = [renderHalt({
@@ -4253,6 +4302,26 @@ function situationOf(item) {
4253
4302
  else if (move) parts.push(`Next: ${move}.`);
4254
4303
  return parts.join(" ");
4255
4304
  }
4305
+ /**
4306
+ * How a settled initiative ended, as the rest of its "Done" or "Canceled"
4307
+ * sentence (PRO-2821): the pull request that landed it, the move that settled
4308
+ * it and who made it, and what took over. Only the single read carries
4309
+ * `ending`; a list row says nothing more.
4310
+ */
4311
+ function endingSaid(item) {
4312
+ const ending = item["ending"];
4313
+ const merged = typeof ending?.landedPr?.number === "number" ? ending.landedPr.number : null;
4314
+ const said = [];
4315
+ if (merged !== null) said.push(`: PR #${merged} merged`);
4316
+ else if (ending && (ending.via === "moved" || ending.via === "off_the_line")) {
4317
+ const who = typeof ending.by?.name === "string" ? ending.by.name : ending.by?.type === "system" ? "Taiga" : "someone";
4318
+ const when = typeof ending.at === "string" ? ` on ${ending.at.slice(0, 10)}` : "";
4319
+ said.push(ending.via === "off_the_line" ? ` by ${who}${when}, who took it off the line` : ` by ${who}${when}`);
4320
+ }
4321
+ const next = ending?.supersededBy;
4322
+ if (next && typeof next.id === "string") said.push(`; superseded by "${String(next.title)}" (${next.id})`);
4323
+ return said.join("");
4324
+ }
4256
4325
  /** A row or item with its situation added when it has a lifecycle. */
4257
4326
  function withSituation(item, row = item) {
4258
4327
  const situation = situationOf(item);
@@ -4815,7 +4884,7 @@ function registerMaintenanceTools(server, client) {
4815
4884
  if (!client.hasCapability("products:read")) return;
4816
4885
  server.registerTool("taiga_list_findings", {
4817
4886
  title: "List maintenance findings",
4818
- description: "List what maintenance sweeps found in a product's repository: dependencies, secrets, insecure code (sast) and infrastructure. The first page opens with sweep (open counts, latest sweeps), so an old or failed sweep is not read as an all-clear. Rows: severity, tier (act_now: known exploited; urgent: likely and serious; elevated: one of the two; routine), location, remediableBy (upstream_only: no fixed version), breached controls and initiativeId of a fix under way. Omit status for open findings. taiga_get_finding reads one.",
4887
+ description: "List what maintenance sweeps found in a product's repository: dependencies, secrets, insecure code (sast) and infrastructure. The first page opens with sweep (open counts, latest sweeps), so an old or failed sweep is not read as an all-clear. Rows: severity, tier (act_now: known exploited; urgent: likely and serious; elevated: one of the two; routine), location, remediableBy (coding_agent: a run here can fix it; upstream_only: the code is in a submodule, so the fix belongs in its own repository), fixable: false when no fixed version is known yet, breached controls and initiativeId of a fix under way. Omit status for open findings. taiga_get_finding reads one.",
4819
4888
  inputSchema: z.object({
4820
4889
  productId,
4821
4890
  status: z.enum(MAINTENANCE_FINDING_STATUSES).optional().describe("Only findings in this status; omit for the ones needing action"),
@@ -4861,7 +4930,7 @@ function registerMaintenanceTools(server, client) {
4861
4930
  }));
4862
4931
  server.registerTool("taiga_get_policy_standing", {
4863
4932
  title: "Get a product's policy standing",
4864
- description: "How a product stands against the organization's published policies: summary counts, when the documents were assessed, and each violated control with its violations (critical and high only, citing the policy sentence and the contradicting passage) and the work on each. state: breached (open scanner findings map to it, by findingsByTier; routine-only is not urgent) or diverged (the documents contradict it). live: false means the violation's initiative ended without fixing it: it needs a person or a new fix. An unlisted control is not \"passing\" (summary.controlsWithNothingRecorded counts them). full: true adds evidence and resolved ones. Fix with taiga_fix_policy_violation.",
4933
+ description: "How a product stands against the organization's published policies: summary counts, when the documents were assessed, and each violated control with its violations (critical and high only, citing the policy sentence and the contradicting passage) and the work on each. state: breached (open scanner findings map to it, by findingsByTier; routine-only is not urgent) or diverged (the documents contradict it). live: false means the violation's initiative ended without fixing it: it needs a person or a new fix. raisedFor: false means the link is the backlog planner's: it said an initiative a person asked it for would close the violation, which may be a dedicated fix or broader work; true means a fix was raised for it (taiga_fix_policy_violation or the sweep). An unlisted control is not \"passing\" (summary.controlsWithNothingRecorded counts them). full: true adds evidence and resolved ones. Fix with taiga_fix_policy_violation.",
4865
4934
  inputSchema: z.object({
4866
4935
  productId,
4867
4936
  full: z.boolean().optional().describe("Carry each violation's evidence and policy excerpt, cut to about 1,500 characters each, and up to 20 resolved violations (default false)"),
@@ -4944,7 +5013,7 @@ function registerMaintenanceTools(server, client) {
4944
5013
  }));
4945
5014
  server.registerTool("taiga_fix_finding", {
4946
5015
  title: "Fix a finding",
4947
- description: "Raise an initiative that fixes a maintenance finding, at the end of the product's line; it ends in a pull request a person merges, and the initiative's review gate applies. wholeGroup: true fixes the group in one initiative: every open advisory on that package version in one bump, or every location the same rule flagged. Refused for a secret only in history, anything in a submodule, and remediableBy upstream_only.",
5016
+ description: "Raise an initiative that fixes a maintenance finding, at the end of the product's line; it ends in a pull request a person merges, and the initiative's review gate applies. wholeGroup: true fixes the group in one initiative: every open advisory on that package version in one bump, or every location the same rule flagged. Refused for a secret only in history, remediableBy upstream_only (code in a submodule), and a dependency with no fixed version yet (fixable: false).",
4948
5017
  inputSchema: z.object({
4949
5018
  productId,
4950
5019
  findingId,
@@ -4970,16 +5039,18 @@ function registerMaintenanceTools(server, client) {
4970
5039
  }));
4971
5040
  server.registerTool("taiga_fix_policy_violation", {
4972
5041
  title: "Fix policy violations",
4973
- description: "Raise one initiative that fixes a product's policy violations and add it to the end of the line. Name a control to cover all its violations with no initiative yet, or one violationId when the control has several. The violation stays listed until the next assessment no longer finds it, even while the fix is under way.",
5042
+ description: "Raise one initiative that fixes a product's policy violations and add it to the end of the line. Name a control to cover all its violations with no initiative yet, or one violationId when the control has several. The violation stays listed until the next assessment no longer finds it, even while the fix is under way. A violation whose last fix was done after it was last assessed is refused: refresh the reality documents to reassess it first, or pass fixAgain: true if the person wants another fix anyway.",
4974
5043
  inputSchema: z.object({
4975
5044
  productId,
4976
5045
  controlId: z.string().min(1).max(40).optional().describe("The control's identifier, e.g. SEC-3.4"),
4977
- violationId: idInput("violation", "taiga_get_policy_standing gives it").optional()
5046
+ violationId: idInput("violation", "taiga_get_policy_standing gives it").optional(),
5047
+ fixAgain: z.boolean().optional().describe("Raise a fix even though the last one was done after the last assessment (default false)")
4978
5048
  }),
4979
5049
  annotations: OPERATE
4980
5050
  }, (params) => runTool(async () => {
4981
5051
  if (params.controlId === void 0 === (params.violationId === void 0)) return toolError("VALIDATION_ERROR", "Name either a control (controlId) or one violation (violationId), not both or neither.");
4982
- return toolResult((params.controlId ? await client.remediatePolicyControl(params.productId, params.controlId) : await client.remediatePolicyViolation(params.productId, params.violationId)).data);
5052
+ const body = { refuseIfFixedSinceAssessment: params.fixAgain !== true };
5053
+ return toolResult((params.controlId ? await client.remediatePolicyControl(params.productId, params.controlId, body) : await client.remediatePolicyViolation(params.productId, params.violationId, body)).data);
4983
5054
  }));
4984
5055
  }
4985
5056
  //#endregion
@@ -5196,7 +5267,8 @@ const SETUP_TASK_FIELDS = [
5196
5267
  "providerActionUrl",
5197
5268
  "inboxItemId",
5198
5269
  "inboxItemStatus",
5199
- "createdAt"
5270
+ "createdAt",
5271
+ "stale"
5200
5272
  ];
5201
5273
  /** The API's count across every page, on the first page; nothing from an API that does not send one. */
5202
5274
  function totalOf(result) {
@@ -5236,7 +5308,10 @@ function sameKeyOnce(rows) {
5236
5308
  const { alsoFiledAs: _also, ...was } = kept;
5237
5309
  for (const field of Object.keys(kept)) delete kept[field];
5238
5310
  Object.assign(kept, row, { alsoFiledAs: [...also, record(was)] });
5239
- } else kept["alsoFiledAs"] = [...also, record(row)];
5311
+ } else {
5312
+ kept["alsoFiledAs"] = [...also, record(row)];
5313
+ if (isOpen(row) && row["stale"] === void 0) delete kept["stale"];
5314
+ }
5240
5315
  }
5241
5316
  return out;
5242
5317
  }
@@ -5311,7 +5386,7 @@ function registerInboxTools(server, client) {
5311
5386
  }));
5312
5387
  server.registerTool("taiga_list_setup_tasks", {
5313
5388
  title: "List setup tasks",
5314
- description: "List what a person must set up for a product (secrets, variables, accounts), with exactly what and where: key (the name to create; Taiga never stores values), the provider page, and phase: prereq (the deploy fails without it), wiring (generated values back to providers) or followup (can wait until after launch). Runs do not wait for them. One product or all, oldest first; includeDone: true adds done and dismissed. A variable filed twice lists the others under alsoFiledAs; totalCount counts records, and a page that folded some says recordsOnPage. Unlike taiga_list_action_required, which lists the same work as items to resolve.",
5389
+ description: "List what a person must set up for a product (secrets, variables, accounts), with exactly what and where: key (the name to create; Taiga never stores values), the provider page, and phase: prereq (the deploy fails without it), wiring (generated values back to providers) or followup (can wait until after launch). Runs do not wait for them. One product or all, oldest first; includeDone: true adds done and dismissed. A variable filed twice lists the others under alsoFiledAs; totalCount counts records, and a page that folded some says recordsOnPage. stale on an open task says no document derived from the code since it was raised mentions its key: it may not apply any more, so check before asking anyone to set it. Unlike taiga_list_action_required, which lists the same work as items to resolve.",
5315
5390
  inputSchema: z.object({
5316
5391
  productId: productId.optional(),
5317
5392
  includeDone: z.boolean().optional().describe("Also the tasks already done or dismissed (default false)"),
@@ -21059,7 +21134,8 @@ const GITHUB_REPOSITORY_FIELDS = [
21059
21134
  "defaultBranch",
21060
21135
  "description",
21061
21136
  "language",
21062
- "linked"
21137
+ "linked",
21138
+ "linkedProduct"
21063
21139
  ];
21064
21140
  const repositoryId = idInput("repository link", "taiga_list_product_repositories gives it; a linkable repository id is not one");
21065
21141
  /** Round-robin over lists: the first of each, then the second of each, and so on. */
@@ -21075,7 +21151,8 @@ function interleave(lists) {
21075
21151
  */
21076
21152
  async function holderOf(client, error, productId) {
21077
21153
  if (!(error instanceof TaigaApiError) || error.status !== 409) return null;
21078
- const name = /^(\S+\/\S+) is already linked to another product\.?$/.exec(error.apiMessage ?? "")?.[1];
21154
+ if (error.details?.["holder"]) return null;
21155
+ const name = /^(\S+\/\S+) is already linked to another product\./.exec(error.apiMessage ?? "")?.[1];
21079
21156
  if (!name) return null;
21080
21157
  try {
21081
21158
  const holders = (await client.listProducts({
@@ -21137,7 +21214,7 @@ function registerRepositoryTools(server, client) {
21137
21214
  }
21138
21215
  if (client.hasCapability("vcs:read")) server.registerTool("taiga_list_linkable_repositories", {
21139
21216
  title: "Find a repository to link",
21140
- description: "List the repositories the organization's GitHub connection can reach, to link one with taiga_link_repository (it takes the row's id as linkableRepositoryId). query searches by name; account (login, owner, or installationId) reads one account, else rows come from every account in turn. linked: true means a product already links it (taiga_get_product with its URL says which). No connection means GitHub is not connected yet; someone who can install the app does that once, in the web.",
21217
+ description: "List the repositories the organization's GitHub connection can reach, to link one with taiga_link_repository (it takes the row's id as linkableRepositoryId). query searches by name; account (login, owner, or installationId) reads one account, else rows come from every account in turn. linked: true means a product already links it, and linkedProduct names it to a key that sees every product. No connection means GitHub is not connected yet; someone who can install the app does that once, in the web.",
21141
21218
  inputSchema: z.object({
21142
21219
  query: z.string().trim().min(1).max(256).optional().describe("Part of the repository name"),
21143
21220
  account: z.string().trim().min(1).max(140).optional().describe("The GitHub account login (the owner in owner/repo), case-insensitive"),
@@ -21406,41 +21483,29 @@ const MISSING_DOCUMENTS_HOW = "An imported product gets them from its import (ta
21406
21483
  /**
21407
21484
  * Refuse a name already taken (PRO-2800): the API appends -2 to the slug and
21408
21485
  * creates a second one, so a retried call or two agents at once left
21409
- * duplicates nobody asked for. allowDuplicateName says the person means it.
21486
+ * duplicates nobody asked for. The API checks it in the create's transaction
21487
+ * (PRO-2808), so names past any page and two creates at once are caught too.
21488
+ * allowDuplicateName says the person means it.
21410
21489
  */
21411
21490
  const allowDuplicateName = z.boolean().optional().describe("Create it even when one with the same name exists (default false: refused, naming the existing one)");
21412
- /** The same name, as a person would read it: case and surrounding space aside. */
21413
- const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
21414
- /** Pages read looking for a taken name; past this the create goes ahead. */
21415
- const NAME_PAGES = 5;
21416
- /**
21417
- * The first row with this name, across pages. Best effort: a list the API
21418
- * refuses or fails leaves the check out rather than the create.
21419
- */
21420
- async function firstNamed(name, read) {
21421
- let cursor;
21422
- try {
21423
- for (let pageIndex = 0; pageIndex < NAME_PAGES; pageIndex += 1) {
21424
- const result = await read(cursor);
21425
- const same = result.data.find((row) => sameName(row.name, name));
21426
- if (same) return same;
21427
- if (!result.nextCursor) return null;
21428
- cursor = result.nextCursor;
21429
- }
21430
- } catch (error) {
21431
- if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
21432
- }
21433
- return null;
21434
- }
21435
21491
  /**
21436
- * The refusal names the one that exists and leaves the choice to the person: another session may have made it, so
21437
- * "use it" would hand the agent someone else's work (PRO-2800, review 6).
21438
- */
21439
- function duplicateName(kind, existing, where) {
21440
- const made = typeof existing.createdAt === "string" ? `, created ${existing.createdAt}` : "";
21441
- 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).`, {
21442
- existingId: existing.id,
21443
- ...existing.slug ? { existingSlug: existing.slug } : {}
21492
+ * The API's refusal of a taken name, as the tool's: it names the one that
21493
+ * exists when the caller may read it, and leaves the choice to the person.
21494
+ * Another session may have made it, so "use it" would hand the agent someone
21495
+ * else's work (PRO-2800, review 6). Anything else is not this refusal.
21496
+ */
21497
+ function duplicateName(kind, error, where) {
21498
+ if (!(error instanceof TaigaApiError) || error.status !== 409 || error.details?.["reason"] !== "duplicate_name") return null;
21499
+ const said = (key) => typeof error.details?.[key] === "string" ? error.details[key] : void 0;
21500
+ const [existingId, existingSlug, name, createdAt] = [
21501
+ said("existingId"),
21502
+ said("existingSlug"),
21503
+ said("existingName"),
21504
+ said("existingCreatedAt")
21505
+ ];
21506
+ return toolError("DUPLICATE_NAME", `${name ? `A ${kind} named "${name}"` : `A ${kind} with this name`} already exists ${where}${existingId ? ` (${existingId}${createdAt ? `, created ${createdAt}` : ""})` : ""}. ${existingId ? "It may not be this person's: ask whether they meant that one, want another name, or want a second" : "The caller cannot read it: ask whether they want another name or a second"} (allowDuplicateName: true).`, {
21507
+ ...existingId ? { existingId } : {},
21508
+ ...existingSlug ? { existingSlug } : {}
21444
21509
  });
21445
21510
  }
21446
21511
  /** A product row the model can act on: how it started, and while in setup what it still needs. */
@@ -21481,14 +21546,16 @@ function registerSetupTools(server, client) {
21481
21546
  }),
21482
21547
  annotations: WRITE
21483
21548
  }, (params) => runTool(async () => {
21484
- if (!params.allowDuplicateName) {
21485
- const same = await firstNamed(params.name, async (cursor) => client.listFactories({
21486
- limit: 100,
21487
- cursor
21488
- }));
21489
- if (same) return duplicateName("factory", same, "in this organization");
21549
+ try {
21550
+ return toolResult(pickFields([(await client.createFactory({
21551
+ name: params.name,
21552
+ refuseDuplicateName: !params.allowDuplicateName
21553
+ })).data], FACTORY_FIELDS)[0]);
21554
+ } catch (error) {
21555
+ const refused = duplicateName("factory", error, "in this organization");
21556
+ if (refused) return refused;
21557
+ throw error;
21490
21558
  }
21491
- return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
21492
21559
  }));
21493
21560
  server.registerTool("taiga_rename_factory", {
21494
21561
  title: "Rename a factory",
@@ -21553,22 +21620,22 @@ function registerSetupTools(server, client) {
21553
21620
  }, (params) => runTool(async () => {
21554
21621
  const contradiction = notesWithNothingToDescribe(params);
21555
21622
  if (contradiction) return contradiction;
21556
- if (!params.allowDuplicateName) {
21557
- const same = await firstNamed(params.name, async (cursor) => client.listProducts({
21623
+ let product;
21624
+ try {
21625
+ product = (await client.createProduct({
21626
+ name: params.name,
21558
21627
  factoryId: params.factoryId,
21559
- limit: 100,
21560
- cursor
21561
- }));
21562
- if (same) return duplicateName("product", same, "in this factory");
21628
+ ...params.start === "import" ? { projectType: "brownfield" } : {},
21629
+ ...params.ownsInfrastructure !== void 0 ? { ownsInfrastructure: params.ownsInfrastructure } : {},
21630
+ ...params.ownsDeployment !== void 0 ? { ownsDeployment: params.ownsDeployment } : {},
21631
+ ...params.deploymentNotes !== void 0 ? { deploymentNotes: params.deploymentNotes } : {},
21632
+ refuseDuplicateName: !params.allowDuplicateName
21633
+ })).data;
21634
+ } catch (error) {
21635
+ const refused = duplicateName("product", error, "in this factory");
21636
+ if (refused) return refused;
21637
+ throw error;
21563
21638
  }
21564
- const product = (await client.createProduct({
21565
- name: params.name,
21566
- factoryId: params.factoryId,
21567
- ...params.start === "import" ? { projectType: "brownfield" } : {},
21568
- ...params.ownsInfrastructure !== void 0 ? { ownsInfrastructure: params.ownsInfrastructure } : {},
21569
- ...params.ownsDeployment !== void 0 ? { ownsDeployment: params.ownsDeployment } : {},
21570
- ...params.deploymentNotes !== void 0 ? { deploymentNotes: params.deploymentNotes } : {}
21571
- })).data;
21572
21639
  return toolResult(await productRow(client, product));
21573
21640
  }));
21574
21641
  server.registerTool("taiga_update_product", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.25.0-rc.464",
3
+ "version": "0.25.0-rc.474",
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",
package/src/client.ts CHANGED
@@ -31,6 +31,7 @@ import type {
31
31
  MonitoringHistory,
32
32
  MoveProductResult,
33
33
  PolicyConformance,
34
+ PolicyRemediationInput,
34
35
  PolicyRemediationResult,
35
36
  Product,
36
37
  ProductDocument,
@@ -44,6 +45,8 @@ import type {
44
45
  SetupTaskListEntry,
45
46
  TenantDocument,
46
47
  UptimeReport,
48
+ CreateFactoryInput,
49
+ CreateProductInput,
47
50
  } from '@taiga/types';
48
51
  import { SERVER_VERSION } from './version.js';
49
52
 
@@ -645,19 +648,19 @@ export class TaigaClient {
645
648
  return this.request<One<PolicyConformance>>('GET', `/products/${seg(productId)}/maintenance/policies`);
646
649
  }
647
650
 
648
- async remediatePolicyControl(productId: string, controlId: string) {
651
+ async remediatePolicyControl(productId: string, controlId: string, body: PolicyRemediationInput = {}) {
649
652
  return this.request<One<PolicyRemediationResult>>(
650
653
  'POST',
651
654
  `/products/${seg(productId)}/maintenance/policies/controls/${seg(controlId)}/remediate`,
652
- {}
655
+ body
653
656
  );
654
657
  }
655
658
 
656
- async remediatePolicyViolation(productId: string, divergenceId: string) {
659
+ async remediatePolicyViolation(productId: string, divergenceId: string, body: PolicyRemediationInput = {}) {
657
660
  return this.request<One<PolicyRemediationResult>>(
658
661
  'POST',
659
662
  `/products/${seg(productId)}/maintenance/policies/divergences/${seg(divergenceId)}/remediate`,
660
- {}
663
+ body
661
664
  );
662
665
  }
663
666
 
@@ -844,7 +847,7 @@ export class TaigaClient {
844
847
  return this.request<Page<Factory>>('GET', `/factories${qs ? `?${qs}` : ''}`);
845
848
  }
846
849
 
847
- async createFactory(body: { name: string }) {
850
+ async createFactory(body: CreateFactoryInput) {
848
851
  return this.request<One<Factory>>('POST', '/factories', body);
849
852
  }
850
853
 
@@ -856,14 +859,18 @@ export class TaigaClient {
856
859
  // Setting a product up
857
860
  // ---------------------------------------------------------------------------
858
861
 
859
- async createProduct(body: {
860
- name: string;
861
- factoryId: string;
862
- projectType?: 'brownfield' | undefined;
863
- ownsInfrastructure?: boolean | null | undefined;
864
- ownsDeployment?: boolean | null | undefined;
865
- deploymentNotes?: string | undefined;
866
- }) {
862
+ async createProduct(
863
+ body: Pick<
864
+ CreateProductInput,
865
+ | 'name'
866
+ | 'factoryId'
867
+ | 'projectType'
868
+ | 'ownsInfrastructure'
869
+ | 'ownsDeployment'
870
+ | 'deploymentNotes'
871
+ | 'refuseDuplicateName'
872
+ >
873
+ ) {
867
874
  return this.request<One<Product>>('POST', '/products', body);
868
875
  }
869
876