@taiga-ai/mcp-server 0.23.0-rc.460 → 0.24.0-dev.461

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
@@ -583,14 +583,14 @@ const OPERATE_SETTING = {
583
583
  * schema's message, not the description, when a call fails validation, and
584
584
  * "Invalid UUID" alone sends a model that passed a name to guess again.
585
585
  */
586
- function idInput(what, 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}`);
586
+ function idInput(what, from, said = from) {
587
+ return z.string().uuid({ message: `${/^[aeiou]/i.test(what) ? "An" : "A"} ${what} ID is a UUID, not a name: ${from}` }).meta({ pattern: void 0 }).describe(`${what[0].toUpperCase()}${what.slice(1)} ID -- ${said}`);
588
588
  }
589
- const productId = idInput("product", "taiga_list_products (query finds one by name), or taiga_get_product with a git remote URL, gives it");
589
+ const productId = idInput("product", "taiga_list_products (query finds one by name), or taiga_get_product with a git remote URL, gives it", "taiga_list_products gives it");
590
590
  /** The page inputs every list tool takes; `nextCursor` of one page is the `cursor` of the next. */
591
591
  const pagination = {
592
592
  limit: z.number().int().min(1).max(100).optional().describe("Max results (default 25)"),
593
- cursor: z.string().max(256).optional().describe("The nextCursor of the previous page, to read the next one; omit for the first page")
593
+ cursor: z.string().max(256).optional().describe("The previous page's nextCursor; omit for the first page")
594
594
  };
595
595
  const slug = z.string().max(63).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "A slug is lowercase words joined by hyphens").describe("Product slug, as it appears in Taiga URLs");
596
596
  const remoteUrl = z.string().min(1).max(2048).describe("The git remote URL of a checkout (git remote get-url origin): https://, git@host:owner/repo.git or ssh://");
@@ -2278,9 +2278,9 @@ const BY_ARGUMENT = {
2278
2278
  initiativeId: ITEM_ID_HINT,
2279
2279
  runId: "taiga_list_runs lists the runs, of one product or of every product the key can see.",
2280
2280
  planId: "taiga_get_plan gives the initiative's active plan and its ID.",
2281
- repositoryId: "taiga_list_product_repositories lists the product's repository links; a GitHub repository id is not a link ID.",
2281
+ repositoryId: "taiga_list_product_repositories lists the product's repository links; a linkable repository id is not a link ID.",
2282
2282
  replacesRepositoryId: "taiga_list_product_repositories lists the product's repository links.",
2283
- githubRepositoryId: "taiga_list_linkable_repositories lists the repositories the GitHub connection reaches, with their ids.",
2283
+ linkableRepositoryId: "taiga_list_linkable_repositories lists the repositories the GitHub connection reaches, with their ids.",
2284
2284
  installationId: "taiga_list_linkable_repositories lists the connected GitHub accounts.",
2285
2285
  jobId: "A job ID is what the tool that started the job returned (taiga_request_initiatives, taiga_start_import).",
2286
2286
  factoryId: "taiga_list_factories lists the factories the key can see.",
@@ -2307,7 +2307,7 @@ const MISSING_THING = {
2307
2307
  planId: /\bplan\b/i,
2308
2308
  repositoryId: /repository link|\blink\b/i,
2309
2309
  replacesRepositoryId: /repository link|\blink\b/i,
2310
- githubRepositoryId: /github repository/i,
2310
+ linkableRepositoryId: /github repository/i,
2311
2311
  installationId: /installation|account/i,
2312
2312
  jobId: /\bjob\b/i,
2313
2313
  factoryId: /factory|team/i,
@@ -2365,10 +2365,24 @@ const OPAQUE_MAPS = /* @__PURE__ */ new Set([
2365
2365
  "settings"
2366
2366
  ]);
2367
2367
  /** A copy of an API value with its keys in the product's words, values untouched. */
2368
+ /**
2369
+ * Run actions only the web offers (PRO-2800): start_over was listed beside what an agent can call, on runs and on
2370
+ * every initiative's latest run, while the descriptions said it was web only. Split apart wherever actions appear.
2371
+ */
2372
+ const WEB_ONLY_ACTIONS = /* @__PURE__ */ new Set(["start_over"]);
2368
2373
  function inProductWords(value) {
2369
2374
  if (Array.isArray(value)) return value.map(inProductWords);
2370
2375
  if (value === null || typeof value !== "object") return value;
2371
2376
  const out = {};
2377
+ const actions = value["actions"];
2378
+ if (Array.isArray(actions) && actions.some((action) => WEB_ONLY_ACTIONS.has(String(action)))) {
2379
+ const webOnly = actions.filter((action) => WEB_ONLY_ACTIONS.has(String(action)));
2380
+ value = {
2381
+ ...value,
2382
+ actions: actions.filter((action) => !WEB_ONLY_ACTIONS.has(String(action))),
2383
+ webOnlyActions: webOnly
2384
+ };
2385
+ }
2372
2386
  for (const [key, inner] of Object.entries(value)) Object.defineProperty(out, PRODUCT_WORDS[key] ?? key, {
2373
2387
  value: OPAQUE_MAPS.has(key) ? inner : inProductWords(inner),
2374
2388
  enumerable: true,
@@ -2684,6 +2698,24 @@ function controlSections(sections, ids) {
2684
2698
  return found;
2685
2699
  }
2686
2700
  /**
2701
+ * A policy outline names the controls each section states (PRO-2800): a reviewer guessed secretsManagement from its
2702
+ * name, which works for secrets and not for "what covers logging of secret access". Read off each section's text.
2703
+ */
2704
+ function withControls(outline, sections, policyType) {
2705
+ const pattern = new RegExp(`^\\|\\s*(${CONTROL_ID_SOURCE})\\s*\\|\\s*(.*?)\\s*(?:\\||$)`, "gm");
2706
+ return outline.map((entry) => {
2707
+ const text = sectionContent(sections, entry.id) ?? "";
2708
+ const ids = [...new Set([...text.matchAll(pattern)].filter((match) => !/^_?Reserved_?/i.test(match[2] ?? "")).map((match) => match[1]).filter((id) => {
2709
+ const owner = POLICY_TYPE_BY_CONTROL_PREFIX[id.split("-")[0] ?? ""];
2710
+ return owner === void 0 || owner === policyType;
2711
+ }))];
2712
+ return ids.length > 0 ? {
2713
+ ...entry,
2714
+ controls: ids
2715
+ } : entry;
2716
+ });
2717
+ }
2718
+ /**
2687
2719
  * The answer to a read that names sections: the ones the document has, the
2688
2720
  * rest listed with their sizes, and the asked-for ids it lacks under
2689
2721
  * unknownSections. One shape whatever matched (PRO-2778): a wrong id does not
@@ -2708,7 +2740,7 @@ function sectionReadResult(doc, asked, label, options = {}) {
2708
2740
  const { data: _data, ...meta } = cut.doc;
2709
2741
  return toolResult({
2710
2742
  ...meta,
2711
- outline: cut.omittedSections,
2743
+ outline: options.controlIds && before ? withControls(cut.omittedSections, before, doc.type) : cut.omittedSections,
2712
2744
  ...unknownSections.length > 0 ? { unknownSections } : {},
2713
2745
  ...controlNote
2714
2746
  });
@@ -3848,6 +3880,20 @@ const STEP_SUMMARY_FIELDS = [
3848
3880
  "startedAt",
3849
3881
  "completedAt"
3850
3882
  ];
3883
+ /**
3884
+ * A step a failed run never reached reads as planned, as it was (PRO-2800): a run rejected before it began showed
3885
+ * each step failed with "still in flight", though none had started. The API reaches failed only through in_progress,
3886
+ * which sets startedAt, so a failed step without one is a planned step the run's failure swept up.
3887
+ */
3888
+ function neverStarted(step) {
3889
+ if (step["status"] !== "failed" || step["startedAt"]) return step;
3890
+ const { error: _error, ...rest } = step;
3891
+ return {
3892
+ ...rest,
3893
+ status: "planned",
3894
+ completedAt: null
3895
+ };
3896
+ }
3851
3897
  /** Past this many characters a step's headline is cut; the full read has the rest. */
3852
3898
  const HEADLINE_LIMIT = 600;
3853
3899
  /**
@@ -3917,7 +3963,7 @@ function registerBuildTools(server, client) {
3917
3963
  }));
3918
3964
  server.registerTool("taiga_get_run", {
3919
3965
  title: "Get run",
3920
- description: "Read one run with its steps: outcome, disposition, pull request, hardening, and per step its title, status, commit, files changed, verification and headline (first paragraph of the agent's report). verification is Taiga's own check after the step (tests, lint, build), so a docs-only step can fail on the repository's failing tests (verificationNote says when one failed throughout). A failed or stopped run has stoppedIn, lastProgressAt and failedReasonMeans (what its failedReason means). full: true adds complete instructions and reports. actions: resume and retry_from_failed_step are taiga_resume_initiative; start_over is web only; to stop it, move the initiative out of Build. A maintenance run has no initiative and is retried from its finding.",
3966
+ description: "Read one run with its steps: outcome, disposition, pull request, hardening, and per step its title, status, commit, files changed, verification and headline (first paragraph of the agent's report). verification is Taiga's own check after the step (tests, lint, build), so a docs-only step can fail on the repository's failing tests (verificationNote says when one failed throughout). A failed or stopped run has stoppedIn, lastProgressAt and failedReasonMeans (what its failedReason means). full: true adds complete instructions and reports. actions: resume and retry_from_failed_step are taiga_resume_initiative; webOnlyActions (start_over) are in the web; to stop it, move the initiative out of Build. A maintenance run has no initiative and is retried from its finding.",
3921
3967
  inputSchema: z.object({
3922
3968
  runId,
3923
3969
  full: z.boolean().optional().describe("Carry each step's complete instructions and report (default false: a summary)")
@@ -3926,7 +3972,7 @@ function registerBuildTools(server, client) {
3926
3972
  }, (params) => runTool(async () => {
3927
3973
  const [run, steps] = await Promise.all([client.getBuildRun(params.runId), client.listBuildRunSteps(params.runId)]);
3928
3974
  const runRow = run.data;
3929
- const rows = steps.data;
3975
+ const rows = steps.data.map((step) => neverStarted(step));
3930
3976
  const where = stoppedIn(runRow, rows);
3931
3977
  const stop = where ? { stoppedIn: where } : {};
3932
3978
  const states = await initiativeStates(client, [runRow]);
@@ -3937,7 +3983,7 @@ function registerBuildTools(server, client) {
3937
3983
  ...readable,
3938
3984
  ...stop,
3939
3985
  ...verification,
3940
- steps: steps.data
3986
+ steps: rows
3941
3987
  });
3942
3988
  return toolResult({
3943
3989
  ...readable,
@@ -4423,13 +4469,22 @@ function registerBacklogReads(server, client) {
4423
4469
  return toolResult(page(situatedRows(result.data), result.nextCursor));
4424
4470
  }
4425
4471
  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.");
4472
+ let closedSeen = 0;
4473
+ const needle = (query ?? "").toLowerCase();
4426
4474
  const found = await searchPages((cursor) => client.listBacklogItems(params.productId, {
4427
4475
  ...apiFilters,
4428
4476
  limit: 100,
4429
4477
  cursor
4430
- }), query ?? "", (row) => [row.title], params.limit ?? 25, (row) => (state === void 0 || row.state === state) && (!open || !["done", "canceled"].includes(row.status)));
4478
+ }), query ?? "", (row) => [row.title], params.limit ?? 25, (row) => {
4479
+ if (state !== void 0 && row.state !== state) return false;
4480
+ if (!open || !["done", "canceled"].includes(row.status)) return true;
4481
+ if (query !== void 0 && String(row.title).toLowerCase().includes(needle)) closedSeen += 1;
4482
+ return false;
4483
+ });
4484
+ const hidden = query !== void 0 && state === void 0 && !found.more ? closedSeen : 0;
4431
4485
  return toolResult({
4432
4486
  ...page(situatedRows(found.rows), null),
4487
+ ...hidden > 0 ? { closedMatches: `${hidden} done or canceled initiative${hidden === 1 ? " also matches" : "s also match"}; status: 'all' lists them.` } : {},
4433
4488
  ...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." } : {}
4434
4489
  });
4435
4490
  }));
@@ -4806,7 +4861,8 @@ function registerMaintenanceTools(server, client) {
4806
4861
  sweepNote: "The sweep overview could not be read: an empty list here is not an all-clear."
4807
4862
  } : sweep ? { sweep } : {},
4808
4863
  ...page(pickFields(result.data, FINDING_ROW_FIELDS).map(withFixability), result.nextCursor),
4809
- ...typeof result.totalCount === "number" ? { totalCount: result.totalCount } : {}
4864
+ ...typeof result.totalCount === "number" ? { totalCount: result.totalCount } : {},
4865
+ ...params.status === void 0 && params.severity === void 0 && params.kind === void 0 && typeof result.totalCount === "number" && sweep && sweep !== "unread" && typeof sweep["openFindings"] === "number" && sweep["openFindings"] !== result.totalCount ? { totalNote: "totalCount includes findings with a fix under way (remediating) and ignored findings whose ignore has expired; sweep.openFindings counts open ones only." } : {}
4810
4866
  });
4811
4867
  }));
4812
4868
  server.registerTool("taiga_get_finding", {
@@ -4988,7 +5044,10 @@ function readWith(row, backlogRead) {
4988
5044
  switch (row["subjectType"]) {
4989
5045
  case "build_run": return "taiga_get_run (runId: subjectId)";
4990
5046
  case "backlog_item": return backlogRead ? "taiga_get_plan (initiativeId: subjectId)" : void 0;
4991
- case "product_document": return "taiga_list_product_documents (productId)";
5047
+ case "product_document": {
5048
+ const type = /^[^:]+:([a-z_]+):decision:/.exec(String(row["subjectId"]))?.[1];
5049
+ return type ? `taiga_get_product_document (productId, type: ${type}); the decision's text is this row with full: true` : "taiga_list_product_documents (productId)";
5050
+ }
4992
5051
  case "inbox_task": return "taiga_list_setup_tasks (productId)";
