@taiga-ai/mcp-server 0.25.0-dev.463 → 0.25.0-dev.466

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
@@ -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;
@@ -4815,7 +4827,7 @@ function registerMaintenanceTools(server, client) {
4815
4827
  if (!client.hasCapability("products:read")) return;
4816
4828
  server.registerTool("taiga_list_findings", {
4817
4829
  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.",
4830
+ 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
4831
  inputSchema: z.object({
4820
4832
  productId,
4821
4833
  status: z.enum(MAINTENANCE_FINDING_STATUSES).optional().describe("Only findings in this status; omit for the ones needing action"),
@@ -4944,7 +4956,7 @@ function registerMaintenanceTools(server, client) {
4944
4956
  }));
4945
4957
  server.registerTool("taiga_fix_finding", {
4946
4958
  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.",
4959
+ 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
4960
  inputSchema: z.object({
4949
4961
  productId,
4950
4962
  findingId,
@@ -21059,7 +21071,8 @@ const GITHUB_REPOSITORY_FIELDS = [
21059
21071
  "defaultBranch",
21060
21072
  "description",
21061
21073
  "language",
21062
- "linked"
21074
+ "linked",
21075
+ "linkedProduct"
21063
21076
  ];
21064
21077
  const repositoryId = idInput("repository link", "taiga_list_product_repositories gives it; a linkable repository id is not one");
21065
21078
  /** Round-robin over lists: the first of each, then the second of each, and so on. */
@@ -21075,7 +21088,8 @@ function interleave(lists) {
21075
21088
  */
21076
21089
  async function holderOf(client, error, productId) {
21077
21090
  if (!(error instanceof TaigaApiError) || error.status !== 409) return null;
21078
- const name = /^(\S+\/\S+) is already linked to another product\.?$/.exec(error.apiMessage ?? "")?.[1];
21091
+ if (error.details?.["holder"]) return null;
21092
+ const name = /^(\S+\/\S+) is already linked to another product\./.exec(error.apiMessage ?? "")?.[1];
21079
21093
  if (!name) return null;
21080
21094
  try {
21081
21095
  const holders = (await client.listProducts({
@@ -21137,7 +21151,7 @@ function registerRepositoryTools(server, client) {
21137
21151
  }
21138
21152
  if (client.hasCapability("vcs:read")) server.registerTool("taiga_list_linkable_repositories", {
21139
21153
  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.",
21154
+ 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
21155
  inputSchema: z.object({
21142
21156
  query: z.string().trim().min(1).max(256).optional().describe("Part of the repository name"),
21143
21157
  account: z.string().trim().min(1).max(140).optional().describe("The GitHub account login (the owner in owner/repo), case-insensitive"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.25.0-dev.463",
3
+ "version": "0.25.0-dev.466",
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",
@@ -8848,4 +8848,55 @@ describe('what the seventh agent review found (PRO-2800)', () => {
8848
8848
  expect(bundle.runs[0].hardeningNote).toMatch(/^Hardening ended with 2 blocking findings/);
8849
8849
  expect(bundle.runs[0]).not.toHaveProperty('hardeningResult');
8850
8850
  });
8851
+
8852
+ it('says what the API says when it names the product holding a repository', async () => {
8853
+ const { client, fetchSpy } = await bootMcpPair({
8854
+ capabilities: ALL_CAPABILITIES,
8855
+ routes: {
8856
+ [`POST /v1/products/${PRODUCT_ID}/repositories`]: refusal(
8857
+ 409,
8858
+ 'CONFLICT',
8859
+ 'taiga-sandbox/import-test-chi is already linked to Orders. A repository belongs to one product; unlink it there first.',
8860
+ { holder: { productId: PRODUCT_ID_2, name: 'Orders' } }
8861
+ ),
8862
+ },
8863
+ });
8864
+
8865
+ const result = parseToolResult(
8866
+ await client.callTool({
8867
+ name: 'taiga_link_repository',
8868
+ arguments: { productId: PRODUCT_ID, linkableRepositoryId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f07' },
8869
+ })
8870
+ );
8871
+
8872
+ expect(result.message).toMatch(/already linked to Orders\./);
8873
+ expect(result.details.holder).toEqual({ productId: PRODUCT_ID_2, name: 'Orders' });
8874
+ // No second lookup when the API named it.
8875
+ expect(fetchSpy.mock.calls.some((call: unknown[]) => String(call[0]).includes('/v1/products?'))).toBe(false);
8876
+ });
8877
+
8878
+ it('passes on the product holding a linked repository when the API names it', async () => {
8879
+ const INSTALL = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f0b';
8880
+ const { client } = await bootMcpPair({
8881
+ capabilities: ALL_CAPABILITIES,
8882
+ routes: {
8883
+ 'GET /v1/github/installations': { data: [{ id: INSTALL, accountLogin: 'acme' }], nextCursor: null },
8884
+ [`/v1/github/installations/${INSTALL}/repositories`]: {
8885
+ data: [
8886
+ {
8887
+ id: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f0c',
8888
+ fullName: 'acme/web',
8889
+ linked: true,
8890
+ linkedProduct: { id: PRODUCT_ID_2, name: 'Orders' },
8891
+ },
8892
+ ],
8893
+ nextCursor: null,
8894
+ },
8895
+ },
8896
+ });
8897
+
8898
+ const result = parseToolResult(await client.callTool({ name: 'taiga_list_linkable_repositories', arguments: {} }));
8899
+
8900
+ expect(result.repositories.data[0].linkedProduct).toEqual({ id: PRODUCT_ID_2, name: 'Orders' });
8901
+ });
8851
8902
  });
@@ -209,8 +209,10 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
209
209
  "List what maintenance sweeps found in a product's repository: dependencies, secrets, insecure code " +
210
210
  '(sast) and infrastructure. The first page opens with sweep (open counts, latest sweeps), so an old or ' +
211
211
  'failed sweep is not read as an all-clear. Rows: severity, tier (act_now: known exploited; urgent: ' +
212
- 'likely and serious; elevated: one of the two; routine), location, remediableBy (upstream_only: no fixed ' +
213
- 'version), breached controls and initiativeId of a fix under way. Omit status for open findings. taiga_get_finding reads one.',
212
+ 'likely and serious; elevated: one of the two; routine), location, remediableBy (coding_agent: a run here ' +
213
+ 'can fix it; upstream_only: the code is in a submodule, so the fix belongs in its own repository), fixable: false ' +
214
+ 'when no fixed version is known yet, breached controls and initiativeId of a fix under way. Omit status ' +
215
+ 'for open findings. taiga_get_finding reads one.',
214
216
  inputSchema: z.object({
215
217
  productId,
216
218
  status: z
@@ -462,8 +464,8 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
462
464
  "Raise an initiative that fixes a maintenance finding, at the end of the product's line; it ends in a " +
463
465
  "pull request a person merges, and the initiative's review gate applies. wholeGroup: true fixes the " +
464
466
  'group in one initiative: every open advisory on that package version in one bump, or every location the ' +
465
- 'same rule flagged. Refused for a secret only in history, anything in a submodule, and remediableBy ' +
466
- 'upstream_only.',
467
+ 'same rule flagged. Refused for a secret only in history, remediableBy upstream_only (code in a ' +
468
+ 'submodule), and a dependency with no fixed version yet (fixable: false).',
467
469
  inputSchema: z.object({
468
470
  productId,
469
471
  findingId,
@@ -49,6 +49,8 @@ const GITHUB_REPOSITORY_FIELDS = [
49
49
  'language',
50
50
  // A product already links it (PRO-2800); absent from an API that predates the field.
51
51
  'linked',
52
+ // Which product holds it, to a key that sees every product (PRO-2822).
53
+ 'linkedProduct',
52
54
  ] as const;
53
55
 
54
56
  const repositoryId = idInput(
@@ -72,7 +74,10 @@ export function interleave<T>(lists: T[][]): T[] {
72
74
  */
73
75
  async function holderOf(client: TaigaClient, error: unknown, productId: string) {
74
76
  if (!(error instanceof TaigaApiError) || error.status !== 409) return null;
75
- const name = /^(\S+\/\S+) is already linked to another product\.?$/.exec(error.apiMessage ?? '')?.[1];
77
+ // An API that names the holder (PRO-2807) says it itself; an older one, or a key that cannot see every product,
78
+ // says "another product", and this lists the ones the key can see.
79
+ if (error.details?.['holder']) return null;
80
+ const name = /^(\S+\/\S+) is already linked to another product\./.exec(error.apiMessage ?? '')?.[1];
76
81
  if (!name) return null;
77
82
  try {
78
83
  // The refusal names the repository only, not its host, and the same name can live on github.com and on an
@@ -162,7 +167,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
162
167
  "List the repositories the organization's GitHub connection can reach, to link one with " +
163
168
  "taiga_link_repository (it takes the row's id as linkableRepositoryId). query searches by name; account (login, owner, or " +
164
169
  'installationId) reads one account, else rows come from every account in turn. linked: true means a ' +
165
- 'product already links it (taiga_get_product with its URL says which). No connection means GitHub is not ' +
170
+ 'product already links it, and linkedProduct names it to a key that sees every product. No connection means GitHub is not ' +
166
171
  'connected yet; someone who can install the app does that once, in the web.',
167
172
  inputSchema: z.object({
168
173
  query: z.string().trim().min(1).max(256).optional().describe('Part of the repository name'),
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { CallToolResult } from '@modelcontextprotocol/server';
15
15
  import { z } from 'zod';
16
- import { CONTROL_ID_SOURCE, isControlId, POLICY_TYPE_BY_CONTROL_PREFIX } from '@taiga/types';
16
+ import { CONTROL_ID_SOURCE, isControlId, policyNamedByControl, policyTypeForControl } 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. */
@@ -177,19 +177,6 @@ export const policySectionsInput = sectionsInput.describe(
177
177
  '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'
178
178
  );
179
179
 
180
- /**
181
- * The policy that states a control: the rightmost policy prefix in its id, so an overlay family's
182
- * TEL-TIC-1.1 is policy_tic and HC-FI-DATA-1.1 policy_data, as the overlays file them.
183
- */
184
- function policyOfControl(id: string): string | undefined {
185
- const prefixes = id.split('-').filter((part) => /^[A-Z]/.test(part));
186
- for (const prefix of prefixes.reverse()) {
187
- const policy = POLICY_TYPE_BY_CONTROL_PREFIX[prefix];
188
- if (policy) return policy;
189
- }
190
- return undefined;
191
- }
192
-
193
180
  /**
194
181
  * The sections that state each control id asked for (PRO-2800): policy
195
182
  * standing cites SEC-5.1, and the section holding it has an id of its own
@@ -231,7 +218,7 @@ function withControls(outline: { id: string; characters: number }[], sections: S
231
218
  // A base control belongs to the policy its prefix names: an SEC row in the data policy is a stale mention,
232
219
  // as heldControlMap reads it. An overlay prefix stays where it is written.
233
220
  .filter((id) => {
234
- const owner = POLICY_TYPE_BY_CONTROL_PREFIX[id.split('-')[0] ?? ''];
221
+ const owner = policyTypeForControl(id);
235
222
  return owner === undefined || owner === policyType;
236
223
  })
237
224
  ),
@@ -267,7 +254,7 @@ export function sectionReadResult(
267
254
  const found = Object.keys(controls).length > 0 ? { controls } : {};
268
255
  // A control id asked of a document that does not state controls: name the policy that does.
269
256
  const asControl = options.controlIds ? undefined : unknownSections.find((id) => isControlId(id));
270
- const policy = asControl ? policyOfControl(asControl) : undefined;
257
+ const policy = asControl ? policyNamedByControl(asControl) : undefined;
271
258
  const controlNote = asControl
272
259
  ? {
273
260
  note: policy