@taiga-ai/mcp-server 0.13.0-rc.424 → 0.14.0-dev.425
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 +1095 -13
- package/package.json +1 -1
- package/src/client.ts +313 -5
- package/src/server.integration.test.ts +899 -5
- package/src/tools/autonomy.ts +90 -0
- package/src/tools/catalogue.test.ts +3 -0
- package/src/tools/documents.ts +28 -15
- package/src/tools/inbox.ts +159 -0
- package/src/tools/index.ts +12 -0
- package/src/tools/instructions.ts +27 -0
- package/src/tools/knowledge.ts +257 -0
- package/src/tools/maintenance.ts +336 -0
- package/src/tools/observe.ts +241 -0
- package/src/tools/product-documents.ts +193 -0
- package/taiga-ai-mcp-server-0.14.0-dev.425.tgz +0 -0
- package/taiga-ai-mcp-server-0.13.0-rc.424.tgz +0 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import {
|
|
3
|
+
MAINTENANCE_FINDING_KINDS,
|
|
4
|
+
MAINTENANCE_FINDING_SEVERITIES,
|
|
5
|
+
MAINTENANCE_FINDING_STATUSES,
|
|
6
|
+
MAINTENANCE_IGNORE_REASONS,
|
|
7
|
+
} from '@taiga/types';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import type { TaigaClient } from '../client.js';
|
|
10
|
+
import { OPERATE, READ_ONLY } from './annotations.js';
|
|
11
|
+
import { runTool, toolError, toolResult } from './errors.js';
|
|
12
|
+
import { pagination, productId } from './inputs.js';
|
|
13
|
+
import { page, pickFields } from './summarize.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Maintaining (docs.tai.ga, Operate): what the sweeps found in a product's
|
|
17
|
+
* repository and where it stands against the organization's own policies,
|
|
18
|
+
* and the two things a person does with a finding, Ignore and Fix. Sweeps run
|
|
19
|
+
* themselves and nothing is marked fixed by hand: a finding resolves when the
|
|
20
|
+
* next sweep no longer detects it, so there is no tool for either.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type Row = Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
/** A finding as a list row: enough to decide whether to open, fix or ignore it. */
|
|
26
|
+
const FINDING_ROW_FIELDS = [
|
|
27
|
+
'id',
|
|
28
|
+
'kind',
|
|
29
|
+
'severity',
|
|
30
|
+
'tier',
|
|
31
|
+
'status',
|
|
32
|
+
'title',
|
|
33
|
+
'ecosystem',
|
|
34
|
+
'packageName',
|
|
35
|
+
'currentVersion',
|
|
36
|
+
'recommendedVersion',
|
|
37
|
+
'advisoryId',
|
|
38
|
+
'location',
|
|
39
|
+
'sourcePaths',
|
|
40
|
+
'productRepositoryId',
|
|
41
|
+
'ownership',
|
|
42
|
+
'remediableBy',
|
|
43
|
+
'controls',
|
|
44
|
+
'backlogItemId',
|
|
45
|
+
'detectedAt',
|
|
46
|
+
'ignoreReason',
|
|
47
|
+
'ignoreExpiresAt',
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
/** A sweep as the record of when the product was last checked, and whether that check held. */
|
|
51
|
+
const SWEEP_FIELDS = [
|
|
52
|
+
'id',
|
|
53
|
+
'trigger',
|
|
54
|
+
'status',
|
|
55
|
+
'scanners',
|
|
56
|
+
'findingsCount',
|
|
57
|
+
'newFindingsCount',
|
|
58
|
+
'error',
|
|
59
|
+
'createdAt',
|
|
60
|
+
'completedAt',
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
/** A policy violation in a list: what it is, how bad, and what is being done about it. */
|
|
64
|
+
const VIOLATION_FIELDS = [
|
|
65
|
+
'id',
|
|
66
|
+
'controlId',
|
|
67
|
+
'policyType',
|
|
68
|
+
'severity',
|
|
69
|
+
'status',
|
|
70
|
+
'title',
|
|
71
|
+
'summary',
|
|
72
|
+
'work',
|
|
73
|
+
'unconfirmedSince',
|
|
74
|
+
'verifyRequestedAt',
|
|
75
|
+
'assessedAt',
|
|
76
|
+
] as const;
|
|
77
|
+
|
|
78
|
+
const findingId = z.string().uuid().describe('Finding ID -- taiga_list_findings gives it');
|
|
79
|
+
|
|
80
|
+
export function registerMaintenanceTools(server: McpServer, client: TaigaClient) {
|
|
81
|
+
if (!client.hasCapability('products:read')) return;
|
|
82
|
+
|
|
83
|
+
server.registerTool(
|
|
84
|
+
'taiga_get_maintenance',
|
|
85
|
+
{
|
|
86
|
+
title: "Get a product's maintenance standing",
|
|
87
|
+
description:
|
|
88
|
+
"Where a product's repository stands: open findings by severity and kind, the most pressing ones, and " +
|
|
89
|
+
'the latest sweeps, so you can tell when the product was last checked and whether that check succeeded. A ' +
|
|
90
|
+
'quiet list from a sweep that last ran long ago, or that failed, is not an all-clear; a product never swept ' +
|
|
91
|
+
'says so. Sweeps run on their own after a push and periodically; nothing here starts one. Read the ' +
|
|
92
|
+
'findings with taiga_list_findings and the policy standing with taiga_get_policy_standing.',
|
|
93
|
+
inputSchema: { productId },
|
|
94
|
+
annotations: READ_ONLY,
|
|
95
|
+
},
|
|
96
|
+
(params) =>
|
|
97
|
+
runTool(async () => {
|
|
98
|
+
const [summary, sweeps] = await Promise.all([
|
|
99
|
+
client.getMaintenanceSummary(params.productId),
|
|
100
|
+
client.listMaintenanceSweeps(params.productId, { limit: 3 }),
|
|
101
|
+
]);
|
|
102
|
+
const latest = pickFields(sweeps.data as Row[], SWEEP_FIELDS);
|
|
103
|
+
const { lastRun: _lastRun, ...standing } = summary.data as Row;
|
|
104
|
+
return toolResult({
|
|
105
|
+
...standing,
|
|
106
|
+
latestSweeps: latest,
|
|
107
|
+
...(latest.length === 0 ? { note: 'This product has never been swept: no result is not an all-clear.' } : {}),
|
|
108
|
+
});
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
server.registerTool(
|
|
113
|
+
'taiga_list_findings',
|
|
114
|
+
{
|
|
115
|
+
title: 'List maintenance findings',
|
|
116
|
+
description:
|
|
117
|
+
"List what the sweeps found in a product's repository: vulnerable or outdated dependencies, committed " +
|
|
118
|
+
'secrets, insecure code (sast) and infrastructure misconfigurations (iac_misconfig). Each row carries its ' +
|
|
119
|
+
'severity and its priority tier (act_now: known exploited; urgent: likely and serious; elevated: one of ' +
|
|
120
|
+
'the two; routine), where it is, who can fix it (remediableBy: coding_agent, or upstream_only for a ' +
|
|
121
|
+
'submodule or a dependency with no fixed version), the policy controls it breaches, and the initiative ' +
|
|
122
|
+
'already fixing it (backlogItemId). Omit status for the findings that need action; work from the top tier ' +
|
|
123
|
+
'down. Read one in full with taiga_get_finding.',
|
|
124
|
+
inputSchema: {
|
|
125
|
+
productId,
|
|
126
|
+
status: z
|
|
127
|
+
.enum(MAINTENANCE_FINDING_STATUSES)
|
|
128
|
+
.optional()
|
|
129
|
+
.describe('Only findings in this status; omit for the ones needing action'),
|
|
130
|
+
severity: z.enum(MAINTENANCE_FINDING_SEVERITIES).optional().describe('Only this severity'),
|
|
131
|
+
kind: z.enum(MAINTENANCE_FINDING_KINDS).optional().describe('Only this kind of finding'),
|
|
132
|
+
...pagination,
|
|
133
|
+
},
|
|
134
|
+
annotations: READ_ONLY,
|
|
135
|
+
},
|
|
136
|
+
(params) =>
|
|
137
|
+
runTool(async () => {
|
|
138
|
+
const { productId: id, ...filters } = params;
|
|
139
|
+
const result = await client.listFindings(id, filters);
|
|
140
|
+
return toolResult({
|
|
141
|
+
...page(pickFields(result.data as Row[], FINDING_ROW_FIELDS), result.nextCursor),
|
|
142
|
+
...(typeof result.totalCount === 'number' ? { totalCount: result.totalCount } : {}),
|
|
143
|
+
});
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
server.registerTool(
|
|
148
|
+
'taiga_get_finding',
|
|
149
|
+
{
|
|
150
|
+
title: 'Get a maintenance finding',
|
|
151
|
+
description:
|
|
152
|
+
'Read one finding in full: what it is, how likely it is to be exploited (EPSS, the known-exploited list), ' +
|
|
153
|
+
'the advisory and the fixed version, the place in the code, the policy controls it breaches, why it is ' +
|
|
154
|
+
'ranked where it is, its history (reintroduced, previously resolved) and the remediation under way. A ' +
|
|
155
|
+
"secret's value is never stored; the finding names the rule and the location only.",
|
|
156
|
+
inputSchema: { findingId },
|
|
157
|
+
annotations: READ_ONLY,
|
|
158
|
+
},
|
|
159
|
+
(params) => runTool(async () => toolResult((await client.getFinding(params.findingId)).data))
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
server.registerTool(
|
|
163
|
+
'taiga_get_policy_standing',
|
|
164
|
+
{
|
|
165
|
+
title: "Get a product's policy standing",
|
|
166
|
+
description:
|
|
167
|
+
"Where a product stands against the organization's own published policies: the controls it violates, " +
|
|
168
|
+
"each with its violations (critical and high only, each citing the policy's sentence and the document " +
|
|
169
|
+
'passage that contradicts it), what is being done about each (an initiative raised, a fix in progress, ' +
|
|
170
|
+
'awaiting verification), and when the documents were derived and assessed. A control with no violation ' +
|
|
171
|
+
'is not listed, and it is not "passing" either: there is no such state. Pass full: true for every ' +
|
|
172
|
+
"violation's evidence and the resolved ones. Fix a control with taiga_fix_policy_violation.",
|
|
173
|
+
inputSchema: {
|
|
174
|
+
productId,
|
|
175
|
+
full: z
|
|
176
|
+
.boolean()
|
|
177
|
+
.optional()
|
|
178
|
+
.describe("Carry each violation's evidence, the policy excerpt and the resolved violations (default false)"),
|
|
179
|
+
},
|
|
180
|
+
annotations: READ_ONLY,
|
|
181
|
+
},
|
|
182
|
+
(params) =>
|
|
183
|
+
runTool(async () => {
|
|
184
|
+
const standing = (await client.getPolicyStanding(params.productId)).data as Row;
|
|
185
|
+
if (params.full) return toolResult(standing);
|
|
186
|
+
const controls = Array.isArray(standing.controls) ? (standing.controls as Row[]) : [];
|
|
187
|
+
const violations = Array.isArray(standing.divergences) ? (standing.divergences as Row[]) : [];
|
|
188
|
+
const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
|
|
189
|
+
return toolResult({
|
|
190
|
+
productId: standing.productId,
|
|
191
|
+
hasPolicies: standing.hasPolicies,
|
|
192
|
+
assessment: standing.assessment,
|
|
193
|
+
documents: standing.documents,
|
|
194
|
+
// Only the controls something is recorded against; every other
|
|
195
|
+
// control is simply not reported, as the page does.
|
|
196
|
+
controls: pickFields(
|
|
197
|
+
controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0),
|
|
198
|
+
['controlId', 'policyType', 'text', 'state', 'divergenceCount', 'findingCount', 'plannedCount']
|
|
199
|
+
),
|
|
200
|
+
violations: pickFields(violations, VIOLATION_FIELDS),
|
|
201
|
+
resolvedViolations: resolved,
|
|
202
|
+
});
|
|
203
|
+
})
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (!client.hasCapability('products:write')) return;
|
|
207
|
+
|
|
208
|
+
server.registerTool(
|
|
209
|
+
'taiga_ignore_finding',
|
|
210
|
+
{
|
|
211
|
+
title: 'Ignore or reopen a finding',
|
|
212
|
+
description:
|
|
213
|
+
'Ignore a finding that does not apply or whose risk is accepted, with the reason (false_positive, ' +
|
|
214
|
+
'not_reachable, accepted_risk, wont_fix), an optional note and an optional expiry after which it resurfaces ' +
|
|
215
|
+
'if it still exists; or reopen an ignored one (ignored: false). Ignored findings are kept and reviewable. ' +
|
|
216
|
+
'There is no "mark fixed": a finding resolves when the next sweep no longer detects it. For a secret that ' +
|
|
217
|
+
'is only in history, rotate the credential outside Taiga, then ignore it with a reason. Ignoring hides a ' +
|
|
218
|
+
"shared signal and can record the team's acceptance of a risk, which is the person's decision: ask the " +
|
|
219
|
+
'person first.',
|
|
220
|
+
inputSchema: {
|
|
221
|
+
findingId,
|
|
222
|
+
ignored: z.boolean().describe('true ignores the finding, false reopens it'),
|
|
223
|
+
reason: z.enum(MAINTENANCE_IGNORE_REASONS).optional().describe('Why it is ignored; required when ignoring'),
|
|
224
|
+
note: z.string().trim().max(280).optional().describe('A short justification, kept for audit'),
|
|
225
|
+
expiresAt: z
|
|
226
|
+
.string()
|
|
227
|
+
.datetime({ offset: true })
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('When the ignore lapses and the finding resurfaces if it still exists (ISO 8601, in the future)'),
|
|
230
|
+
},
|
|
231
|
+
annotations: OPERATE,
|
|
232
|
+
},
|
|
233
|
+
(params) =>
|
|
234
|
+
runTool(async () => {
|
|
235
|
+
if (params.ignored && params.reason === undefined) {
|
|
236
|
+
return toolError('VALIDATION_FAILED', 'Say why the finding is ignored (reason).');
|
|
237
|
+
}
|
|
238
|
+
const body = params.ignored
|
|
239
|
+
? {
|
|
240
|
+
status: 'ignored' as const,
|
|
241
|
+
ignoreReason: params.reason,
|
|
242
|
+
...(params.note !== undefined ? { ignoreNote: params.note } : {}),
|
|
243
|
+
...(params.expiresAt !== undefined ? { ignoreExpiresAt: params.expiresAt } : {}),
|
|
244
|
+
}
|
|
245
|
+
: { status: 'open' as const };
|
|
246
|
+
const finding = (await client.updateFinding(params.findingId, body)).data as Row;
|
|
247
|
+
return toolResult(pickFields([finding], [...FINDING_ROW_FIELDS, 'ignoreNote'])[0]);
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
server.registerTool(
|
|
252
|
+
'taiga_fix_finding',
|
|
253
|
+
{
|
|
254
|
+
title: 'Fix a finding',
|
|
255
|
+
description:
|
|
256
|
+
"Ask for a finding's fix: Taiga raises an initiative and adds it to the end of the product's queue, where " +
|
|
257
|
+
"the line plans it in turn; whether the build then waits for a person is the initiative's review gate, " +
|
|
258
|
+
'as for any initiative. The fix is a normal run that ends in a pull request you review and merge. With ' +
|
|
259
|
+
"wholeGroup: true it fixes the finding's whole group in one initiative, as the page does: for a " +
|
|
260
|
+
'dependency, every open advisory on that package at that installed version in one version bump; for ' +
|
|
261
|
+
'code or infrastructure, every location the same rule flagged. Absent for a secret only in history, ' +
|
|
262
|
+
'anything in a submodule, and a dependency with no fixed version (remediableBy says upstream_only); the ' +
|
|
263
|
+
'API refuses those. Starts paid work; ask the person first.',
|
|
264
|
+
inputSchema: {
|
|
265
|
+
productId,
|
|
266
|
+
findingId,
|
|
267
|
+
wholeGroup: z
|
|
268
|
+
.boolean()
|
|
269
|
+
.optional()
|
|
270
|
+
.describe("Fix every finding in this finding's group (its package and version, or its rule) together"),
|
|
271
|
+
},
|
|
272
|
+
annotations: OPERATE,
|
|
273
|
+
},
|
|
274
|
+
(params) =>
|
|
275
|
+
runTool(async () => {
|
|
276
|
+
if (!params.wholeGroup) {
|
|
277
|
+
const fixed = (await client.remediateFinding(params.productId, params.findingId)).data as {
|
|
278
|
+
finding?: Row;
|
|
279
|
+
backlogItemId?: unknown;
|
|
280
|
+
};
|
|
281
|
+
return toolResult({
|
|
282
|
+
backlogItemId: fixed.backlogItemId,
|
|
283
|
+
finding: fixed.finding ? pickFields([fixed.finding], FINDING_ROW_FIELDS)[0] : null,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
// The group is the finding's own: the route matches the package (or
|
|
287
|
+
// rule), the installed version and the repository exactly, so they are
|
|
288
|
+
// taken from the finding rather than asked of the caller.
|
|
289
|
+
const finding = (await client.getFinding(params.findingId)).data as Row;
|
|
290
|
+
// The finding is read by its own id, so it is held to the product the
|
|
291
|
+
// group fix is sent to, as the single-finding route holds it.
|
|
292
|
+
if (finding.productId !== params.productId) {
|
|
293
|
+
return toolError('NOT_FOUND', 'No such finding in this product.');
|
|
294
|
+
}
|
|
295
|
+
const result = await client.remediatePackage(params.productId, {
|
|
296
|
+
ecosystem: String(finding.ecosystem),
|
|
297
|
+
packageName: String(finding.packageName),
|
|
298
|
+
currentVersion: typeof finding.currentVersion === 'string' ? finding.currentVersion : null,
|
|
299
|
+
productRepositoryId: typeof finding.productRepositoryId === 'string' ? finding.productRepositoryId : null,
|
|
300
|
+
});
|
|
301
|
+
return toolResult(result.data);
|
|
302
|
+
})
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
server.registerTool(
|
|
306
|
+
'taiga_fix_policy_violation',
|
|
307
|
+
{
|
|
308
|
+
title: 'Fix policy violations',
|
|
309
|
+
description:
|
|
310
|
+
"Ask for the fix of a product's policy violations: name a control to cover every violation recorded " +
|
|
311
|
+
'against it that has no initiative yet, or a single violation (violationId) when the control has more ' +
|
|
312
|
+
"than one. Taiga raises one initiative and adds it to the end of the product's queue, exactly as a " +
|
|
313
|
+
'finding fix does. The violation stays listed while the work is under way: only the next assessment ' +
|
|
314
|
+
'can record that the documents no longer show it. Starts paid work; ask the person first.',
|
|
315
|
+
inputSchema: {
|
|
316
|
+
productId,
|
|
317
|
+
controlId: z.string().min(1).max(40).optional().describe("The control's identifier, e.g. SEC-3.4"),
|
|
318
|
+
violationId: z.string().uuid().optional().describe('One violation -- taiga_get_policy_standing gives it'),
|
|
319
|
+
},
|
|
320
|
+
annotations: OPERATE,
|
|
321
|
+
},
|
|
322
|
+
(params) =>
|
|
323
|
+
runTool(async () => {
|
|
324
|
+
if ((params.controlId === undefined) === (params.violationId === undefined)) {
|
|
325
|
+
return toolError(
|
|
326
|
+
'VALIDATION_FAILED',
|
|
327
|
+
'Name either a control (controlId) or one violation (violationId), not both or neither.'
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const result = params.controlId
|
|
331
|
+
? await client.remediatePolicyControl(params.productId, params.controlId)
|
|
332
|
+
: await client.remediatePolicyViolation(params.productId, params.violationId!);
|
|
333
|
+
return toolResult(result.data);
|
|
334
|
+
})
|
|
335
|
+
);
|
|
336
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { ENVIRONMENT_TYPES } from '@taiga/types';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
5
|
+
import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
6
|
+
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
7
|
+
import { pagination, productId } from './inputs.js';
|
|
8
|
+
import { page, pickFields } from './summarize.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What runs where and how it is doing (docs.tai.ga, Deliver: Deployments;
|
|
12
|
+
* Operate: Monitoring). Taiga observes deployments, it does not perform them:
|
|
13
|
+
* the history is mirrored from the provider, so these tools read and never
|
|
14
|
+
* deploy, approve or cancel. Importing history reaches into the connected
|
|
15
|
+
* provider and is an administrator's action in the web.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type Row = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
/** A deployment as a line of history: what shipped where, how it ended, and who shipped it. */
|
|
21
|
+
const DEPLOYMENT_FIELDS = [
|
|
22
|
+
'id',
|
|
23
|
+
'environmentId',
|
|
24
|
+
'status',
|
|
25
|
+
'commitSha',
|
|
26
|
+
'commitSubject',
|
|
27
|
+
'version',
|
|
28
|
+
'tag',
|
|
29
|
+
'ref',
|
|
30
|
+
'prNumber',
|
|
31
|
+
'buildRunId',
|
|
32
|
+
'actorLogin',
|
|
33
|
+
'error',
|
|
34
|
+
'runUrl',
|
|
35
|
+
'createdAt',
|
|
36
|
+
'completedAt',
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
const ENVIRONMENT_FIELDS = ['id', 'name', 'slug', 'type', 'ordinal', 'status', 'isProtected', 'url'] as const;
|
|
40
|
+
|
|
41
|
+
const environmentId = z.string().uuid().describe('Environment ID -- taiga_get_deployments lists them');
|
|
42
|
+
const windowDays = (choices: string) =>
|
|
43
|
+
z.number().int().optional().describe(`Days to look back: ${choices} (default 30)`);
|
|
44
|
+
|
|
45
|
+
/** Whether a failure is the API's (refused, missing, unreachable) rather than a bug here. */
|
|
46
|
+
const isApiFailure = (error: unknown) => error instanceof TaigaApiError || isNetworkFailure(error);
|
|
47
|
+
|
|
48
|
+
/** A part that may fail on its own without failing the read: null and the reason, or the value. */
|
|
49
|
+
async function part<T>(read: () => Promise<T>): Promise<{ value: T } | { unavailable: string }> {
|
|
50
|
+
try {
|
|
51
|
+
return { value: await read() };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (!isApiFailure(error)) throw error;
|
|
54
|
+
return {
|
|
55
|
+
unavailable: error instanceof TaigaApiError ? `${error.status}: ${error.apiMessage ?? ''}`.trim() : 'unreachable',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Environment pages read for one answer; a product has a handful, so this is a guard, not a page size. */
|
|
61
|
+
const ENVIRONMENT_PAGES = 5;
|
|
62
|
+
|
|
63
|
+
/** Every environment of a product, page by page, and whether any were left past the guard. */
|
|
64
|
+
async function allEnvironments(client: TaigaClient, product: string): Promise<{ rows: Row[]; more: boolean }> {
|
|
65
|
+
const rows: Row[] = [];
|
|
66
|
+
let cursor: string | undefined;
|
|
67
|
+
for (let pageNo = 0; pageNo < ENVIRONMENT_PAGES; pageNo++) {
|
|
68
|
+
const result = await client.listEnvironments(product, { limit: 100, cursor });
|
|
69
|
+
rows.push(...(result.data as Row[]));
|
|
70
|
+
if (!result.nextCursor) return { rows, more: false };
|
|
71
|
+
cursor = result.nextCursor;
|
|
72
|
+
}
|
|
73
|
+
return { rows, more: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
77
|
+
if (!client.hasCapability('products:read')) return;
|
|
78
|
+
|
|
79
|
+
server.registerTool(
|
|
80
|
+
'taiga_get_deployments',
|
|
81
|
+
{
|
|
82
|
+
title: 'Get what runs where',
|
|
83
|
+
description:
|
|
84
|
+
"What is running in each of a product's environments now, anything deploying to them, and the recent " +
|
|
85
|
+
'history, newest first: each deployment with its commit, pull request, status, who deployed it and, ' +
|
|
86
|
+
'for a failure, the error the provider reported. What runs is the most recent deployment that actually ' +
|
|
87
|
+
'succeeded, so a newer failed attempt shows beside the version still running, not in its place. The ' +
|
|
88
|
+
'history is mirrored from the provider, including deployments made outside Taiga; syncStatus says when ' +
|
|
89
|
+
"it last synced. Pass environmentId for one environment's history. Taiga observes deployments and " +
|
|
90
|
+
'never performs them.',
|
|
91
|
+
inputSchema: { productId, environmentId: environmentId.optional(), ...pagination },
|
|
92
|
+
annotations: READ_ONLY,
|
|
93
|
+
},
|
|
94
|
+
(params) =>
|
|
95
|
+
runTool(async () => {
|
|
96
|
+
const [environments, current, history, sync] = await Promise.all([
|
|
97
|
+
allEnvironments(client, params.productId),
|
|
98
|
+
client.getCurrentDeployments(params.productId),
|
|
99
|
+
client.listDeployments(params.productId, {
|
|
100
|
+
environmentId: params.environmentId,
|
|
101
|
+
limit: params.limit ?? 10,
|
|
102
|
+
cursor: params.cursor,
|
|
103
|
+
}),
|
|
104
|
+
part(() => client.getDeploymentSyncStatus(params.productId)),
|
|
105
|
+
]);
|
|
106
|
+
const now = current.data as { current?: Row[]; deploying?: Row[] };
|
|
107
|
+
return toolResult({
|
|
108
|
+
environments: pickFields(environments.rows, ENVIRONMENT_FIELDS),
|
|
109
|
+
...(environments.more ? { moreEnvironments: true } : {}),
|
|
110
|
+
running: pickFields(now.current ?? [], DEPLOYMENT_FIELDS),
|
|
111
|
+
deploying: pickFields(now.deploying ?? [], DEPLOYMENT_FIELDS),
|
|
112
|
+
history: page(pickFields(history.data as Row[], DEPLOYMENT_FIELDS), history.nextCursor),
|
|
113
|
+
...('value' in sync
|
|
114
|
+
? { syncStatus: sync.value.data }
|
|
115
|
+
: { syncStatus: null, syncUnavailable: sync.unavailable }),
|
|
116
|
+
});
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
server.registerTool(
|
|
121
|
+
'taiga_get_delivery_metrics',
|
|
122
|
+
{
|
|
123
|
+
title: 'Get delivery metrics',
|
|
124
|
+
description:
|
|
125
|
+
"DORA's five software delivery metrics for one environment (the one marked production, as the page " +
|
|
126
|
+
'reports them), computed from its deployment history over the last 30, 60 or 90 days: deployment ' +
|
|
127
|
+
'frequency, lead time and failed deployment recovery time (throughput), change-failure rate and rework ' +
|
|
128
|
+
'rate (instability), each with its trend against the window before, and a performance band for all but ' +
|
|
129
|
+
'rework rate, which DORA has published none for. Failures and recoveries are read from deployments, not ' +
|
|
130
|
+
'incidents. A trend built on fewer than ten deployments is approximate; a metric no deployment fed says ' +
|
|
131
|
+
'so instead of showing a number. Previews and checks do not count.',
|
|
132
|
+
inputSchema: { productId, environmentId, windowDays: windowDays('30, 60 or 90') },
|
|
133
|
+
annotations: READ_ONLY,
|
|
134
|
+
},
|
|
135
|
+
(params) =>
|
|
136
|
+
runTool(async () => {
|
|
137
|
+
const days = params.windowDays ?? 30;
|
|
138
|
+
if (![30, 60, 90].includes(days)) {
|
|
139
|
+
return toolError('VALIDATION_FAILED', 'windowDays is 30, 60 or 90.');
|
|
140
|
+
}
|
|
141
|
+
return toolResult((await client.getDeliveryMetrics(params.productId, params.environmentId, days)).data);
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
server.registerTool(
|
|
146
|
+
'taiga_get_monitoring',
|
|
147
|
+
{
|
|
148
|
+
title: "Get a product's monitoring",
|
|
149
|
+
description:
|
|
150
|
+
"How a product's environments are doing: each environment's current health from Taiga's availability " +
|
|
151
|
+
'checks of its App URL, with its uptime and when it was last checked; a status Taiga cannot verify now ' +
|
|
152
|
+
'reads as not checked rather than the last thing it knew, and a new environment as baseline learning. ' +
|
|
153
|
+
"With environmentId, that environment's uptime over a 30, 60 or 90-day window, and the experience " +
|
|
154
|
+
'signals the beacon reports (Core Web Vitals, JavaScript errors, client-observed API health) where it is ' +
|
|
155
|
+
'installed; until then they read as not set up, which means no data, not good data. Setting up the ' +
|
|
156
|
+
"beacon raises an initiative from the environment's settings in the web.",
|
|
157
|
+
inputSchema: { productId, environmentId: environmentId.optional(), windowDays: windowDays('30, 60 or 90') },
|
|
158
|
+
annotations: READ_ONLY,
|
|
159
|
+
},
|
|
160
|
+
(params) =>
|
|
161
|
+
runTool(async () => {
|
|
162
|
+
const health = await client.getMonitoringHealth(params.productId);
|
|
163
|
+
if (!params.environmentId) return toolResult({ health: health.data });
|
|
164
|
+
const days = params.windowDays ?? 30;
|
|
165
|
+
if (![30, 60, 90].includes(days)) {
|
|
166
|
+
return toolError('VALIDATION_FAILED', 'windowDays is 30, 60 or 90, the windows the page offers.');
|
|
167
|
+
}
|
|
168
|
+
const env = params.environmentId;
|
|
169
|
+
const [uptime, experience, clientHealth] = await Promise.all([
|
|
170
|
+
part(() => client.getMonitoringSignal(params.productId, 'uptime', env, days)),
|
|
171
|
+
part(() => client.getMonitoringSignal(params.productId, 'rum', env, days)),
|
|
172
|
+
part(() => client.getMonitoringSignal(params.productId, 'client-health', env, days)),
|
|
173
|
+
]);
|
|
174
|
+
const value = (p: { value: { data: unknown } } | { unavailable: string }) =>
|
|
175
|
+
'value' in p ? p.value.data : { unavailable: p.unavailable };
|
|
176
|
+
return toolResult({
|
|
177
|
+
health: (health.data as Row[]).filter((row) => row.environmentId === env),
|
|
178
|
+
windowDays: days,
|
|
179
|
+
uptime: value(uptime),
|
|
180
|
+
realUserExperience: value(experience),
|
|
181
|
+
clientHealth: value(clientHealth),
|
|
182
|
+
});
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (!client.hasCapability('products:write')) return;
|
|
187
|
+
|
|
188
|
+
server.registerTool(
|
|
189
|
+
'taiga_define_environment',
|
|
190
|
+
{
|
|
191
|
+
title: 'Define an environment',
|
|
192
|
+
description:
|
|
193
|
+
"Add a place a product's software runs: development, staging, production or whatever ladder it uses. " +
|
|
194
|
+
'Deployments are recorded against an environment, monitoring is reported per environment, and plans ' +
|
|
195
|
+
'read where the work will run. Describing one gives Taiga no access to it: the pipeline deploys, Taiga ' +
|
|
196
|
+
'learns what happened from source control. Member-level work. Give it its App URL afterwards with ' +
|
|
197
|
+
'taiga_update_environment, which is all monitoring needs to start checking. Changes the ' +
|
|
198
|
+
"product's setup for everybody; ask the person first.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
productId,
|
|
201
|
+
name: z.string().trim().min(1).max(100).describe('What the team calls it, e.g. staging'),
|
|
202
|
+
type: z.enum(ENVIRONMENT_TYPES).describe('Its kind; production is the one delivery metrics report on'),
|
|
203
|
+
},
|
|
204
|
+
annotations: OPERATE,
|
|
205
|
+
},
|
|
206
|
+
(params) =>
|
|
207
|
+
runTool(async () => {
|
|
208
|
+
const created = (await client.createEnvironment(params.productId, { name: params.name, type: params.type }))
|
|
209
|
+
.data as Row;
|
|
210
|
+
return toolResult(pickFields([created], ENVIRONMENT_FIELDS)[0]);
|
|
211
|
+
})
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
server.registerTool(
|
|
215
|
+
'taiga_update_environment',
|
|
216
|
+
{
|
|
217
|
+
title: 'Update an environment',
|
|
218
|
+
description:
|
|
219
|
+
'Rename an environment, change its kind, or set its public App URL (null clears it). The App URL is all ' +
|
|
220
|
+
"monitoring needs: once it is saved, Taiga checks the app's availability every few minutes on its own. " +
|
|
221
|
+
'Marking an environment production makes it the one delivery metrics report on. Changes the ' +
|
|
222
|
+
"product's setup for everybody; ask the person first.",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
environmentId,
|
|
225
|
+
name: z.string().trim().min(1).max(100).optional().describe('A new name'),
|
|
226
|
+
type: z.enum(ENVIRONMENT_TYPES).optional().describe('A new kind'),
|
|
227
|
+
url: z.string().trim().max(500).url().nullable().optional().describe('The public App URL; null clears it'),
|
|
228
|
+
},
|
|
229
|
+
annotations: OPERATE_SETTING,
|
|
230
|
+
},
|
|
231
|
+
(params) =>
|
|
232
|
+
runTool(async () => {
|
|
233
|
+
const { environmentId: id, ...changes } = params;
|
|
234
|
+
if (Object.values(changes).every((value) => value === undefined)) {
|
|
235
|
+
return toolError('VALIDATION_FAILED', 'Change at least one of name, type or url.');
|
|
236
|
+
}
|
|
237
|
+
const updated = (await client.updateEnvironment(id, changes)).data as Row;
|
|
238
|
+
return toolResult(pickFields([updated], ENVIRONMENT_FIELDS)[0]);
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
}
|