4993
5052
  case "product": return {
4994
5053
  review_backlog: backlogRead ? "taiga_list_initiatives (productId)" : void 0,
@@ -5270,6 +5329,7 @@ function registerInboxTools(server, client) {
5270
5329
  } : item;
5271
5330
  }), result.nextCursor),
5272
5331
  ...totalOf(result),
5332
+ ...overview && typeof result.totalCount === "number" && (stale.size > 0 || overview.data.length > overviewDue.length || overview.nextCursor) ? { totalNote: "totalCount counts every item, stale and snoozed ones too; summary.open does not" + (overview.nextCursor ? `, and counts only the first ${SUMMARY_PAGE}.` : ".") } : {},
5273
5333
  ...hidden > 0 ? { snoozedHidden: hidden } : {},
5274
5334
  ...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).` } : {}
5275
5335
  });
@@ -21012,7 +21072,7 @@ const GITHUB_REPOSITORY_FIELDS = [
21012
21072
  "language",
21013
21073
  "linked"
21014
21074
  ];
21015
- const repositoryId = idInput("repository link", "taiga_list_product_repositories gives it; a GitHub repository id is not one");
21075
+ const repositoryId = idInput("repository link", "taiga_list_product_repositories gives it; a linkable repository id is not one");
21016
21076
  /** Round-robin over lists: the first of each, then the second of each, and so on. */
21017
21077
  function interleave(lists) {
21018
21078
  const out = [];
@@ -21021,6 +21081,28 @@ function interleave(lists) {
21021
21081
  return out;
21022
21082
  }
21023
21083
  /**
21084
+ * A repository linked elsewhere names the product that holds it (PRO-2800): the API says "another product", and a
21085
+ * reviewer spent a call finding which. One read, the products the key can see; null leaves the API's refusal.
21086
+ */
21087
+ async function holderOf(client, error, productId) {
21088
+ if (!(error instanceof TaigaApiError) || error.status !== 409) return null;
21089
+ const name = /^(\S+\/\S+) is already linked to another product\.?$/.exec(error.apiMessage ?? "")?.[1];
21090
+ if (!name) return null;
21091
+ try {
21092
+ const holders = (await client.listProducts({
21093
+ repository: name,
21094
+ limit: 20
21095
+ })).data.filter((p) => p.id !== productId);
21096
+ return toolError("CONFLICT", `${name} is already linked to another product. ${holders.length > 0 ? `Products this key can see with a repository named ${name}: ${holders.map((p) => `${p.name} (${p.id})`).join(", ")}.` : "It is linked to a product this key cannot see."} A repository belongs to one product; unlink it there first (taiga_unlink_repository) if this product should have it.`, { holders: holders.map((p) => ({
21097
+ productId: p.id,
21098
+ name: p.name
21099
+ })) });
21100
+ } catch (inner) {
21101
+ if (inner instanceof TaigaApiError || isNetworkFailure(inner)) return null;
21102
+ throw inner;
21103
+ }
21104
+ }
21105
+ /**
21024
21106
  * A product in setup says what it still needs after a repository write, as create_product and get_product do
21025
21107
  * (PRO-2800): a reviewer had to read the product again to learn that the import came next. Best effort: the write
21026
21108
  * is done, so a failed read leaves readiness out rather than failing it.
@@ -21066,7 +21148,7 @@ function registerRepositoryTools(server, client) {
21066
21148
  }
21067
21149
  if (client.hasCapability("vcs:read")) server.registerTool("taiga_list_linkable_repositories", {
21068
21150
  title: "Find a repository to link",
21069
- description: "List the repositories the organization's GitHub connection can reach, to link one with taiga_link_repository (it takes the row's id: Taiga's record of the repository, not GitHub's numeric id, and not a link ID). 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.",
21151
+ 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.",
21070
21152
  inputSchema: z.object({
21071
21153
  query: z.string().trim().min(1).max(256).optional().describe("Part of the repository name"),
21072
21154
  account: z.string().trim().min(1).max(140).optional().describe("The GitHub account login (the owner in owner/repo), case-insensitive"),
@@ -21116,16 +21198,24 @@ function registerRepositoryTools(server, client) {
21116
21198
  description: "Link a GitHub repository to a product (taiga_list_linkable_repositories gives its id). replacesRepositoryId swaps out the one linked now in one step (the same one changes nothing); linking while one is linked without it is refused. A managed link (isManaged) cannot be replaced or unlinked. Once linked, planning reads its code, runs open pull requests in it, sweeps scan it, and its deployment history is imported.",
21117
21199
  inputSchema: z.object({
21118
21200
  productId,
21119
- githubRepositoryId: idInput("GitHub repository", "taiga_list_linkable_repositories gives it (the row's id, Taiga's record; not GitHub's numeric id)"),
21201
+ linkableRepositoryId: idInput("linkable repository", "taiga_list_linkable_repositories gives it (a row's id; not GitHub's numeric id, not a link ID)"),
21120
21202
  replacesRepositoryId: repositoryId.optional().describe("The link this one replaces")
21121
21203
  }),
21122
21204
  annotations: OPERATE
21123
21205
  }, (params) => runTool(async () => {
21124
- return toolResult({
21125
- ...pickFields([(await client.linkRepository(params.productId, {
21126
- githubRepositoryId: params.githubRepositoryId,
21206
+ let linked;
21207
+ try {
21208
+ linked = await client.linkRepository(params.productId, {
21209
+ githubRepositoryId: params.linkableRepositoryId,
21127
21210
  ...params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}
21128
- })).data], LINK_FIELDS)[0],
21211
+ });
21212
+ } catch (error) {
21213
+ const holder = await holderOf(client, error, params.productId);
21214
+ if (holder) return holder;
21215
+ throw error;
21216
+ }
21217
+ return toolResult({
21218
+ ...pickFields([linked.data], LINK_FIELDS)[0],
21129
21219
  ...await readinessAfter(client, params.productId)
21130
21220
  });
21131
21221
  }));
@@ -22090,8 +22180,8 @@ function registerWhoami(server, client, registered) {
22090
22180
  * (PRO-2800).
22091
22181
  */
22092
22182
  const SERVER_INSTRUCTIONS = [
22093
- "Taiga is a policy-aware software delivery platform. Use these tools (taiga_*) when the person asks about a Taiga",
22094
- "product, its initiatives or runs, policies, findings, deployments, or setting a product up.",
22183
+ "Taiga is a policy-aware software delivery platform. Use these tools (taiga_*) for anything about a Taiga",
22184
+ "product: its initiatives, runs, policies, findings, deployments or setup.",
22095
22185
  "",
22096
22186
  "**Which read.** Where a product stands (counts, not detail): taiga_get_product_context, by git remote URL, slug",
22097
22187
  "or ID. What waits on people: taiga_list_action_required. A policy: taiga_get_document (sections: [], or a",
@@ -22104,17 +22194,17 @@ const SERVER_INSTRUCTIONS = [
22104
22194
  "a person makes; a sweep is a scan for findings; standing is how a product meets the policies. Discovery is the web",
22105
22195
  "conversation that specifies a new product. A factory owns products. People stamp checks in the web.",
22106
22196
  "",
22107
- "**IDs** are UUIDs; the product, factory and initiative lists take query to find one by name. A NOT_FOUND names the tool that lists the right",
22108
- "ones. Lists page with nextCursor. outcome, situation and nextStep are one-sentence summaries; setup writes",
22109
- "return readiness with nextStep, so there is no need to read the product again. A product document has two",
22110
- "timelines: plan (what was meant) and reality (derived from the code).",
22197
+ "**IDs** are UUIDs; the product, factory and initiative lists take query to find one by name. A NOT_FOUND names",
22198
+ "the tool that lists the right ones. Lists page with nextCursor; taiga_list_initiatives lists open ones unless",
22199
+ "status: 'all'. outcome, situation and nextStep are one-sentence summaries; setup writes return readiness. A",
22200
+ "product document has two timelines: plan (what was meant) and reality (derived from the code).",
22111
22201
  "",
22112
22202
  "**Writing.** A tool that writes opens with \"Writes.\", \"Ask first (paid agent work).\" or \"Ask first (others",
22113
22203
  "see it).\"; before an ask-first tool, tell the person and wait for their yes. Others share the organization: a",
22114
22204
  "taken name is refused unless allowDuplicateName, and the one that exists may not be this person's.",
22115
22205
  "",
22116
- "**Only in the web:** connecting GitHub, members and roles, Discovery, stamping checks, starting a run over, and",
22117
- "deleting factories and products."
22206
+ "**Only in the web:** connecting GitHub, members and roles, the Discovery conversation, stamping checks, starting",
22207
+ "a run over, and deleting factories and products."
22118
22208
  ].join("\n");
22119
22209
  //#endregion
22120
22210
  //#region src/server.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.23.0-rc.460",
3
+ "version": "0.24.0-dev.461",
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",
@@ -3553,7 +3553,9 @@ describe('MCP server integration', () => {
3553
3553
  completedSteps: 1,
3554
3554
  totalSteps: 3,
3555
3555
  prUrl: null,
3556
- actions: ['resume', 'start_over'],
3556
+ // start_over is the web's, said apart from what this server can call.
3557
+ actions: ['resume'],
3558
+ webOnlyActions: ['start_over'],
3557
3559
  disposition: 'stopped',
3558
3560
  outcome: 'Stopped before it finished (1 of 3 steps done).',
3559
3561
  });
@@ -6000,7 +6002,7 @@ describe('MCP server integration', () => {
6000
6002
  );
6001
6003
  await client.callTool({
6002
6004
  name: 'taiga_link_repository',
6003
- arguments: { productId: PRODUCT_ID, githubRepositoryId: GH_REPO_ID },
6005
+ arguments: { productId: PRODUCT_ID, linkableRepositoryId: GH_REPO_ID },
6004
6006
  });
6005
6007
  const set = parseToolResult(
6006
6008
  await client.callTool({
@@ -6209,7 +6211,7 @@ describe('MCP server integration', () => {
6209
6211
  });
6210
6212
  const link = await client.callTool({
6211
6213
  name: 'taiga_link_repository',
6212
- arguments: { productId: PRODUCT_ID, githubRepositoryId: GH_REPO_ID },
6214
+ arguments: { productId: PRODUCT_ID, linkableRepositoryId: GH_REPO_ID },
6213
6215
  });
6214
6216
 
6215
6217
  expect(finish.isError).toBe(true);
@@ -6433,7 +6435,7 @@ describe('what the agent review found (PRO-2759)', () => {
6433
6435
  // The API names the link, so only the link is hinted: the product the call
6434
6436
  // also named was right, and its hint read as blaming it (PRO-2800).
6435
6437
  expect(parseToolResult(branches).message).toBe(
6436
- "Repository link not found. taiga_list_product_repositories lists the product's repository links; a GitHub " +
6438
+ "Repository link not found. taiga_list_product_repositories lists the product's repository links; a linkable " +
6437
6439
  'repository id is not a link ID.'
6438
6440
  );
6439
6441
  });
@@ -6456,7 +6458,7 @@ describe('what the agent review found (PRO-2759)', () => {
6456
6458
  });
6457
6459
  expect(parseToolResult(branches).message).toBe(
6458
6460
  "The requested resource does not exist. taiga_list_product_repositories lists the product's repository " +
6459
- 'links; a GitHub repository id is not a link ID. taiga_list_products lists the products the key can see; ' +
6461
+ 'links; a linkable repository id is not a link ID. taiga_list_products lists the products the key can see; ' +
6460
6462
  'a name is not an ID (taiga_get_product takes a slug).'
6461
6463
  );
6462
6464
  });
@@ -8253,7 +8255,7 @@ describe('what the fourth agent review found (PRO-2800)', () => {
8253
8255
  const linked = parseToolResult(
8254
8256
  await client.callTool({
8255
8257
  name: 'taiga_link_repository',
8256
- arguments: { productId: PRODUCT_ID, githubRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
8258
+ arguments: { productId: PRODUCT_ID, linkableRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
8257
8259
  })
8258
8260
  );
8259
8261
  expect(linked.readiness?.nextStep).toEqual(expect.any(String));
@@ -8271,7 +8273,7 @@ describe('what the fourth agent review found (PRO-2800)', () => {
8271
8273
  const refused = parseToolResult(
8272
8274
  await second.callTool({
8273
8275
  name: 'taiga_link_repository',
8274
- arguments: { productId: PRODUCT_ID, githubRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
8276
+ arguments: { productId: PRODUCT_ID, linkableRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
8275
8277
  })
8276
8278
  );
8277
8279
  expect(refused.message).toBe(
@@ -8590,3 +8592,225 @@ describe('what the sixth agent review found (PRO-2800)', () => {
8590
8592
  expect(run).not.toHaveProperty('runtime');
8591
8593
  });
8592
8594
  });
8595
+
8596
+ describe('what the seventh agent review found (PRO-2800)', () => {
8597
+ beforeEach(() => {
8598
+ vi.restoreAllMocks();
8599
+ });
8600
+
8601
+ it('says an ID is a UUID by format, not by a pattern repeated on every ID', async () => {
8602
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
8603
+
8604
+ const { tools } = await client.listTools();
8605
+ const listing = JSON.stringify(tools);
8606
+ const refused = await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: 'my item' } });
8607
+
8608
+ expect(listing).not.toMatch(/"pattern":"\^\(\[0-9a-fA-F\]\{8\}/);
8609
+ expect(listing).toMatch(/"format":"uuid"/);
8610
+ expect(JSON.stringify(refused.content)).toMatch(/An initiative ID is a UUID/);
8611
+ });
8612
+
8613
+ it('says when a title search under the open default also matches done or canceled initiatives', async () => {
8614
+ const row = (id: string, title: string, status: string) => ({ id, title, status, state: status });
8615
+ const { client } = await bootMcpPair({
8616
+ capabilities: ['backlog:read'],
8617
+ routes: {
8618
+ [`/v1/products/${PRODUCT_ID}/backlog`]: {
8619
+ data: [
8620
+ row('a', 'Secret detection in CI', 'done'),
8621
+ row('b', 'Secret detection v2', 'canceled'),
8622
+ row('c', 'Docs', 'backlog'),
8623
+ ],
8624
+ nextCursor: null,
8625
+ },
8626
+ },
8627
+ });
8628
+
8629
+ const result = parseToolResult(
8630
+ await client.callTool({
8631
+ name: 'taiga_list_initiatives',
8632
+ arguments: { productId: PRODUCT_ID, query: 'secret detection' },
8633
+ })
8634
+ );
8635
+
8636
+ expect(result.data).toEqual([]);
8637
+ expect(result.closedMatches).toBe("2 done or canceled initiatives also match; status: 'all' lists them.");
8638
+ });
8639
+
8640
+ it('shows a step a failed run never reached as planned, and stoppedIn agrees', async () => {
8641
+ const { client } = await bootMcpPair({
8642
+ capabilities: ['products:read'],
8643
+ routes: {
8644
+ [`/v1/builds/${BUILD_ID}/steps`]: {
8645
+ data: [
8646
+ {
8647
+ id: 's1',
8648
+ stepOrder: 1,
8649
+ title: 'One',
8650
+ status: 'failed',
8651
+ startedAt: null,
8652
+ completedAt: '2026-10-10T10:00:00Z',
8653
+ error: 'still in flight',
8654
+ },
8655
+ ],
8656
+ },
8657
+ [`/v1/builds/${BUILD_ID}`]: {
8658
+ data: { id: BUILD_ID, productId: PRODUCT_ID, status: 'failed', failedReason: 'dispatch_rejected' },
8659
+ },
8660
+ },
8661
+ });
8662
+
8663
+ const run = parseToolResult(await client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } }));
8664
+
8665
+ expect(run.steps[0].status).toBe('planned');
8666
+ expect(run.steps[0].completedAt).toBeNull();
8667
+ expect(run.steps[0]).not.toHaveProperty('error');
8668
+ // stoppedIn reads the same steps, so it says the run stopped before any began.
8669
+ expect(run.stoppedIn).toMatch(/^before the first step started/);
8670
+ });
8671
+
8672
+ it("names the document a document decision is about, since its subject id is no tool's input", async () => {
8673
+ const { client } = await bootMcpPair({
8674
+ capabilities: ['products:read'],
8675
+ routes: {
8676
+ 'GET /v1/inbox': {
8677
+ data: [
8678
+ {
8679
+ id: 'd1',
8680
+ productId: PRODUCT_ID,
8681
+ kind: 'review_doc',
8682
+ subjectType: 'product_document',
8683
+ subjectId: `${PRODUCT_ID}:risk_register:decision:b389e792d793`,
8684
+ title: 'Decide',
8685
+ status: 'open',
8686
+ createdAt: new Date().toISOString(),
8687
+ },
8688
+ ],
8689
+ nextCursor: null,
8690
+ },
8691
+ },
8692
+ });
8693
+
8694
+ const list = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
8695
+
8696
+ expect(list.data[0].readWith).toBe(
8697
+ "taiga_get_product_document (productId, type: risk_register); the decision's text is this row with full: true"
8698
+ );
8699
+ });
8700
+
8701
+ it('names the controls each section of a policy outline states', async () => {
8702
+ const { client } = await bootMcpPair({
8703
+ capabilities: ALL_CAPABILITIES,
8704
+ routes: {
8705
+ 'GET /v1/tenants/current/documents/policy_security': {
8706
+ data: {
8707
+ id: 'p',
8708
+ type: 'policy_security',
8709
+ version: 1,
8710
+ isDraft: false,
8711
+ data: {
8712
+ sections: {
8713
+ scope: { content: 'All systems.' },
8714
+ secretsManagement: { content: '| SEC-5.1 | Vault |\n| SEC-5.3 | Log access | see SEC-5.1. |' },
8715
+ telecom: { content: '| TEL-TIC-1.1 | Lawful intercept |\n| TEL-TIC-1.2 | _Reserved_ |' },
8716
+ // A data control in the security policy is a stale mention, not this policy's control.
8717
+ stray: { content: '| DATA-1.1 | Classify data |' },
8718
+ },
8719
+ },
8720
+ },
8721
+ },
8722
+ },
8723
+ });
8724
+
8725
+ const outline = parseToolResult(
8726
+ await client.callTool({ name: 'taiga_get_document', arguments: { type: 'policy_security', sections: [] } })
8727
+ ).outline;
8728
+
8729
+ expect(outline.map((entry: { id: string; controls?: string[] }) => [entry.id, entry.controls ?? []])).toEqual([
8730
+ ['scope', []],
8731
+ ['secretsManagement', ['SEC-5.1', 'SEC-5.3']],
8732
+ ['telecom', ['TEL-TIC-1.1']],
8733
+ ['stray', []],
8734
+ ]);
8735
+ });
8736
+
8737
+ it('names the product a repository is already linked to', async () => {
8738
+ const { client } = await bootMcpPair({
8739
+ capabilities: ALL_CAPABILITIES,
8740
+ routes: {
8741
+ [`POST /v1/products/${PRODUCT_ID}/repositories`]: refusal(
8742
+ 409,
8743
+ 'CONFLICT',
8744
+ 'taiga-sandbox/import-test-chi is already linked to another product.'
8745
+ ),
8746
+ 'GET /v1/products': { data: [{ id: PRODUCT_ID_2, name: 'Imported' }], nextCursor: null },
8747
+ },
8748
+ });
8749
+
8750
+ const result = parseToolResult(
8751
+ await client.callTool({
8752
+ name: 'taiga_link_repository',
8753
+ arguments: { productId: PRODUCT_ID, linkableRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
8754
+ })
8755
+ );
8756
+
8757
+ expect(result.error).toBe('CONFLICT');
8758
+ expect(result.message).toMatch(
8759
+ new RegExp(
8760
+ `Products this key can see with a repository named taiga-sandbox/import-test-chi: Imported \\(${PRODUCT_ID_2}\\)\\.`
8761
+ )
8762
+ );
8763
+ expect(result.details.holders).toEqual([{ productId: PRODUCT_ID_2, name: 'Imported' }]);
8764
+ });
8765
+
8766
+ it('says what totalCount counts beside an Action Required summary that counts differently, and only then', async () => {
8767
+ const item = (id: string, extra: Record<string, unknown> = {}) => ({
8768
+ id,
8769
+ productId: PRODUCT_ID,
8770
+ productName: 'Web',
8771
+ kind: 'setup_secret',
8772
+ subjectType: 'inbox_task',
8773
+ subjectId: id,
8774
+ title: id,
8775
+ status: 'open',
8776
+ createdAt: new Date().toISOString(),
8777
+ ...extra,
8778
+ });
8779
+ const boot = (data: unknown[]) =>
8780
+ bootMcpPair({
8781
+ capabilities: ['products:read'],
8782
+ routes: { 'GET /v1/inbox': { data, nextCursor: null, totalCount: data.length } },
8783
+ });
8784
+
8785
+ const plain = await boot([item('a'), item('b')]);
8786
+ const quiet = parseToolResult(await plain.client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
8787
+ const snoozed = await boot([item('a'), item('b', { status: 'snoozed', snoozedUntil: '2999-01-01T00:00:00Z' })]);
8788
+ const said = parseToolResult(await snoozed.client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
8789
+
8790
+ expect(quiet).not.toHaveProperty('totalNote');
8791
+ expect(said.totalNote).toBe('totalCount counts every item, stale and snoozed ones too; summary.open does not.');
8792
+ });
8793
+
8794
+ it('says what the findings totalCount counts beside the sweep count, unfiltered only', async () => {
8795
+ const routes = {
8796
+ [`/v1/products/${PRODUCT_ID}/maintenance/findings`]: { data: [{ id: 'f1' }], nextCursor: null, totalCount: 33 },
8797
+ [`/v1/products/${PRODUCT_ID}/maintenance/summary`]: { data: { openFindings: 31 } },
8798
+ [`/v1/products/${PRODUCT_ID}/maintenance/runs`]: { data: [{ id: 's1', status: 'completed' }], nextCursor: null },
8799
+ };
8800
+ const { client } = await bootMcpPair({ capabilities: ['products:read'], routes });
8801
+
8802
+ const all = parseToolResult(
8803
+ await client.callTool({ name: 'taiga_list_findings', arguments: { productId: PRODUCT_ID } })
8804
+ );
8805
+ const critical = parseToolResult(
8806
+ await client.callTool({ name: 'taiga_list_findings', arguments: { productId: PRODUCT_ID, severity: 'critical' } })
8807
+ );
8808
+ const secrets = parseToolResult(
8809
+ await client.callTool({ name: 'taiga_list_findings', arguments: { productId: PRODUCT_ID, kind: 'secret' } })
8810
+ );
8811
+
8812
+ expect(all.totalNote).toMatch(/^totalCount includes findings with a fix under way/);
8813
+ expect(critical).not.toHaveProperty('totalNote');
8814
+ expect(secrets).not.toHaveProperty('totalNote');
8815
+ });
8816
+ });
@@ -369,15 +369,31 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
369
369
  '(or one status) to page through every item.'
370
370
  );
371
371
  }
372
+ // A title search under the open default said nothing of the done and canceled ones it matched: a
373
+ // reviewer searched "secret detection", got [], and concluded the work never existed (PRO-2800). Counted
374
+ // in the same pass; exact when the pass read every page.
375
+ let closedSeen = 0;
376
+ const needle = (query ?? '').toLowerCase();
372
377
  const found = await searchPages(
373
378
  (cursor) => client.listBacklogItems(params.productId, { ...apiFilters, limit: SEARCH_PAGE_SIZE, cursor }),
374
379
  query ?? '',
375
380
  (row) => [row.title],
376
381
  params.limit ?? 25,
377
- (row) => (state === undefined || row.state === state) && (!open || !['done', 'canceled'].includes(row.status))
382
+ (row) => {
383
+ if (state !== undefined && row.state !== state) return false;
384
+ if (!open || !['done', 'canceled'].includes(row.status)) return true;
385
+ if (query !== undefined && String(row.title).toLowerCase().includes(needle)) closedSeen += 1;
386
+ return false;
387
+ }
378
388
  );
389
+ const hidden = query !== undefined && state === undefined && !found.more ? closedSeen : 0;
379
390
  return toolResult({
380
391
  ...page(situatedRows(found.rows), null),
392
+ ...(hidden > 0
393
+ ? {
394
+ closedMatches: `${hidden} done or canceled initiative${hidden === 1 ? ' also matches' : 's also match'}; status: 'all' lists them.`,
395
+ }
396
+ : {}),
381
397
  ...(found.more
382
398
  ? {
383
399
  more:
@@ -403,6 +403,18 @@ const STEP_SUMMARY_FIELDS = [
403
403
  'completedAt',
404
404
  ] as const;
405
405
 
406
+ /**
407
+ * A step a failed run never reached reads as planned, as it was (PRO-2800): a run rejected before it began showed
408
+ * each step failed with "still in flight", though none had started. The API reaches failed only through in_progress,
409
+ * which sets startedAt, so a failed step without one is a planned step the run's failure swept up.
410
+ */
411
+ function neverStarted(step: Record<string, unknown>): Record<string, unknown> {
412
+ if (step['status'] !== 'failed' || step['startedAt']) return step;
413
+ // The sweep also stamped completedAt and an error; a planned step has neither.
414
+ const { error: _error, ...rest } = step;
415
+ return { ...rest, status: 'planned', completedAt: null };
416
+ }
417
+
406
418
  /** Past this many characters a step's headline is cut; the full read has the rest. */
407
419
  const HEADLINE_LIMIT = 600;
408
420
 
@@ -540,7 +552,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
540
552
  "the repository's failing tests (verificationNote says when one failed throughout). A failed or stopped " +
541
553
  'run has stoppedIn, lastProgressAt and failedReasonMeans (what its failedReason means). full: true ' +
542
554
  'adds complete instructions and reports. actions: resume and retry_from_failed_step are ' +
543
- 'taiga_resume_initiative; start_over is web only; to stop it, move the initiative out of Build. A ' +
555
+ 'taiga_resume_initiative; webOnlyActions (start_over) are in the web; to stop it, move the initiative out of Build. A ' +
544
556
  'maintenance run has no initiative and is retried from its finding.',
545
557
  inputSchema: z.object({
546
558
  runId,
@@ -560,14 +572,17 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
560
572
  client.listBuildRunSteps(params.runId),
561
573
  ]);
562
574
  const runRow = run.data;
563
- const rows = steps.data;
575
+ // Steps a failed run never reached read as planned before anything else reads them, stoppedIn included.
576
+ const rows = steps.data.map((step) =>
577
+ neverStarted(step as unknown as Record<string, unknown>)
578
+ ) as unknown as typeof steps.data;
564
579
  const where = stoppedIn(runRow, rows);
565
580
  const stop = where ? { stoppedIn: where } : {};
566
581
  const states = await initiativeStates(client, [runRow as unknown as Record<string, unknown>]);
567
582
  const readable = withInitiativeState(readableRun(runRow), states);
568
583
  const note = verificationNote(rows as unknown as Record<string, unknown>[]);
569
584
  const verification = note ? { verificationNote: note } : {};
570
- if (params.full) return toolResult({ ...readable, ...stop, ...verification, steps: steps.data });
585
+ if (params.full) return toolResult({ ...readable, ...stop, ...verification, steps: rows });
571
586
  return toolResult({
572
587
  ...readable,
573
588
  ...stop,
@@ -23,11 +23,26 @@ const PRODUCT_WORDS: Record<string, string> = {
23
23
  const OPAQUE_MAPS = new Set(['sections', 'metadata', 'settings']);
24
24
 
25
25
  /** A copy of an API value with its keys in the product's words, values untouched. */
26
+ /**
27
+ * Run actions only the web offers (PRO-2800): start_over was listed beside what an agent can call, on runs and on
28
+ * every initiative's latest run, while the descriptions said it was web only. Split apart wherever actions appear.
29
+ */
30
+ const WEB_ONLY_ACTIONS = new Set(['start_over']);
31
+
26
32
  export function inProductWords(value: unknown): unknown {
27
33
  if (Array.isArray(value)) return value.map(inProductWords);
28
34
  if (value === null || typeof value !== 'object') return value;
29
35
  const out: Record<string, unknown> = {};
30
- for (const [key, inner] of Object.entries(value)) {
36
+ const actions = (value as Record<string, unknown>)['actions'];
37
+ if (Array.isArray(actions) && actions.some((action) => WEB_ONLY_ACTIONS.has(String(action)))) {
38
+ const webOnly = actions.filter((action) => WEB_ONLY_ACTIONS.has(String(action)));
39
+ value = {
40
+ ...(value as Record<string, unknown>),
41
+ actions: actions.filter((action) => !WEB_ONLY_ACTIONS.has(String(action))),
42
+ webOnlyActions: webOnly,
43
+ };
44
+ }
45
+ for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
31
46
  Object.defineProperty(out, PRODUCT_WORDS[key] ?? key, {
32
47
  value: OPAQUE_MAPS.has(key) ? inner : inProductWords(inner),
33
48
  enumerable: true,
@@ -14,9 +14,9 @@ const BY_ARGUMENT: Record<string, string> = {
14
14
  runId: 'taiga_list_runs lists the runs, of one product or of every product the key can see.',
15
15
  planId: "taiga_get_plan gives the initiative's active plan and its ID.",
16
16
  repositoryId:
17
- "taiga_list_product_repositories lists the product's repository links; a GitHub repository id is not a link ID.",
17
+ "taiga_list_product_repositories lists the product's repository links; a linkable repository id is not a link ID.",
18
18
  replacesRepositoryId: "taiga_list_product_repositories lists the product's repository links.",
19
- githubRepositoryId:
19
+ linkableRepositoryId:
20
20
  'taiga_list_linkable_repositories lists the repositories the GitHub connection reaches, with their ids.',
21
21
  installationId: 'taiga_list_linkable_repositories lists the connected GitHub accounts.',
22
22
  jobId: 'A job ID is what the tool that started the job returned (taiga_request_initiatives, taiga_start_import).',
@@ -57,7 +57,7 @@ const MISSING_THING: Record<string, RegExp> = {
57
57
  planId: /\bplan\b/i,
58
58
  repositoryId: /repository link|\blink\b/i,
59
59
  replacesRepositoryId: /repository link|\blink\b/i,
60
- githubRepositoryId: /github repository/i,
60
+ linkableRepositoryId: /github repository/i,
61
61
  installationId: /installation|account/i,
62
62
  jobId: /\bjob\b/i,
63
63
  factoryId: /factory|team/i,
@@ -57,8 +57,14 @@ function readWith(row: Row, backlogRead: boolean): string | undefined {
57
57
  case 'backlog_item':
58
58
  // Only a tool the key has: the backlog reads need backlog:read.
59
59
  return backlogRead ? 'taiga_get_plan (initiativeId: subjectId)' : undefined;
60
- case 'product_document':
61
- return 'taiga_list_product_documents (productId)';
60
+ case 'product_document': {
61
+ // A document decision's subject is "<productId>:<type>:decision:<hash>", which no tool takes: name the
62
+ // document it is about, and say where the decision's own text is.
63
+ const type = /^[^:]+:([a-z_]+):decision:/.exec(String(row['subjectId']))?.[1];
64
+ return type
65
+ ? `taiga_get_product_document (productId, type: ${type}); the decision's text is this row with full: true`
66
+ : 'taiga_list_product_documents (productId)';
67
+ }
62
68
  case 'inbox_task':
