@taiga-ai/mcp-server 0.26.0-rc.500 → 0.26.0-rc.510
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 +197 -62
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1457,6 +1457,24 @@ function hardeningCoverage(result) {
|
|
|
1457
1457
|
gaps
|
|
1458
1458
|
};
|
|
1459
1459
|
}
|
|
1460
|
+
//#endregion
|
|
1461
|
+
//#region ../../packages/types/dist/inbox.js
|
|
1462
|
+
/** What action is required. Drives icon, copy, and completion logic. */
|
|
1463
|
+
const INBOX_ITEM_KINDS = [
|
|
1464
|
+
"review_plan",
|
|
1465
|
+
"review_backlog",
|
|
1466
|
+
"review_doc",
|
|
1467
|
+
"review_pr",
|
|
1468
|
+
"review_maintenance",
|
|
1469
|
+
"review_policy",
|
|
1470
|
+
"setup_env_var",
|
|
1471
|
+
"setup_secret",
|
|
1472
|
+
"setup_third_party",
|
|
1473
|
+
"setup_dns",
|
|
1474
|
+
"setup_billing",
|
|
1475
|
+
"setup_cloud_permission",
|
|
1476
|
+
"setup_manual"
|
|
1477
|
+
];
|
|
1460
1478
|
z.discriminatedUnion("kind", [
|
|
1461
1479
|
z.object({
|
|
1462
1480
|
type: z.literal("narrative"),
|
|
@@ -1919,6 +1937,7 @@ const ENVIRONMENT_TYPES = [
|
|
|
1919
1937
|
const MAINTENANCE_FINDING_KINDS = [
|
|
1920
1938
|
"vulnerable_dependency",
|
|
1921
1939
|
"outdated_dependency",
|
|
1940
|
+
"license_violation",
|
|
1922
1941
|
"secret",
|
|
1923
1942
|
"sast",
|
|
1924
1943
|
"iac_misconfig"
|
|
@@ -1943,7 +1962,11 @@ const CODE_REMEDIATION_KINDS = [
|
|
|
1943
1962
|
"iac_misconfig"
|
|
1944
1963
|
];
|
|
1945
1964
|
new Map(Object.entries({
|
|
1946
|
-
deps: [
|
|
1965
|
+
deps: [
|
|
1966
|
+
"vulnerable_dependency",
|
|
1967
|
+
"outdated_dependency",
|
|
1968
|
+
"license_violation"
|
|
1969
|
+
],
|
|
1947
1970
|
secrets: ["secret"],
|
|
1948
1971
|
sast: ["sast"],
|
|
1949
1972
|
iac: ["iac_misconfig"]
|
|
@@ -2030,6 +2053,19 @@ function lockfileScope(path, groups) {
|
|
|
2030
2053
|
}
|
|
2031
2054
|
}
|
|
2032
2055
|
/**
|
|
2056
|
+
* The kinds that are a package in a lockfile (PRO-2862): the ones with a
|
|
2057
|
+
* dependency scope, a relation and an inventory entry. A code kind reuses the
|
|
2058
|
+
* dependency columns for its rule and category and has none of those.
|
|
2059
|
+
*/
|
|
2060
|
+
const DEPENDENCY_FINDING_KINDS = [
|
|
2061
|
+
"vulnerable_dependency",
|
|
2062
|
+
"outdated_dependency",
|
|
2063
|
+
"license_violation"
|
|
2064
|
+
];
|
|
2065
|
+
function isDependencyFindingKind(kind) {
|
|
2066
|
+
return DEPENDENCY_FINDING_KINDS.includes(kind);
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2033
2069
|
* Whether a dependency finding sits only in development dependencies, in at
|
|
2034
2070
|
* least one runtime dependency set, or in lockfiles that do not say. Decided
|
|
2035
2071
|
* per lockfile (`lockfileScope`) and combined pessimistically: one runtime
|
|
@@ -2040,8 +2076,12 @@ function lockfileScope(path, groups) {
|
|
|
2040
2076
|
* install script in any group.
|
|
2041
2077
|
*/
|
|
2042
2078
|
function dependencyScope(finding) {
|
|
2043
|
-
if (finding.kind
|
|
2044
|
-
|
|
2079
|
+
if (!isDependencyFindingKind(finding.kind)) return "unknown";
|
|
2080
|
+
return scopeFromGroups(finding.dependencyGroups);
|
|
2081
|
+
}
|
|
2082
|
+
/** The scope read from per-lockfile groups alone: for an inventory record, which is a package by construction. */
|
|
2083
|
+
function scopeFromGroups(dependencyGroups) {
|
|
2084
|
+
const entries = Object.entries(dependencyGroups ?? {});
|
|
2045
2085
|
if (entries.length === 0) return "unknown";
|
|
2046
2086
|
return combineScopes(entries.map(([path, groups]) => lockfileScope(path, Array.isArray(groups) ? groups : [])));
|
|
2047
2087
|
}
|
|
@@ -2059,8 +2099,12 @@ function combineScopes(scopes) {
|
|
|
2059
2099
|
* remediation step already finds every resolved copy and its parent itself.
|
|
2060
2100
|
*/
|
|
2061
2101
|
function dependencyRelation(finding) {
|
|
2062
|
-
if (finding.kind
|
|
2063
|
-
|
|
2102
|
+
if (!isDependencyFindingKind(finding.kind)) return "unknown";
|
|
2103
|
+
return relationFromLockfiles(finding.dependencyRelation);
|
|
2104
|
+
}
|
|
2105
|
+
/** The relation read from per-lockfile answers alone: for an inventory record, which is a package by construction. */
|
|
2106
|
+
function relationFromLockfiles(dependencyRelation) {
|
|
2107
|
+
const values = Object.values(dependencyRelation ?? {});
|
|
2064
2108
|
if (values.includes("direct")) return "direct";
|
|
2065
2109
|
if (values.length > 0 && values.every((v) => v === "transitive")) return "transitive";
|
|
2066
2110
|
return "unknown";
|
|
@@ -2417,7 +2461,7 @@ const DOCUMENT_INPUTS = Object.freeze({
|
|
|
2417
2461
|
* reads as deleted) and fields they explain would simply be missing. So it
|
|
2418
2462
|
* refuses to start, and says what to do.
|
|
2419
2463
|
*/
|
|
2420
|
-
function apiLevelProblem(introspection, apiUrl, required =
|
|
2464
|
+
function apiLevelProblem(introspection, apiUrl, required = 3, version = SERVER_VERSION) {
|
|
2421
2465
|
const offered = introspection.mcpApiLevel ?? 0;
|
|
2422
2466
|
if (offered >= required) return null;
|
|
2423
2467
|
return `The Taiga API at ${apiUrl} is older than @taiga-ai/mcp-server ${version} needs (API level ${offered}, needs ${required}). Run the version published for it, or wait for that API to be updated; an earlier release of this package works against it.`;
|
|
@@ -2604,7 +2648,7 @@ function importStatusOf(product) {
|
|
|
2604
2648
|
/** Why a side of the timeline is empty, for a list or a miss that shows one. */
|
|
2605
2649
|
function emptyOriginNote(origin, product) {
|
|
2606
2650
|
const imported = isImportedProduct(product);
|
|
2607
|
-
if (origin === "plan") return imported ? `This product
|
|
2651
|
+
if (origin === "plan") return imported ? `This product is set up from its code (an import), so no discovery chain is generated for it: its derived documents are on the reality side. Only the documents a person writes (${PLAN_ONLY_DOCUMENT_TYPES.map(productDocumentName).join(", ")}) would appear here.` : "No plan documents are published yet: they are written during discovery in Taiga.";
|
|
2608
2652
|
if (!imported) return "No reality documents yet: they are derived from the code once the first initiative is done (a merged pull request marks it done) or the repository is pushed to. Until then read the plan side.";
|
|
2609
2653
|
switch (importStatusOf(product)) {
|
|
2610
2654
|
case "pending": return "No reality documents yet: the import has not started. taiga_start_import starts it once a repository is linked; taiga_get_product's readiness says whether one is.";
|
|
@@ -2674,7 +2718,7 @@ const BY_ARGUMENT = {
|
|
|
2674
2718
|
/** Where an argument means something else on one tool, or the tool's 404 needs its own word. */
|
|
2675
2719
|
const BY_TOOL = {
|
|
2676
2720
|
taiga_resolve_action: () => "taiga_list_action_required lists the items with their IDs.",
|
|
2677
|
-
taiga_get_plan: (said) => /\bplan\b/i.test(said) ? "taiga_get_initiative's situation says whether one is being planned or failed, and
|
|
2721
|
+
taiga_get_plan: (said) => /\bplan\b/i.test(said) ? "taiga_get_initiative's situation says whether one is being planned or failed, and names the call that asks for one (taiga_queue_initiative off the line, taiga_plan_again for a failed plan)." : ITEM_ID_HINT
|
|
2678
2722
|
};
|
|
2679
2723
|
/**
|
|
2680
2724
|
* What the API calls the thing each ID names, as its 404 says it ("Environment
|
|
@@ -2908,7 +2952,11 @@ async function runTool(handler) {
|
|
|
2908
2952
|
if (error instanceof TaigaApiError) {
|
|
2909
2953
|
const code = error.code ?? CODE_BY_STATUS[error.status] ?? "API_ERROR";
|
|
2910
2954
|
const worded = inProductWording(error.apiMessage ?? `Taiga API answered ${error.status}.`);
|
|
2911
|
-
return toolError(code, error.status === 404 ? withNotFoundHint(worded) :
|
|
2955
|
+
return toolError(code, error.status === 404 ? withNotFoundHint(worded) : [
|
|
2956
|
+
502,
|
|
2957
|
+
503,
|
|
2958
|
+
504
|
|
2959
|
+
].includes(error.status) ? `${withStop(worded)} This is usually temporary: a read may answer on a retry; after a write, check whether it took effect before repeating it.` : worded, error.details);
|
|
2912
2960
|
}
|
|
2913
2961
|
if (isNetworkFailure(error)) return toolError("NETWORK_ERROR", `Could not reach the Taiga API: ${networkFailureDetail(error)}. Check TAIGA_API_URL.`);
|
|
2914
2962
|
throw error;
|
|
@@ -3313,7 +3361,7 @@ const SEARCH_PAGE_LIMIT = 10;
|
|
|
3313
3361
|
* the whole list to find its ID. `more` is true when it stopped before the end.
|
|
3314
3362
|
*/
|
|
3315
3363
|
async function searchPages(fetchPage, query, text, limit, keep = () => true, nameOf) {
|
|
3316
|
-
const needle = query
|
|
3364
|
+
const needle = searchable(query);
|
|
3317
3365
|
const rows = [];
|
|
3318
3366
|
const names = [];
|
|
3319
3367
|
let cursor;
|
|
@@ -3323,7 +3371,7 @@ async function searchPages(fetchPage, query, text, limit, keep = () => true, nam
|
|
|
3323
3371
|
for (const [at, row] of page.entries()) {
|
|
3324
3372
|
if (!keep(row)) continue;
|
|
3325
3373
|
if (nameOf) names.push(nameOf(row));
|
|
3326
|
-
if (!text(row).some((value) => typeof value === "string" && value
|
|
3374
|
+
if (!text(row).some((value) => typeof value === "string" && searchable(value).includes(needle))) continue;
|
|
3327
3375
|
rows.push(row);
|
|
3328
3376
|
if (rows.length === limit) return {
|
|
3329
3377
|
rows,
|
|
@@ -3346,12 +3394,18 @@ async function searchPages(fetchPage, query, text, limit, keep = () => true, nam
|
|
|
3346
3394
|
};
|
|
3347
3395
|
}
|
|
3348
3396
|
/**
|
|
3397
|
+
* Text as a search compares it: lower case, punctuation as spaces, so "sign in" finds "Staff Sign-In" (PRO-2823).
|
|
3398
|
+
*/
|
|
3399
|
+
function searchable(text) {
|
|
3400
|
+
return text.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim() || text.toLowerCase();
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3349
3403
|
* Up to three names close to a query that matched none: a typo of the name or
|
|
3350
3404
|
* of one of its words, within a third of the query's length ("tiaga", "rally"
|
|
3351
3405
|
* for ref-rallly), or a shared word of three letters or more. Closest first.
|
|
3352
3406
|
*/
|
|
3353
3407
|
function closestNames(query, names) {
|
|
3354
|
-
const words = (text) => text
|
|
3408
|
+
const words = (text) => searchable(text).split(" ").filter((word) => word.length >= 3);
|
|
3355
3409
|
const wanted = new Set(words(query));
|
|
3356
3410
|
const ranked = names.map((name) => ({
|
|
3357
3411
|
name,
|
|
@@ -3870,10 +3924,17 @@ async function publishDraft(scope) {
|
|
|
3870
3924
|
...published.warnings ? { warnings: published.warnings } : {}
|
|
3871
3925
|
});
|
|
3872
3926
|
}
|
|
3927
|
+
/** The policies the section ids name when every one is a control id (SEC-5.1), else none. */
|
|
3928
|
+
function policiesNamed(sections) {
|
|
3929
|
+
if (sections.length === 0) return [];
|
|
3930
|
+
const types = sections.map((id) => isControlId(id.toUpperCase()) ? policyNamedByControl(id.toUpperCase()) : void 0);
|
|
3931
|
+
if (types.some((type) => type === void 0)) return [];
|
|
3932
|
+
return [...new Set(types)];
|
|
3933
|
+
}
|
|
3873
3934
|
function registerDocumentTools(server, client) {
|
|
3874
3935
|
if (client.hasCapability("tenant:documents:read")) server.registerTool("taiga_get_organization_document", {
|
|
3875
3936
|
title: "Get an organization document",
|
|
3876
|
-
description: `Read the organization's own documents, which apply to every product. Two answers: without type, the list (type, name, version, draft state); with type, that document. The ${POLICY_TYPES.length} policies (${named$1(POLICY_TYPES)}) set the rules for every product; the others (${named$1(OTHER_TENANT_DOCUMENT_TYPES)}) carry goals, standards, voice and instructions. Policies are long: pass sections: [] for the outline, then the section ids you need, or a control id such as SEC-5.1; unknownSections names ids it lacks. A miss says which documents are published and whether a draft exists. A section's source (template, overlay-<id>, cloud-preset:<providers>) and priority (core: every organization keeps it) do not change what it requires.`,
|
|
3937
|
+
description: `Read the organization's own documents, which apply to every product. Two answers: without type, the list (type, name, version, draft state); with type, that document. The ${POLICY_TYPES.length} policies (${named$1(POLICY_TYPES)}) set the rules for every product; the others (${named$1(OTHER_TENANT_DOCUMENT_TYPES)}) carry goals, standards, voice and instructions. Policies are long: pass sections: [] for the outline, then the section ids you need, or a control id such as SEC-5.1 (which names its policy, so type may be left out); unknownSections names ids it lacks. A miss says which documents are published and whether a draft exists. A section's source (template, overlay-<id>, cloud-preset:<providers>) and priority (core: every organization keeps it) do not change what it requires.`,
|
|
3877
3938
|
inputSchema: z.object({
|
|
3878
3939
|
type: tenantDocumentType.optional(),
|
|
3879
3940
|
sections: policySectionsInput,
|
|
@@ -3881,19 +3942,25 @@ function registerDocumentTools(server, client) {
|
|
|
3881
3942
|
}),
|
|
3882
3943
|
annotations: READ_ONLY
|
|
3883
3944
|
}, (params) => runTool(async () => {
|
|
3884
|
-
|
|
3885
|
-
|
|
3945
|
+
const named = params.type === void 0 && params.sections ? policiesNamed(params.sections) : [];
|
|
3946
|
+
const args = params.type === void 0 && named.length === 1 ? {
|
|
3947
|
+
...params,
|
|
3948
|
+
type: named[0],
|
|
3949
|
+
sections: params.sections?.map((id) => id.toUpperCase())
|
|
3950
|
+
} : params;
|
|
3951
|
+
if (args.type === void 0) {
|
|
3952
|
+
if (args.sections) return toolError("VALIDATION_ERROR", named.length > 1 ? `Those control ids are in different policies (${named.join(", ")}): read one type at a time.` : "Name the document (type) to read its sections; a control id such as SEC-5.1 names its policy itself.");
|
|
3886
3953
|
const result = await client.listTenantDocuments({
|
|
3887
|
-
limit:
|
|
3888
|
-
cursor:
|
|
3954
|
+
limit: args.limit,
|
|
3955
|
+
cursor: args.cursor
|
|
3889
3956
|
});
|
|
3890
3957
|
return toolResult(page(summaries(result.data), result.nextCursor));
|
|
3891
3958
|
}
|
|
3892
|
-
const type =
|
|
3893
|
-
if (
|
|
3959
|
+
const type = args.type;
|
|
3960
|
+
if (args.limit !== void 0 || args.cursor !== void 0) return toolError("VALIDATION_ERROR", params.type === void 0 ? "limit and cursor page the list, which takes no sections; drop them to read the control." : "limit and cursor page the list; omit type to list, or drop them to read.");
|
|
3894
3961
|
try {
|
|
3895
3962
|
const doc = stripProvenance((await client.getTenantDocument(type)).data);
|
|
3896
|
-
return
|
|
3963
|
+
return args.sections ? sectionReadResult(doc, args.sections, type, { controlIds: POLICY_TYPES.includes(type) }) : toolResult(doc);
|
|
3897
3964
|
} catch (error) {
|
|
3898
3965
|
if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
|
|
3899
3966
|
const [draft, all] = await Promise.allSettled([client.getTenantDocumentDraft(type), client.listTenantDocuments({ limit: 100 })]);
|
|
@@ -4232,15 +4299,28 @@ function runOutcome(run) {
|
|
|
4232
4299
|
}[disposition] ?? status
|
|
4233
4300
|
};
|
|
4234
4301
|
}
|
|
4302
|
+
/** Words a sentence does not end at: "e.g." and the like, and a lone initial. */
|
|
4303
|
+
const ABBREVIATION = /(?:\b(?:e\.g|i\.e|etc|vs|cf|incl|approx)|(?:^|\s)[A-Za-z])\.$/i;
|
|
4304
|
+
/** A finding's first sentence, capped: it ends at . ! or ? before white space, but not at an abbreviation. */
|
|
4305
|
+
function firstSentence$1(text) {
|
|
4306
|
+
let sentence = text;
|
|
4307
|
+
for (const match of text.matchAll(/[.!?](?=\s|$)/g)) {
|
|
4308
|
+
const end = match.index + 1;
|
|
4309
|
+
if (ABBREVIATION.test(text.slice(0, end))) continue;
|
|
4310
|
+
sentence = text.slice(0, end);
|
|
4311
|
+
break;
|
|
4312
|
+
}
|
|
4313
|
+
return sentence.length > 160 ? `${sentence.slice(0, 157)}...` : sentence;
|
|
4314
|
+
}
|
|
4235
4315
|
/**
|
|
4236
4316
|
* What hardening left blocking, in a sentence: the findings the run names (PRO-2817), or, for a run reported before
|
|
4237
4317
|
* it recorded them, how many.
|
|
4238
4318
|
*/
|
|
4239
4319
|
function hardeningSaid(blocking, named) {
|
|
4240
4320
|
const count = `Hardening ended with ${blocking} blocking finding${blocking === 1 ? "" : "s"} it could not fix`;
|
|
4241
|
-
if (named.length === 0) return `${count};
|
|
4321
|
+
if (named.length === 0) return `${count}; this run predates recording which.`;
|
|
4242
4322
|
const shown = named.slice(0, 3).map((finding) => {
|
|
4243
|
-
const message = String(finding["message"]);
|
|
4323
|
+
const message = firstSentence$1(String(finding["message"]));
|
|
4244
4324
|
const where = finding["line"] ? `${String(finding["path"])}:${String(finding["line"])}` : String(finding["path"]);
|
|
4245
4325
|
return `${message} (${String(finding["tool"])}, ${where})`;
|
|
4246
4326
|
});
|
|
@@ -5075,7 +5155,7 @@ function registerBacklogReads(server, client) {
|
|
|
5075
5155
|
}
|
|
5076
5156
|
if (params.cursor !== void 0) return toolError("VALIDATION_ERROR", "A title search answers one page; narrow the title, or leave query out to page through a column.");
|
|
5077
5157
|
let closedSeen = 0;
|
|
5078
|
-
const needle = query
|
|
5158
|
+
const needle = searchable(query);
|
|
5079
5159
|
const found = await searchPages((cursor) => client.listBacklogItems(id, {
|
|
5080
5160
|
...apiFilters,
|
|
5081
5161
|
...column === "open" ? { open: void 0 } : {},
|
|
@@ -5083,14 +5163,15 @@ function registerBacklogReads(server, client) {
|
|
|
5083
5163
|
cursor
|
|
5084
5164
|
}), query, (row) => [row.title], params.limit ?? 25, (row) => {
|
|
5085
5165
|
if (column !== "open" || !["done", "canceled"].includes(String(row.state))) return true;
|
|
5086
|
-
if (String(row.title)
|
|
5166
|
+
if (searchable(String(row.title)).includes(needle)) closedSeen += 1;
|
|
5087
5167
|
return false;
|
|
5088
|
-
});
|
|
5168
|
+
}, (row) => String(row.title));
|
|
5089
5169
|
const hidden = column === "open" && !found.more ? closedSeen : 0;
|
|
5090
5170
|
return toolResult({
|
|
5091
5171
|
...page(situatedRows(found.rows), null),
|
|
5092
5172
|
...hidden > 0 ? { closedMatches: `${hidden} done or canceled initiative${hidden === 1 ? " also matches" : "s also match"}; column: 'all' lists them.` } : {},
|
|
5093
|
-
...found.more ? { more: "There may be more than the limit carries: narrow the title, or pick one column." } : {}
|
|
5173
|
+
...found.more ? { more: "There may be more than the limit carries: narrow the title, or pick one column." } : {},
|
|
5174
|
+
...hidden === 0 ? nearNote(found.near) : {}
|
|
5094
5175
|
});
|
|
5095
5176
|
}));
|
|
5096
5177
|
server.registerTool("taiga_get_initiative", {
|
|
@@ -5346,6 +5427,11 @@ const FINDING_ROW_FIELDS = [
|
|
|
5346
5427
|
* read off the row; remediableBy stays as the sweep recorded it.
|
|
5347
5428
|
*/
|
|
5348
5429
|
function withFixability(row) {
|
|
5430
|
+
if (row["kind"] === "license_violation") return {
|
|
5431
|
+
...row,
|
|
5432
|
+
fixable: false,
|
|
5433
|
+
fixNote: "A license violation has no fix version: review the license or replace the package; taiga_fix_finding refuses it."
|
|
5434
|
+
};
|
|
5349
5435
|
if ((row["kind"] === "vulnerable_dependency" || row["kind"] === "outdated_dependency") && !row["recommendedVersion"] && row["remediableBy"] === "coding_agent") return {
|
|
5350
5436
|
...row,
|
|
5351
5437
|
fixable: false,
|
|
@@ -5363,7 +5449,7 @@ function withDependencyScope(row, { keepGroups = false } = {}) {
|
|
|
5363
5449
|
...row,
|
|
5364
5450
|
dependencyRelation: void 0
|
|
5365
5451
|
} : lean;
|
|
5366
|
-
if (!(row["kind"]
|
|
5452
|
+
if (!isDependencyFindingKind(String(row["kind"]))) return rest;
|
|
5367
5453
|
const kind = row["kind"];
|
|
5368
5454
|
return {
|
|
5369
5455
|
...rest,
|
|
@@ -5385,7 +5471,7 @@ function findingRows(findings, extra = []) {
|
|
|
5385
5471
|
"dependencyGroups",
|
|
5386
5472
|
"dependencyRelation",
|
|
5387
5473
|
...extra
|
|
5388
|
-
]).map((row) => withDependencyScope(row)).map(withFixability);
|
|
5474
|
+
]).map((row) => withDependencyScope(row)).map(withFixability).map((row) => Object.fromEntries(Object.entries(row).filter(([key, value]) => value !== null || key === "ignoreExpiresAt" && row["status"] === "ignored")));
|
|
5389
5475
|
}
|
|
5390
5476
|
/** The parts of an advisory record an agent reads; the rest (every affected range, every reference) is full: true. */
|
|
5391
5477
|
const ADVISORY_KEPT = [
|
|
@@ -5493,9 +5579,10 @@ function registerMaintenanceTools(server, client) {
|
|
|
5493
5579
|
if (!client.hasCapability("products:read")) return;
|
|
5494
5580
|
server.registerTool("taiga_list_findings", {
|
|
5495
5581
|
title: "List maintenance findings",
|
|
5496
|
-
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.",
|
|
5582
|
+
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. controlId lists the findings that breach one control. taiga_get_finding reads one.",
|
|
5497
5583
|
inputSchema: z.object({
|
|
5498
5584
|
productId,
|
|
5585
|
+
controlId: z.string().trim().min(1).max(40).optional().describe("Only the findings that breach this control, such as SEC-5.1 (one page, searched across all)"),
|
|
5499
5586
|
status: z.enum(MAINTENANCE_FINDING_STATUSES).optional().describe("Only findings in this status; omit for the ones needing action"),
|
|
5500
5587
|
severity: z.enum(MAINTENANCE_FINDING_SEVERITIES).optional().describe("Only this severity"),
|
|
5501
5588
|
kind: z.enum(MAINTENANCE_FINDING_KINDS).optional().describe("Only this kind of finding"),
|
|
@@ -5503,7 +5590,21 @@ function registerMaintenanceTools(server, client) {
|
|
|
5503
5590
|
}),
|
|
5504
5591
|
annotations: READ_ONLY
|
|
5505
5592
|
}, (params) => runTool(async () => {
|
|
5506
|
-
const { productId: id, ...filters } = params;
|
|
5593
|
+
const { productId: id, controlId, ...filters } = params;
|
|
5594
|
+
if (controlId) {
|
|
5595
|
+
if (params.cursor !== void 0) return toolError("VALIDATION_ERROR", "controlId reads every page itself; drop the cursor.");
|
|
5596
|
+
const wanted = controlId.toUpperCase();
|
|
5597
|
+
const found = await searchPages((cursor) => client.listFindings(id, {
|
|
5598
|
+
...filters,
|
|
5599
|
+
limit: 100,
|
|
5600
|
+
cursor
|
|
5601
|
+
}), "", () => [""], params.limit ?? 25, (row) => Array.isArray(row["controls"]) && row["controls"].some((c) => String(c).toUpperCase() === wanted));
|
|
5602
|
+
return toolResult({
|
|
5603
|
+
...page(findingRows(found.rows), null),
|
|
5604
|
+
...found.more ? { more: found.rows.length < (params.limit ?? 25) ? `Only the first 1000 findings were searched; narrow by status, severity or kind to reach the rest.` : "There may be more than the limit carries: raise the limit, or narrow by status, severity or kind." } : {},
|
|
5605
|
+
...found.rows.length === 0 && !found.more ? { note: `No ${params.status ? `${params.status} ` : "open "}finding breaches ${wanted}. That is no all-clear on its own: taiga_list_findings without controlId says when the product was last swept.` } : {}
|
|
5606
|
+
});
|
|
5607
|
+
}
|
|
5507
5608
|
const [result, sweep] = await Promise.all([client.listFindings(id, filters), params.cursor === void 0 ? sweepOverview(client, id).catch((error) => {
|
|
5508
5609
|
if (error instanceof TaigaApiError || isNetworkFailure(error)) return "unread";
|
|
5509
5610
|
throw error;
|
|
@@ -5555,7 +5656,7 @@ function registerMaintenanceTools(server, client) {
|
|
|
5555
5656
|
const summary = standingSummary(standing);
|
|
5556
5657
|
const assessment = standing.assessment;
|
|
5557
5658
|
const assessedAt = typeof assessment?.assessedAt === "string" ? assessment.assessedAt : null;
|
|
5558
|
-
const note = assessment?.state === "never" ? "Never assessed: the documents have not been checked against the policies yet, so nothing here is an all-clear. An assessment runs when the reality documents are derived or refreshed (taiga_refresh_reality_documents, paid work: ask first)." : summary.controlsMet + summary.controlsNotApplicable === 0 && assessedAt ? `No control is recorded as met or not applicable: the last assessment (${assessedAt}) either ran before assessments recorded those verdicts, or found none. Until the next one, only violations are known; the rest are not assessed, which is no pass.` : null;
|
|
5659
|
+
const note = assessment?.state === "never" ? "Never assessed: the documents have not been checked against the policies yet, so nothing here is an all-clear. " + (standing.canAssess === false && standing.hasPolicies !== false ? "It has no reality documents to assess yet: an import derives them (taiga_start_import for a product set up from its code, paid work: ask first), and the assessment follows." : "An assessment runs when the reality documents are derived or refreshed (taiga_refresh_reality_documents, paid work: ask first).") : summary.controlsMet + summary.controlsNotApplicable === 0 && assessedAt ? `No control is recorded as met or not applicable: the last assessment (${assessedAt}) either ran before assessments recorded those verdicts, or found none. Until the next one, only violations are known; the rest are not assessed, which is no pass.` : null;
|
|
5559
5660
|
const resolvedAny = Array.isArray(standing.resolvedDivergences) && standing.resolvedDivergences.length > 0;
|
|
5560
5661
|
const wording = violations.length > 0 || resolvedAny ? { wordingNote: "A violation's evidence is from its assessedAt; its title and summary may be from when it was first recorded. Where they disagree, the evidence is current." } : {};
|
|
5561
5662
|
if (params.controlId && params.violationId) return toolError("VALIDATION_ERROR", "Name either a control (controlId) or one violation (violationId), not both.");
|
|
@@ -5737,6 +5838,9 @@ function readWith(row, backlogRead) {
|
|
|
5737
5838
|
* were the same things.
|
|
5738
5839
|
*/
|
|
5739
5840
|
const KIND_SAID = { review_backlog: "review_initiatives" };
|
|
5841
|
+
/** The kinds as the rows say them, for the kind filter, and the way back to the API's word. */
|
|
5842
|
+
const KINDS_SAID = INBOX_ITEM_KINDS.map((kind) => KIND_SAID[kind] ?? kind);
|
|
5843
|
+
const KIND_FROM_SAID = Object.fromEntries(Object.entries(KIND_SAID).map(([api, said]) => [said, api]));
|
|
5740
5844
|
const SUBJECT_SAID = {
|
|
5741
5845
|
backlog_item: "initiative",
|
|
5742
5846
|
build_run: "run",
|
|
@@ -5827,7 +5931,7 @@ const STALE_CHECKS = 10;
|
|
|
5827
5931
|
async function staleReviews(client, rows) {
|
|
5828
5932
|
const byId = new Map(rows.filter((row) => row["kind"] === "review_pr" && row["subjectType"] === "build_run").map((row) => [String(row["id"]), row]));
|
|
5829
5933
|
const candidates = [...byId.values()].slice(0, STALE_CHECKS);
|
|
5830
|
-
|
|
5934
|
+
const uncheckedIds = new Set([...byId.keys()].slice(STALE_CHECKS));
|
|
5831
5935
|
const out = /* @__PURE__ */ new Map();
|
|
5832
5936
|
await Promise.all(candidates.map(async (row) => {
|
|
5833
5937
|
try {
|
|
@@ -5835,19 +5939,20 @@ async function staleReviews(client, rows) {
|
|
|
5835
5939
|
if (run["prMerged"] === true) return void out.set(String(row["id"]), "its pull request has merged");
|
|
5836
5940
|
if (run["prState"] === "closed") return void out.set(String(row["id"]), "its pull request was closed");
|
|
5837
5941
|
if (typeof run["backlogItemId"] !== "string") return;
|
|
5838
|
-
if (!client.hasCapability("backlog:read")) return void (
|
|
5942
|
+
if (!client.hasCapability("backlog:read")) return void uncheckedIds.add(String(row["id"]));
|
|
5839
5943
|
const item = (await client.getBacklogItem(run["backlogItemId"])).data;
|
|
5840
5944
|
if (item["state"] === "canceled" || item["status"] === "canceled") out.set(String(row["id"]), "its initiative was canceled; the pull request is still open on the forge");
|
|
5841
5945
|
else if (item["state"] === "done" || item["status"] === "done") out.set(String(row["id"]), "its initiative is done; this run is not the one that landed it");
|
|
5842
5946
|
} catch (error) {
|
|
5843
5947
|
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
5844
|
-
|
|
5948
|
+
uncheckedIds.add(String(row["id"]));
|
|
5845
5949
|
}
|
|
5846
5950
|
}));
|
|
5847
|
-
|
|
5951
|
+
await staleBacklogReviews(client, rows, out, uncheckedIds);
|
|
5848
5952
|
return {
|
|
5849
5953
|
stale: out,
|
|
5850
|
-
unchecked
|
|
5954
|
+
unchecked: uncheckedIds.size,
|
|
5955
|
+
uncheckedIds
|
|
5851
5956
|
};
|
|
5852
5957
|
}
|
|
5853
5958
|
/**
|
|
@@ -5855,12 +5960,13 @@ async function staleReviews(client, rows) {
|
|
|
5855
5960
|
* waiting 15 days on a product whose initiatives had all moved on, with nothing an agent could act on. Best effort,
|
|
5856
5961
|
* a handful of products; returns how many could not be checked.
|
|
5857
5962
|
*/
|
|
5858
|
-
async function staleBacklogReviews(client, rows, out) {
|
|
5963
|
+
async function staleBacklogReviews(client, rows, out, unchecked) {
|
|
5859
5964
|
const asks = [...new Map(rows.filter((row) => row["kind"] === "review_backlog" && typeof row["productId"] === "string").map((row) => [String(row["id"]), row])).values()];
|
|
5860
|
-
|
|
5861
|
-
if (
|
|
5965
|
+
const skip = (which) => which.forEach((row) => unchecked.add(String(row["id"])));
|
|
5966
|
+
if (asks.length === 0) return;
|
|
5967
|
+
if (!client.hasCapability("backlog:read")) return skip(asks);
|
|
5862
5968
|
const checked = [...new Set(asks.map((row) => String(row["productId"])))].slice(0, STALE_CHECKS);
|
|
5863
|
-
|
|
5969
|
+
skip(asks.filter((row) => !checked.includes(String(row["productId"]))));
|
|
5864
5970
|
const empty = /* @__PURE__ */ new Set();
|
|
5865
5971
|
await Promise.all(checked.map(async (productId) => {
|
|
5866
5972
|
try {
|
|
@@ -5871,11 +5977,10 @@ async function staleBacklogReviews(client, rows, out) {
|
|
|
5871
5977
|
empty.add(productId);
|
|
5872
5978
|
} catch (error) {
|
|
5873
5979
|
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
5874
|
-
|
|
5980
|
+
skip(asks.filter((row) => row["productId"] === productId));
|
|
5875
5981
|
}
|
|
5876
5982
|
}));
|
|
5877
5983
|
for (const row of asks) if (empty.has(String(row["productId"]))) out.set(String(row["id"]), "no initiative is in the backlog or todo now: the ones it asked about have moved on");
|
|
5878
|
-
return unchecked;
|
|
5879
5984
|
}
|
|
5880
5985
|
/**
|
|
5881
5986
|
* One product's Action Required as counts, the way the list's summary counts them: open is what still stands and is
|
|
@@ -5901,6 +6006,35 @@ function isDue(row, now) {
|
|
|
5901
6006
|
}
|
|
5902
6007
|
/** Pages read for the summary; past this it says it counted only those. */
|
|
5903
6008
|
const SUMMARY_PAGE = 100;
|
|
6009
|
+
/** Pages the summary reads before it says it stopped: 1,000 items. */
|
|
6010
|
+
const SUMMARY_PAGES = 10;
|
|
6011
|
+
/** Every item the summary counts, up to SUMMARY_PAGES pages; nextCursor set when it stopped short. Null on failure. */
|
|
6012
|
+
async function everyItem(client, query, orNull = true) {
|
|
6013
|
+
const data = [];
|
|
6014
|
+
let cursor;
|
|
6015
|
+
try {
|
|
6016
|
+
for (let index = 0; index < SUMMARY_PAGES; index += 1) {
|
|
6017
|
+
const page = await client.listInbox({
|
|
6018
|
+
...query,
|
|
6019
|
+
limit: SUMMARY_PAGE,
|
|
6020
|
+
cursor
|
|
6021
|
+
});
|
|
6022
|
+
data.push(...page.data);
|
|
6023
|
+
if (!page.nextCursor) return {
|
|
6024
|
+
data,
|
|
6025
|
+
nextCursor: null
|
|
6026
|
+
};
|
|
6027
|
+
cursor = page.nextCursor;
|
|
6028
|
+
}
|
|
6029
|
+
return {
|
|
6030
|
+
data,
|
|
6031
|
+
nextCursor: cursor ?? null
|
|
6032
|
+
};
|
|
6033
|
+
} catch (error) {
|
|
6034
|
+
if (orNull && (error instanceof TaigaApiError || isNetworkFailure(error))) return null;
|
|
6035
|
+
throw error;
|
|
6036
|
+
}
|
|
6037
|
+
}
|
|
5904
6038
|
const SETUP_TASK_FIELDS = [
|
|
5905
6039
|
"id",
|
|
5906
6040
|
"productId",
|
|
@@ -5979,15 +6113,17 @@ function registerInboxTools(server, client) {
|
|
|
5979
6113
|
inputSchema: z.object({
|
|
5980
6114
|
productId: productId.optional(),
|
|
5981
6115
|
category: z.enum(["gate", "setup"]).optional().describe("gate: things to review; setup: things to set up"),
|
|
6116
|
+
kind: z.enum(KINDS_SAID).optional().describe("Only this kind, such as review_plan or review_pr, as the rows name it"),
|
|
5982
6117
|
resolved: z.boolean().optional().describe("Read the resolved archive (done and dismissed) instead"),
|
|
5983
6118
|
full: z.boolean().optional().describe("Carry each item's metadata, such as a document decision's whole text (default false)"),
|
|
5984
6119
|
...pagination
|
|
5985
6120
|
}),
|
|
5986
6121
|
annotations: READ_ONLY
|
|
5987
6122
|
}, (params) => runTool(async () => {
|
|
5988
|
-
const { resolved, full, ...rest } = params;
|
|
6123
|
+
const { resolved, full, kind, ...rest } = params;
|
|
5989
6124
|
const query = {
|
|
5990
6125
|
...rest,
|
|
6126
|
+
...kind ? { kind: KIND_FROM_SAID[kind] ?? kind } : {},
|
|
5991
6127
|
...resolved ? { resolved: true } : {}
|
|
5992
6128
|
};
|
|
5993
6129
|
const first = params.cursor === void 0;
|
|
@@ -5995,14 +6131,7 @@ function registerInboxTools(server, client) {
|
|
|
5995
6131
|
...query,
|
|
5996
6132
|
...first ? { withTotal: true } : {}
|
|
5997
6133
|
});
|
|
5998
|
-
const overview = !first || resolved ? null : !result.nextCursor ? result : await client
|
|
5999
|
-
...query,
|
|
6000
|
-
limit: SUMMARY_PAGE,
|
|
6001
|
-
cursor: void 0
|
|
6002
|
-
}).catch((error) => {
|
|
6003
|
-
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
6004
|
-
throw error;
|
|
6005
|
-
});
|
|
6134
|
+
const overview = !first || resolved ? null : !result.nextCursor ? result : await everyItem(client, query);
|
|
6006
6135
|
const now = Date.now();
|
|
6007
6136
|
const due_ = (row) => isDue(row, now);
|
|
6008
6137
|
const rows = result.data;
|
|
@@ -6011,26 +6140,35 @@ function registerInboxTools(server, client) {
|
|
|
6011
6140
|
const overviewDue = overview ? overview.data.filter(due_) : [];
|
|
6012
6141
|
const checked = resolved ? {
|
|
6013
6142
|
stale: /* @__PURE__ */ new Map(),
|
|
6014
|
-
unchecked: 0
|
|
6143
|
+
unchecked: 0,
|
|
6144
|
+
uncheckedIds: /* @__PURE__ */ new Set()
|
|
6015
6145
|
} : await staleReviews(client, [...due, ...overviewDue]);
|
|
6016
6146
|
const stale = checked.stale;
|
|
6017
6147
|
return toolResult({
|
|
6018
6148
|
...overview ? {
|
|
6019
6149
|
summary: summaryOf(overviewDue, stale),
|
|
6020
|
-
...overview.nextCursor ? { summaryNote: `The summary counts the first
|
|
6150
|
+
...overview.nextCursor ? { summaryNote: `The summary counts the first 1000 items` + (params.productId ? "." : "; productId narrows it to one product.") } : {}
|
|
6021
6151
|
} : {},
|
|
6022
6152
|
...page(due.map((row) => {
|
|
6023
6153
|
const item = itemRow(row, now, full === true, client.hasCapability("backlog:read"));
|
|
6024
6154
|
const why = stale.get(String(row["id"]));
|
|
6025
|
-
|
|
6155
|
+
if (why) return {
|
|
6026
6156
|
...item,
|
|
6027
6157
|
stale: why
|
|
6158
|
+
};
|
|
6159
|
+
return checked.uncheckedIds.has(String(row["id"])) ? {
|
|
6160
|
+
...item,
|
|
6161
|
+
staleChecked: false
|
|
6028
6162
|
} : item;
|
|
6029
6163
|
}), result.nextCursor),
|
|
6030
6164
|
...totalOf(result),
|
|
6031
|
-
...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
|
|
6165
|
+
...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 1000.` : ".") } : {},
|
|
6032
6166
|
...hidden > 0 ? { snoozedHidden: hidden } : {},
|
|
6033
|
-
...checked.unchecked > 0 ?
|
|
6167
|
+
...checked.unchecked > 0 ? (() => {
|
|
6168
|
+
const onPage = due.filter((row) => checked.uncheckedIds.has(String(row["id"]))).length;
|
|
6169
|
+
const beyond = checked.unchecked - onPage;
|
|
6170
|
+
return { staleNote: `${[...onPage > 0 ? [`${onPage} review${onPage === 1 ? "" : "s"} on this page could not be checked for whether the subject moved on: those rows say staleChecked: false, and their readWith read tells`] : [], ...beyond > 0 ? [`${beyond} review${beyond === 1 ? "" : "s"} counted in the summary but not on this page could not be checked either, so the summary counts them open`] : []].join("; ")}.` };
|
|
6171
|
+
})() : {}
|
|
6034
6172
|
});
|
|
6035
6173
|
}));
|
|
6036
6174
|
server.registerTool("taiga_list_setup_tasks", {
|
|
@@ -6395,13 +6533,10 @@ const CONTEXT_RUN_SAID = [
|
|
|
6395
6533
|
* the list's disagree (18 open here, 16 there), because this one counted stale reviews and put-off items as open.
|
|
6396
6534
|
*/
|
|
6397
6535
|
async function attentionPart(client, productId) {
|
|
6398
|
-
const result = await client
|
|
6399
|
-
productId,
|
|
6400
|
-
limit: 100
|
|
6401
|
-
});
|
|
6536
|
+
const result = await everyItem(client, { productId }, false);
|
|
6402
6537
|
return {
|
|
6403
6538
|
...await attentionCounts(client, result.data),
|
|
6404
|
-
...result.nextCursor ? { note: "These count the first
|
|
6539
|
+
...result.nextCursor ? { note: "These count the first 1000 items; taiga_list_action_required pages the rest." } : {}
|
|
6405
6540
|
};
|
|
6406
6541
|
}
|
|
6407
6542
|
/** The latest sweep and what it left open, as counts; taiga_list_findings lists them. */
|
|
@@ -22976,7 +23111,7 @@ function registerAllTools(server, client, options = {}) {
|
|
|
22976
23111
|
function registerWhoami(server, client, registered) {
|
|
22977
23112
|
server.registerTool("taiga_whoami", {
|
|
22978
23113
|
title: "Who am I",
|
|
22979
|
-
description: "Read who this API key acts as: the API it talks to (api
|
|
23114
|
+
description: "Read who this API key acts as: the API host it talks to (api), the person's email and organization role, the key's scopes (null means every capability the person has), its capabilities, and its tools split into reads, writes, and ask-first writes (paid agent work, changes others see). A tool the key cannot use is not offered at all.",
|
|
22980
23115
|
inputSchema: z.object({}),
|
|
22981
23116
|
annotations: READ_ONLY
|
|
22982
23117
|
}, () => runTool(async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taiga-ai/mcp-server",
|
|
3
|
-
"version": "0.26.0-rc.
|
|
3
|
+
"version": "0.26.0-rc.510",
|
|
4
4
|
"description": "Taiga for AI coding assistants: read products, policies, initiatives and runs, and operate the line, over MCP",
|
|
5
5
|
"mcpName": "ga.tai/mcp-server",
|
|
6
6
|
"type": "module",
|