@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 +32 -18
- package/package.json +1 -1
- package/src/server.integration.test.ts +51 -0
- package/src/tools/maintenance.ts +6 -4
- package/src/tools/repositories.ts +7 -2
- package/src/tools/sections.ts +3 -16
- package/taiga-ai-mcp-server-0.25.0-dev.466.tgz +0 -0
- package/taiga-ai-mcp-server-0.25.0-dev.463.tgz +0 -0
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 =
|
|
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 ?
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
});
|
package/src/tools/maintenance.ts
CHANGED
|
@@ -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 (
|
|
213
|
-
'
|
|
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,
|
|
466
|
-
'
|
|
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
|
-
|
|
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
|
|
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'),
|
package/src/tools/sections.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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 ?
|
|
257
|
+
const policy = asControl ? policyNamedByControl(asControl) : undefined;
|
|
271
258
|
const controlNote = asControl
|
|
272
259
|
? {
|
|
273
260
|
note: policy
|
|
Binary file
|
|
Binary file
|