63
69
  return 'taiga_list_setup_tasks (productId)';
64
70
  case 'product':
@@ -433,6 +439,17 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
433
439
  result.nextCursor
434
440
  ),
435
441
  ...totalOf(result),
442
+ // totalCount is the API's, every item; the summary's open leaves stale and put-off ones out. A reviewer
443
+ // summed one and compared it with the other (68 against 63).
444
+ ...(overview &&
445
+ typeof result.totalCount === 'number' &&
446
+ (stale.size > 0 || overview.data.length > overviewDue.length || overview.nextCursor)
447
+ ? {
448
+ totalNote:
449
+ 'totalCount counts every item, stale and snoozed ones too; summary.open does not' +
450
+ (overview.nextCursor ? `, and counts only the first ${SUMMARY_PAGE}.` : '.'),
451
+ }
452
+ : {}),
436
453
  ...(hidden > 0 ? { snoozedHidden: hidden } : {}),
437
454
  // Past the cap a review is not checked, so no stale on it is not "live".
438
455
  ...(checked.unchecked > 0
@@ -7,26 +7,29 @@ import { z } from 'zod';
7
7
  * schema's message, not the description, when a call fails validation, and
8
8
  * "Invalid UUID" alone sends a model that passed a name to guess again.
9
9
  */
10
- export function idInput(what: string, from: string) {
11
- return z
12
- .string()
13
- .uuid({ message: `${/^[aeiou]/i.test(what) ? 'An' : 'A'} ${what} ID is a UUID, not a name: ${from}` })
14
- .describe(`${what[0]!.toUpperCase()}${what.slice(1)} ID -- ${from}`);
10
+ export function idInput(what: string, from: string, said: string = from) {
11
+ return (
12
+ z
13
+ .string()
14
+ .uuid({ message: `${/^[aeiou]/i.test(what) ? 'An' : 'A'} ${what} ID is a UUID, not a name: ${from}` })
15
+ // format: uuid says it; the 190-character pattern zod adds beside it, on every ID of 79 tools, was the bulk
16
+ // of the schema a reviewer loaded (PRO-2800). Validation is unchanged.
17
+ .meta({ pattern: undefined })
18
+ .describe(`${what[0]!.toUpperCase()}${what.slice(1)} ID -- ${said}`)
19
+ );
15
20
  }
16
21
 
17
22
  export const productId = idInput(
18
23
  'product',
19
- 'taiga_list_products (query finds one by name), or taiga_get_product with a git remote URL, gives it'
24
+ 'taiga_list_products (query finds one by name), or taiga_get_product with a git remote URL, gives it',
25
+ // On 39 tools: the full sentence is the refusal's, where it is needed.
26
+ 'taiga_list_products gives it'
20
27
  );
21
28
 
22
29
  /** The page inputs every list tool takes; `nextCursor` of one page is the `cursor` of the next. */
23
30
  export const pagination = {
24
31
  limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
25
- cursor: z
26
- .string()
27
- .max(256)
28
- .optional()
29
- .describe('The nextCursor of the previous page, to read the next one; omit for the first page'),
32
+ cursor: z.string().max(256).optional().describe("The previous page's nextCursor; omit for the first page"),
30
33
  };
31
34
 
32
35
  export const slug = z
@@ -10,8 +10,8 @@
10
10
  * (PRO-2800).
11
11
  */
12
12
  export const SERVER_INSTRUCTIONS = [
13
- 'Taiga is a policy-aware software delivery platform. Use these tools (taiga_*) when the person asks about a Taiga',
14
- 'product, its initiatives or runs, policies, findings, deployments, or setting a product up.',
13
+ 'Taiga is a policy-aware software delivery platform. Use these tools (taiga_*) for anything about a Taiga',
14
+ 'product: its initiatives, runs, policies, findings, deployments or setup.',
15
15
  '',
16
16
  '**Which read.** Where a product stands (counts, not detail): taiga_get_product_context, by git remote URL, slug',
17
17
  'or ID. What waits on people: taiga_list_action_required. A policy: taiga_get_document (sections: [], or a',
@@ -24,17 +24,17 @@ export const SERVER_INSTRUCTIONS = [
24
24
  'a person makes; a sweep is a scan for findings; standing is how a product meets the policies. Discovery is the web',
25
25
  'conversation that specifies a new product. A factory owns products. People stamp checks in the web.',
26
26
  '',
27
- '**IDs** are UUIDs; the product, factory and initiative lists take query to find one by name. A NOT_FOUND names the tool that lists the right',
28
- 'ones. Lists page with nextCursor. outcome, situation and nextStep are one-sentence summaries; setup writes',
29
- 'return readiness with nextStep, so there is no need to read the product again. A product document has two',
30
- 'timelines: plan (what was meant) and reality (derived from the code).',
27
+ '**IDs** are UUIDs; the product, factory and initiative lists take query to find one by name. A NOT_FOUND names',
28
+ 'the tool that lists the right ones. Lists page with nextCursor; taiga_list_initiatives lists open ones unless',
29
+ "status: 'all'. outcome, situation and nextStep are one-sentence summaries; setup writes return readiness. A",
30
+ 'product document has two timelines: plan (what was meant) and reality (derived from the code).',
31
31
  '',
32
32
  '**Writing.** A tool that writes opens with "Writes.", "Ask first (paid agent work)." or "Ask first (others',
33
33
  'see it)."; before an ask-first tool, tell the person and wait for their yes. Others share the organization: a',
34
34
  "taken name is refused unless allowDuplicateName, and the one that exists may not be this person's.",
35
35
  '',
36
- '**Only in the web:** connecting GitHub, members and roles, Discovery, stamping checks, starting a run over, and',
37
- 'deleting factories and products.',
36
+ '**Only in the web:** connecting GitHub, members and roles, the Discovery conversation, stamping checks, starting',
37
+ 'a run over, and deleting factories and products.',
38
38
  ].join('\n');
39
39
 
40
40
  /** The most the instructions may take: Claude Code cuts each server's at 2,048 characters. */
@@ -250,6 +250,22 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
250
250
  : {}),
251
251
  ...page(pickFields(result.data, FINDING_ROW_FIELDS).map(withFixability), result.nextCursor),
252
252
  ...(typeof result.totalCount === 'number' ? { totalCount: result.totalCount } : {}),
253
+ // The default list has findings with a fix under way too; the sweep's count does not (31 against 33).
254
+ ...(params.status === undefined &&
255
+ params.severity === undefined &&
256
+ params.kind === undefined &&
257
+ typeof result.totalCount === 'number' &&
258
+ sweep &&
259
+ sweep !== 'unread' &&
260
+ typeof (sweep as Row)['openFindings'] === 'number' &&
261
+ (sweep as Row)['openFindings'] !== result.totalCount
262
+ ? {
263
+ totalNote:
264
+ 'totalCount includes findings with a fix under way (remediating) and ignored findings whose ' +
265
+ 'ignore has expired; ' +
266
+ 'sweep.openFindings counts open ones only.',
267
+ }
268
+ : {}),
253
269
  });
254
270
  })
255
271
  );
@@ -53,7 +53,7 @@ const GITHUB_REPOSITORY_FIELDS = [
53
53
 
54
54
  const repositoryId = idInput(
55
55
  'repository link',
56
- 'taiga_list_product_repositories gives it; a GitHub repository id is not one'
56
+ 'taiga_list_product_repositories gives it; a linkable repository id is not one'
57
57
  );
58
58
 
59
59
  /** Round-robin over lists: the first of each, then the second of each, and so on. */
@@ -66,6 +66,34 @@ export function interleave<T>(lists: T[][]): T[] {
66
66
  return out;
67
67
  }
68
68
 
69
+ /**
70
+ * A repository linked elsewhere names the product that holds it (PRO-2800): the API says "another product", and a
71
+ * reviewer spent a call finding which. One read, the products the key can see; null leaves the API's refusal.
72
+ */
73
+ async function holderOf(client: TaigaClient, error: unknown, productId: string) {
74
+ if (!(error instanceof TaigaApiError) || error.status !== 409) return null;
75
+ const name = /^(\S+\/\S+) is already linked to another product\.?$/.exec(error.apiMessage ?? '')?.[1];
76
+ if (!name) return null;
77
+ try {
78
+ // The refusal names the repository only, not its host, and the same name can live on github.com and on an
79
+ // enterprise host: these are the products with a repository of that name, which is as exact as it can say.
80
+ const holders = (await client.listProducts({ repository: name, limit: 20 })).data.filter((p) => p.id !== productId);
81
+ const said =
82
+ holders.length > 0
83
+ ? `Products this key can see with a repository named ${name}: ${holders.map((p) => `${p.name} (${p.id})`).join(', ')}.`
84
+ : 'It is linked to a product this key cannot see.';
85
+ return toolError(
86
+ 'CONFLICT',
87
+ `${name} is already linked to another product. ${said} A repository belongs to one product; unlink it there ` +
88
+ 'first (taiga_unlink_repository) if this product should have it.',
89
+ { holders: holders.map((p) => ({ productId: p.id, name: p.name })) }
90
+ );
91
+ } catch (inner) {
92
+ if (inner instanceof TaigaApiError || isNetworkFailure(inner)) return null;
93
+ throw inner;
94
+ }
95
+ }
96
+
69
97
  /**
70
98
  * A product in setup says what it still needs after a repository write, as create_product and get_product do
71
99
  * (PRO-2800): a reviewer had to read the product again to learn that the import came next. Best effort: the write
@@ -132,8 +160,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
132
160
  title: 'Find a repository to link',
133
161
  description:
134
162
  "List the repositories the organization's GitHub connection can reach, to link one with " +
135
- "taiga_link_repository (it takes the row's id: Taiga's record of the repository, not GitHub's numeric " +
136
- 'id, and not a link ID). query searches by name; account (login, owner, or ' +
163
+ "taiga_link_repository (it takes the row's id as linkableRepositoryId). query searches by name; account (login, owner, or " +
137
164
  'installationId) reads one account, else rows come from every account in turn. linked: true means a ' +
138
165
  'product already links it (taiga_get_product with its URL says which). No connection means GitHub is not ' +
139
166
  'connected yet; someone who can install the app does that once, in the web.',
@@ -236,9 +263,11 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
236
263
  'history is imported.',
237
264
  inputSchema: z.object({
238
265
  productId,
239
- githubRepositoryId: idInput(
240
- 'GitHub repository',
241
- "taiga_list_linkable_repositories gives it (the row's id, Taiga's record; not GitHub's numeric id)"
266
+ // Named for the list it comes from: it is Taiga's record of a repository the connection reaches, and three
267
+ // reviews read githubRepositoryId as GitHub's numeric id (PRO-2800).
268
+ linkableRepositoryId: idInput(
269
+ 'linkable repository',
270
+ "taiga_list_linkable_repositories gives it (a row's id; not GitHub's numeric id, not a link ID)"
242
271
  ),
243
272
  replacesRepositoryId: repositoryId.optional().describe('The link this one replaces'),
244
273
  }),
@@ -246,10 +275,17 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
246
275
  },
247
276
  (params) =>
248
277
  runTool(async () => {
249
- const linked = await client.linkRepository(params.productId, {
250
- githubRepositoryId: params.githubRepositoryId,
251
- ...(params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}),
252
- });
278
+ let linked;
279
+ try {
280
+ linked = await client.linkRepository(params.productId, {
281
+ githubRepositoryId: params.linkableRepositoryId,
282
+ ...(params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}),
283
+ });
284
+ } catch (error) {
285
+ const holder = await holderOf(client, error, params.productId);
286
+ if (holder) return holder;
287
+ throw error;
288
+ }
253
289
  return toolResult({
254
290
  ...pickFields([linked.data], LINK_FIELDS)[0],
255
291
  ...(await readinessAfter(client, params.productId)),
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { CallToolResult } from '@modelcontextprotocol/server';
15
15
  import { z } from 'zod';
16
- import { isControlId, POLICY_TYPE_BY_CONTROL_PREFIX } from '@taiga/types';
16
+ import { CONTROL_ID_SOURCE, isControlId, POLICY_TYPE_BY_CONTROL_PREFIX } from '@taiga/types';
17
17
  import { toolError, toolResult } from './errors.js';
18
18
 
19
19
  /** A document's section map, keyed by section id: each value a `StoredSection` or (older rows) a bare string. */
@@ -212,6 +212,34 @@ function controlSections(sections: Sections, ids: readonly string[]): Record<str
212
212
  return found;
213
213
  }
214
214
 
215
+ /**
216
+ * A policy outline names the controls each section states (PRO-2800): a reviewer guessed secretsManagement from its
217
+ * name, which works for secrets and not for "what covers logging of secret access". Read off each section's text.
218
+ */
219
+ function withControls(outline: { id: string; characters: number }[], sections: Sections, policyType: unknown) {
220
+ // The first cell of a control table row, as the API reads a policy's controls (policy-controls.ts CONTROL_ROW):
221
+ // not AES-256 in prose, nor "see SEC-5.1" in a section that only cites it.
222
+ // A reserved row (_Reserved_) is a retired id, not a control, as heldControlMap skips it.
223
+ const pattern = new RegExp(`^\\|\\s*(${CONTROL_ID_SOURCE})\\s*\\|\\s*(.*?)\\s*(?:\\||$)`, 'gm');
224
+ return outline.map((entry) => {
225
+ const text = sectionContent(sections, entry.id) ?? '';
226
+ const ids = [
227
+ ...new Set(
228
+ [...text.matchAll(pattern)]
229
+ .filter((match) => !/^_?Reserved_?/i.test(match[2] ?? ''))
230
+ .map((match) => match[1]!)
231
+ // A base control belongs to the policy its prefix names: an SEC row in the data policy is a stale mention,
232
+ // as heldControlMap reads it. An overlay prefix stays where it is written.
233
+ .filter((id) => {
234
+ const owner = POLICY_TYPE_BY_CONTROL_PREFIX[id.split('-')[0] ?? ''];
235
+ return owner === undefined || owner === policyType;
236
+ })
237
+ ),
238
+ ];
239
+ return ids.length > 0 ? { ...entry, controls: ids } : entry;
240
+ });
241
+ }
242
+
215
243
  /**
216
244
  * The answer to a read that names sections: the ones the document has, the
217
245
  * rest listed with their sizes, and the asked-for ids it lacks under
@@ -254,7 +282,10 @@ export function sectionReadResult(
254
282
  const { data: _data, ...meta } = cut.doc;
255
283
  return toolResult({
256
284
  ...meta,
257
- outline: cut.omittedSections,
285
+ outline:
286
+ options.controlIds && before
287
+ ? withControls(cut.omittedSections, before, (doc as { type?: unknown }).type)
288
+ : cut.omittedSections,
258
289
  ...(unknownSections.length > 0 ? { unknownSections } : {}),
259
290
  ...controlNote,
260
291
  });