@taiga-ai/mcp-server 0.18.0-rc.445 → 0.19.0-dev.451
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 +1128 -650
- package/package.json +1 -1
- package/src/client.test.ts +2 -2
- package/src/client.ts +201 -145
- package/src/modern.integration.test.ts +1 -1
- package/src/server.integration.test.ts +782 -243
- package/src/tools/autonomy.ts +3 -5
- package/src/tools/backlogs.ts +182 -72
- package/src/tools/builds.ts +142 -53
- package/src/tools/catalogue.test.ts +28 -22
- package/src/tools/context.ts +126 -56
- package/src/tools/documents.ts +12 -5
- package/src/tools/errors.ts +40 -2
- package/src/tools/hints.ts +37 -9
- package/src/tools/inbox.ts +149 -28
- package/src/tools/index.ts +15 -1
- package/src/tools/instructions.ts +32 -154
- package/src/tools/jobs.ts +6 -5
- package/src/tools/knowledge.ts +4 -6
- package/src/tools/maintenance.ts +53 -32
- package/src/tools/markdown.ts +13 -5
- package/src/tools/observe.ts +32 -14
- package/src/tools/product-documents.ts +4 -6
- package/src/tools/products.ts +32 -27
- package/src/tools/prompts.ts +14 -20
- package/src/tools/queue.ts +47 -26
- package/src/tools/readiness.ts +172 -0
- package/src/tools/repositories.ts +11 -9
- package/src/tools/resources.ts +16 -19
- package/src/tools/sections.test.ts +3 -3
- package/src/tools/sections.ts +57 -16
- package/src/tools/setup.ts +47 -51
- package/src/tools/summarize.test.ts +20 -0
- package/src/tools/summarize.ts +17 -13
- package/src/tools/wait.ts +39 -26
- package/taiga-ai-mcp-server-0.19.0-dev.451.tgz +0 -0
- package/tsconfig.json +1 -5
- package/taiga-ai-mcp-server-0.18.0-rc.445.tgz +0 -0
package/src/tools/maintenance.ts
CHANGED
|
@@ -20,8 +20,6 @@ import { page, pickFields } from './summarize.js';
|
|
|
20
20
|
* next sweep no longer detects it, so there is no tool for either.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
type Row = Record<string, unknown>;
|
|
24
|
-
|
|
25
23
|
/** A finding as a list row: enough to decide whether to open, fix or ignore it. */
|
|
26
24
|
const FINDING_ROW_FIELDS = [
|
|
27
25
|
'id',
|
|
@@ -75,6 +73,31 @@ const VIOLATION_FIELDS = [
|
|
|
75
73
|
'assessedAt',
|
|
76
74
|
] as const;
|
|
77
75
|
|
|
76
|
+
type Row = Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How bad a product's policy standing is, as counts: the controls something is
|
|
80
|
+
* recorded against, the open violations by severity, the resolved ones. The
|
|
81
|
+
* API's standing carries the lists only; this tool and the product context
|
|
82
|
+
* both count them here.
|
|
83
|
+
*/
|
|
84
|
+
export function standingSummary(standing: {
|
|
85
|
+
controls?: unknown;
|
|
86
|
+
divergences?: unknown;
|
|
87
|
+
resolvedDivergences?: unknown;
|
|
88
|
+
}): { controls: number; violations: number; bySeverity: Record<string, number>; resolvedViolations: number } {
|
|
89
|
+
const controls = Array.isArray(standing.controls) ? (standing.controls as Row[]) : [];
|
|
90
|
+
const violations = Array.isArray(standing.divergences) ? (standing.divergences as Row[]) : [];
|
|
91
|
+
const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
|
|
92
|
+
const listed = controls.filter((c) => Number(c['divergenceCount'] ?? 0) > 0 || Number(c['findingCount'] ?? 0) > 0);
|
|
93
|
+
const bySeverity: Record<string, number> = {};
|
|
94
|
+
for (const violation of violations) {
|
|
95
|
+
const severity = String(violation['severity'] ?? 'unknown');
|
|
96
|
+
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
|
|
97
|
+
}
|
|
98
|
+
return { controls: listed.length, violations: violations.length, bySeverity, resolvedViolations: resolved };
|
|
99
|
+
}
|
|
100
|
+
|
|
78
101
|
const findingId = idInput('finding', 'taiga_list_findings gives it');
|
|
79
102
|
|
|
80
103
|
export function registerMaintenanceTools(server: McpServer, client: TaigaClient) {
|
|
@@ -88,8 +111,9 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
88
111
|
"Where a product's repository stands: open findings by severity and kind, the most pressing ones, and " +
|
|
89
112
|
'the latest sweeps, so you can tell when the product was last checked and whether that check succeeded. A ' +
|
|
90
113
|
'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.
|
|
92
|
-
'
|
|
114
|
+
'says so. Sweeps run on their own after a push and periodically; nothing here starts one. This is the ' +
|
|
115
|
+
'overview of the scanners alone; taiga_list_findings lists each finding to act on, and ' +
|
|
116
|
+
'taiga_get_policy_standing judges the product against the policies (scanner findings and the documents).',
|
|
93
117
|
inputSchema: z.object({ productId }),
|
|
94
118
|
annotations: READ_ONLY,
|
|
95
119
|
},
|
|
@@ -99,11 +123,11 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
99
123
|
client.getMaintenanceSummary(params.productId),
|
|
100
124
|
client.listMaintenanceSweeps(params.productId, { limit: 3 }),
|
|
101
125
|
]);
|
|
102
|
-
const latest = pickFields(sweeps.data
|
|
126
|
+
const latest = pickFields(sweeps.data, SWEEP_FIELDS);
|
|
103
127
|
// The sweeps are reported as `latestSweeps`, narrowed to SWEEP_FIELDS;
|
|
104
128
|
// the summary's own run rows (PRO-2527: one per repository) would
|
|
105
129
|
// repeat them with every column.
|
|
106
|
-
const { lastRun: _lastRun, lastRuns: _lastRuns, ...standing } = summary.data
|
|
130
|
+
const { lastRun: _lastRun, lastRuns: _lastRuns, ...standing } = summary.data;
|
|
107
131
|
return toolResult({
|
|
108
132
|
...standing,
|
|
109
133
|
latestSweeps: latest,
|
|
@@ -122,8 +146,9 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
122
146
|
'severity and its priority tier (act_now: known exploited; urgent: likely and serious; elevated: one of ' +
|
|
123
147
|
'the two; routine), where it is, who can fix it (remediableBy: coding_agent, or upstream_only for a ' +
|
|
124
148
|
'submodule or a dependency with no fixed version), the policy controls it breaches, and the initiative ' +
|
|
125
|
-
'already fixing it (
|
|
126
|
-
'down. Read one in full with taiga_get_finding.'
|
|
149
|
+
'already fixing it (initiativeId). Omit status for the findings that need action; work from the top tier ' +
|
|
150
|
+
'down. Read one in full with taiga_get_finding. taiga_get_maintenance has the counts and when the last ' +
|
|
151
|
+
'sweep ran, which says whether an empty list can be trusted.',
|
|
127
152
|
inputSchema: z.object({
|
|
128
153
|
productId,
|
|
129
154
|
status: z
|
|
@@ -141,7 +166,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
141
166
|
const { productId: id, ...filters } = params;
|
|
142
167
|
const result = await client.listFindings(id, filters);
|
|
143
168
|
return toolResult({
|
|
144
|
-
...page(pickFields(result.data
|
|
169
|
+
...page(pickFields(result.data, FINDING_ROW_FIELDS), result.nextCursor),
|
|
145
170
|
...(typeof result.totalCount === 'number' ? { totalCount: result.totalCount } : {}),
|
|
146
171
|
});
|
|
147
172
|
})
|
|
@@ -159,7 +184,13 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
159
184
|
inputSchema: z.object({ findingId }),
|
|
160
185
|
annotations: READ_ONLY,
|
|
161
186
|
},
|
|
162
|
-
(params) =>
|
|
187
|
+
(params) =>
|
|
188
|
+
runTool(async () => {
|
|
189
|
+
// The finding's runId is the sweep that found it, not a coding run:
|
|
190
|
+
// in the product's words runId is what taiga_get_run takes (PRO-2800).
|
|
191
|
+
const { runId, ...finding } = (await client.getFinding(params.findingId)).data as Row;
|
|
192
|
+
return toolResult(runId === undefined ? finding : { sweepId: runId, ...finding });
|
|
193
|
+
})
|
|
163
194
|
);
|
|
164
195
|
|
|
165
196
|
server.registerTool(
|
|
@@ -171,7 +202,11 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
171
202
|
"each with its violations (critical and high only, each citing the policy's sentence and the document " +
|
|
172
203
|
'passage that contradicts it), what is being done about each (an initiative raised, a fix in progress, ' +
|
|
173
204
|
'awaiting verification), and when the documents were derived and assessed, led by a summary of the ' +
|
|
174
|
-
'counts. A control breached
|
|
205
|
+
'counts. A control is breached when open scanner findings map to it, diverged when the assessment of the ' +
|
|
206
|
+
'product documents found them contradicting it (a control can be both; state names the stronger, ' +
|
|
207
|
+
"breached). A violation's work is the initiative raised for it; live: false means that initiative is " +
|
|
208
|
+
'done or canceled without the violation going away, so it needs a person or a new fix. A control ' +
|
|
209
|
+
'breached by scanner findings counts them by tier (findingsByTier): a breach of ' +
|
|
175
210
|
'routine findings only, such as secrets in test fixtures, is not an urgent one. A control with no violation ' +
|
|
176
211
|
'is not listed, and it is not "passing" either: there is no such state. Pass full: true for every ' +
|
|
177
212
|
"violation's evidence and the resolved ones. Fix a control with taiga_fix_policy_violation.",
|
|
@@ -186,24 +221,13 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
186
221
|
},
|
|
187
222
|
(params) =>
|
|
188
223
|
runTool(async () => {
|
|
189
|
-
const standing = (await client.getPolicyStanding(params.productId)).data
|
|
190
|
-
const controls = Array.isArray(standing.controls) ?
|
|
191
|
-
const violations = Array.isArray(standing.divergences) ?
|
|
192
|
-
const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
|
|
224
|
+
const standing = (await client.getPolicyStanding(params.productId)).data;
|
|
225
|
+
const controls = Array.isArray(standing.controls) ? standing.controls : [];
|
|
226
|
+
const violations = Array.isArray(standing.divergences) ? standing.divergences : [];
|
|
193
227
|
const listed = controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0);
|
|
194
|
-
const bySeverity: Record<string, number> = {};
|
|
195
|
-
for (const violation of violations) {
|
|
196
|
-
const severity = String(violation.severity ?? 'unknown');
|
|
197
|
-
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
|
|
198
|
-
}
|
|
199
228
|
// The counts first, on both answers: the lists run to thousands of
|
|
200
229
|
// tokens, and "how bad is it" is the first question.
|
|
201
|
-
const summary =
|
|
202
|
-
controls: listed.length,
|
|
203
|
-
violations: violations.length,
|
|
204
|
-
bySeverity,
|
|
205
|
-
resolvedViolations: resolved,
|
|
206
|
-
};
|
|
230
|
+
const summary = standingSummary(standing);
|
|
207
231
|
if (params.full) return toolResult({ summary, ...standing });
|
|
208
232
|
return toolResult({
|
|
209
233
|
summary,
|
|
@@ -268,7 +292,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
268
292
|
...(params.expiresAt !== undefined ? { ignoreExpiresAt: params.expiresAt } : {}),
|
|
269
293
|
}
|
|
270
294
|
: { status: 'open' as const };
|
|
271
|
-
const finding = (await client.updateFinding(params.findingId, body)).data
|
|
295
|
+
const finding = (await client.updateFinding(params.findingId, body)).data;
|
|
272
296
|
return toolResult(pickFields([finding], [...FINDING_ROW_FIELDS, 'ignoreNote'])[0]);
|
|
273
297
|
})
|
|
274
298
|
);
|
|
@@ -299,10 +323,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
299
323
|
(params) =>
|
|
300
324
|
runTool(async () => {
|
|
301
325
|
if (!params.wholeGroup) {
|
|
302
|
-
const fixed = (await client.remediateFinding(params.productId, params.findingId)).data
|
|
303
|
-
finding?: Row;
|
|
304
|
-
backlogItemId?: unknown;
|
|
305
|
-
};
|
|
326
|
+
const fixed = (await client.remediateFinding(params.productId, params.findingId)).data;
|
|
306
327
|
return toolResult({
|
|
307
328
|
backlogItemId: fixed.backlogItemId,
|
|
308
329
|
finding: fixed.finding ? pickFields([fixed.finding], FINDING_ROW_FIELDS)[0] : null,
|
|
@@ -311,7 +332,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
311
332
|
// The group is the finding's own: the route matches the package (or
|
|
312
333
|
// rule), the installed version and the repository exactly, so they are
|
|
313
334
|
// taken from the finding rather than asked of the caller.
|
|
314
|
-
const finding = (await client.getFinding(params.findingId)).data
|
|
335
|
+
const finding = (await client.getFinding(params.findingId)).data;
|
|
315
336
|
// The finding is read by its own id, so it is held to the product the
|
|
316
337
|
// group fix is sent to, as the single-finding route holds it.
|
|
317
338
|
if (finding.productId !== params.productId) {
|
package/src/tools/markdown.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
2
2
|
import { toMarkdown } from 'mdast-util-to-markdown';
|
|
3
3
|
import type { Heading, Node, Parent, PhrasingContent } from 'mdast';
|
|
4
|
-
import { isRecord, sectionContent, sectionsOf } from './sections.js';
|
|
4
|
+
import { isRecord, isSectionObject, sectionContent, sectionsOf, type StoredDocument } from './sections.js';
|
|
5
|
+
|
|
6
|
+
/** A document as rendered: its data, and the facts its title and header line name. */
|
|
7
|
+
interface RenderedDocument extends StoredDocument {
|
|
8
|
+
name?: unknown;
|
|
9
|
+
type?: unknown;
|
|
10
|
+
}
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* A document as text an IDE can attach: the name as the title, one line of
|
|
@@ -11,7 +17,7 @@ import { isRecord, sectionContent, sectionsOf } from './sections.js';
|
|
|
11
17
|
* described, and a document that is no section map at all is handed over as
|
|
12
18
|
* JSON.
|
|
13
19
|
*/
|
|
14
|
-
export function renderDocument(doc:
|
|
20
|
+
export function renderDocument(doc: RenderedDocument): { mimeType: string; text: string } {
|
|
15
21
|
const data = isRecord(doc.data) ? doc.data : null;
|
|
16
22
|
const sections = data ? sectionsOf(data) : null;
|
|
17
23
|
if (!data || !sections) return { mimeType: 'application/json', text: JSON.stringify(doc, null, 2) };
|
|
@@ -25,7 +31,7 @@ export function renderDocument(doc: Record<string, unknown>): { mimeType: string
|
|
|
25
31
|
// the headings: a heading is text a section could forge, this line is not.
|
|
26
32
|
const core = Object.keys(sections).filter((id) => {
|
|
27
33
|
const section = sections[id];
|
|
28
|
-
return
|
|
34
|
+
return isSectionObject(section) && section.priority === 'core';
|
|
29
35
|
});
|
|
30
36
|
if (core.length > 0) facts.push(`core sections: ${core.join(', ')}`);
|
|
31
37
|
if (facts.length > 0) lines.push('', facts.join(' | '));
|
|
@@ -120,11 +126,13 @@ function atxOf(depth: Heading['depth'], children: PhrasingContent[]): string {
|
|
|
120
126
|
|
|
121
127
|
/** The section's title when it carries one, else its id. */
|
|
122
128
|
function sectionTitle(section: unknown, id: string): string {
|
|
123
|
-
return
|
|
129
|
+
return isSectionObject(section) && typeof section.title === 'string' && section.title.trim()
|
|
130
|
+
? section.title.trim()
|
|
131
|
+
: id;
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
/** The mime type a read will answer with, for a list entry that must not disagree with it. */
|
|
127
|
-
export function mimeTypeOf(doc:
|
|
135
|
+
export function mimeTypeOf(doc: StoredDocument): string {
|
|
128
136
|
const data = isRecord(doc.data) ? doc.data : null;
|
|
129
137
|
return data && sectionsOf(data) ? 'text/markdown' : 'application/json';
|
|
130
138
|
}
|
package/src/tools/observe.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { ENVIRONMENT_TYPES } from '@taiga/types';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import {
|
|
4
|
+
import type { Deployment, Environment } from '@taiga/types';
|
|
5
|
+
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
5
6
|
import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
6
7
|
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
7
8
|
import { withNotFoundHint } from './hints.js';
|
|
@@ -16,8 +17,6 @@ import { page, pickFields } from './summarize.js';
|
|
|
16
17
|
* provider and is an administrator's action in the web.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
|
-
type Row = Record<string, unknown>;
|
|
20
|
-
|
|
21
20
|
/** A deployment as a line of history: what shipped where, how it ended, and who shipped it. */
|
|
22
21
|
const DEPLOYMENT_FIELDS = [
|
|
23
22
|
'id',
|
|
@@ -39,6 +38,22 @@ const DEPLOYMENT_FIELDS = [
|
|
|
39
38
|
|
|
40
39
|
const ENVIRONMENT_FIELDS = ['id', 'name', 'slug', 'type', 'ordinal', 'status', 'isProtected', 'url'] as const;
|
|
41
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Deployments as lines, each naming the repository that deployed it: every
|
|
43
|
+
* repository runs its own deployment in an environment, so two running in
|
|
44
|
+
* production are two repositories, not a contradiction (PRO-2528).
|
|
45
|
+
*/
|
|
46
|
+
function deploymentLines(rows: WithoutTenant<Deployment>[]) {
|
|
47
|
+
return rows.map((row) => {
|
|
48
|
+
// `metadata` is an untyped jsonb column until PRO-2515 types it.
|
|
49
|
+
const repository = row.metadata?.['repositoryFullName'];
|
|
50
|
+
return {
|
|
51
|
+
...pickFields([row], DEPLOYMENT_FIELDS)[0],
|
|
52
|
+
repository: typeof repository === 'string' ? repository : null,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
const environmentId = idInput('environment', 'taiga_list_environments lists them; a product ID is not one');
|
|
43
58
|
/** The windows the pages offer, as the schema's own values rather than a number checked afterwards. */
|
|
44
59
|
const windowDays = z.literal([30, 60, 90]).optional().describe('Days to look back: 30, 60 or 90 (default 30)');
|
|
@@ -62,12 +77,15 @@ async function part<T>(read: () => Promise<T>): Promise<{ value: T } | { unavail
|
|
|
62
77
|
const ENVIRONMENT_PAGES = 5;
|
|
63
78
|
|
|
64
79
|
/** Every environment of a product, page by page, and whether any were left past the guard. */
|
|
65
|
-
async function allEnvironments(
|
|
66
|
-
|
|
80
|
+
async function allEnvironments(
|
|
81
|
+
client: TaigaClient,
|
|
82
|
+
product: string
|
|
83
|
+
): Promise<{ rows: WithoutTenant<Environment>[]; more: boolean }> {
|
|
84
|
+
const rows: WithoutTenant<Environment>[] = [];
|
|
67
85
|
let cursor: string | undefined;
|
|
68
86
|
for (let pageNo = 0; pageNo < ENVIRONMENT_PAGES; pageNo++) {
|
|
69
87
|
const result = await client.listEnvironments(product, { limit: 100, cursor });
|
|
70
|
-
rows.push(...
|
|
88
|
+
rows.push(...result.data);
|
|
71
89
|
if (!result.nextCursor) return { rows, more: false };
|
|
72
90
|
cursor = result.nextCursor;
|
|
73
91
|
}
|
|
@@ -104,23 +122,23 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
104
122
|
}),
|
|
105
123
|
part(() => client.getDeploymentSyncStatus(params.productId)),
|
|
106
124
|
]);
|
|
107
|
-
const now = current.data
|
|
125
|
+
const now = current.data;
|
|
108
126
|
// An empty answer says why, as the maintenance read does: no
|
|
109
127
|
// environment, or none deployed to yet, is not "nothing is wrong".
|
|
110
128
|
const note =
|
|
111
129
|
environments.rows.length === 0
|
|
112
130
|
? 'This product has no environments yet: an environment is defined with taiga_define_environment, ' +
|
|
113
131
|
'and its deployments are mirrored from the provider once it is.'
|
|
114
|
-
:
|
|
132
|
+
: history.data.length === 0 && !params.cursor
|
|
115
133
|
? 'No deployments recorded yet: none was mirrored from the provider (syncStatus says when it last ' +
|
|
116
134
|
'synced), or nothing has been deployed.'
|
|
117
135
|
: null;
|
|
118
136
|
return toolResult({
|
|
119
137
|
environments: pickFields(environments.rows, ENVIRONMENT_FIELDS),
|
|
120
138
|
...(environments.more ? { moreEnvironments: true } : {}),
|
|
121
|
-
running:
|
|
122
|
-
deploying:
|
|
123
|
-
history: page(
|
|
139
|
+
running: deploymentLines(now.current ?? []),
|
|
140
|
+
deploying: deploymentLines(now.deploying ?? []),
|
|
141
|
+
history: page(deploymentLines(history.data), history.nextCursor),
|
|
124
142
|
...('value' in sync
|
|
125
143
|
? { syncStatus: sync.value.data }
|
|
126
144
|
: { syncStatus: null, syncUnavailable: sync.unavailable }),
|
|
@@ -226,7 +244,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
226
244
|
}
|
|
227
245
|
const value = (p: { value: { data: unknown } } | { unavailable: string }) =>
|
|
228
246
|
'value' in p ? p.value.data : { unavailable: p.unavailable };
|
|
229
|
-
const envHealth =
|
|
247
|
+
const envHealth = health.data.filter((row) => row.environmentId === env);
|
|
230
248
|
return toolResult({
|
|
231
249
|
health: envHealth,
|
|
232
250
|
...(envHealth.length === 0
|
|
@@ -267,7 +285,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
267
285
|
(params) =>
|
|
268
286
|
runTool(async () => {
|
|
269
287
|
const created = (await client.createEnvironment(params.productId, { name: params.name, type: params.type }))
|
|
270
|
-
.data
|
|
288
|
+
.data;
|
|
271
289
|
return toolResult(pickFields([created], ENVIRONMENT_FIELDS)[0]);
|
|
272
290
|
})
|
|
273
291
|
);
|
|
@@ -295,7 +313,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
295
313
|
if (Object.values(changes).every((value) => value === undefined)) {
|
|
296
314
|
return toolError('VALIDATION_ERROR', 'Change at least one of name, type or url.');
|
|
297
315
|
}
|
|
298
|
-
const updated = (await client.updateEnvironment(id, changes)).data
|
|
316
|
+
const updated = (await client.updateEnvironment(id, changes)).data;
|
|
299
317
|
return toolResult(pickFields([updated], ENVIRONMENT_FIELDS)[0]);
|
|
300
318
|
})
|
|
301
319
|
);
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
SETUP_REQUIRED_DOCUMENT_TYPES,
|
|
9
9
|
} from '@taiga/types';
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
-
import type { TaigaClient
|
|
11
|
+
import type { TaigaClient } from '../client.js';
|
|
12
12
|
import { OPERATE, WRITE } from './annotations.js';
|
|
13
13
|
import { type DocumentScope, publishDraft, sectionContentInput, sectionIdFrom, updateSection } from './documents.js';
|
|
14
14
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
@@ -25,8 +25,6 @@ import { jobReport } from './jobs.js';
|
|
|
25
25
|
* code and is not edited.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
type Row = Record<string, unknown>;
|
|
29
|
-
|
|
30
28
|
/** The plan-side documents people edit; the codebase snapshot is derived from the code, never written. */
|
|
31
29
|
type EditableType = Exclude<ProductDocumentType, 'codebase_snapshot'>;
|
|
32
30
|
const EDITABLE_TYPES = PRODUCT_DOCUMENT_TYPES.filter((type): type is EditableType => type !== 'codebase_snapshot') as [
|
|
@@ -56,7 +54,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
|
|
|
56
54
|
|
|
57
55
|
const productScope = (product: string, type: string): DocumentScope => ({
|
|
58
56
|
getDraft: () => client.getProductDocumentDraft(product, type),
|
|
59
|
-
getPublished:
|
|
57
|
+
getPublished: () => client.getProductDocument(product, type, 'plan'),
|
|
60
58
|
saveDraft: (data) => client.saveProductDocumentDraft(product, type, { data }),
|
|
61
59
|
publish: () => client.publishProductDocument(product, type),
|
|
62
60
|
});
|
|
@@ -95,7 +93,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
|
|
|
95
93
|
"Publish a product document's draft as its new version, the one agents work from. Documents generated " +
|
|
96
94
|
'from it are not rewritten: each is marked outdated, and is regenerated when someone chooses to ' +
|
|
97
95
|
'(taiga_regenerate_product_document, or taiga_generate_product_documents for every outdated and missing ' +
|
|
98
|
-
'one).
|
|
96
|
+
'one).',
|
|
99
97
|
inputSchema: z.object({ productId, type: documentType }),
|
|
100
98
|
annotations: OPERATE,
|
|
101
99
|
},
|
|
@@ -155,7 +153,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
|
|
|
155
153
|
targetId: params.productId,
|
|
156
154
|
documentType: params.type,
|
|
157
155
|
})
|
|
158
|
-
).data
|
|
156
|
+
).data;
|
|
159
157
|
return toolResult(jobReport(job));
|
|
160
158
|
})
|
|
161
159
|
);
|
package/src/tools/products.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import type { Product, ProductDocumentListItem } from '@taiga/types';
|
|
4
|
+
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
4
5
|
import { READ_ONLY } from './annotations.js';
|
|
5
6
|
import { idInput, pagination, productId, remoteUrl, slug } from './inputs.js';
|
|
6
7
|
import {
|
|
@@ -14,14 +15,17 @@ import {
|
|
|
14
15
|
isImportedProduct,
|
|
15
16
|
productDocumentName,
|
|
16
17
|
type ProductDocumentOrigin,
|
|
17
|
-
setupNextStep,
|
|
18
18
|
type ProductSettingsView,
|
|
19
19
|
} from './catalogue.js';
|
|
20
20
|
import { apiFailureCode, apiFailureText, isMissOrRefusal, runTool, toolError, toolResult } from './errors.js';
|
|
21
21
|
import { parseRemote } from './remote.js';
|
|
22
22
|
import { sectionReadResult, sectionsInput, stripProvenance } from './sections.js';
|
|
23
|
+
import { setupReadiness } from './readiness.js';
|
|
23
24
|
import { SEARCH_NOTE, SEARCH_PAGE_SIZE, SEARCH_WITH_CURSOR, page, pickFields, searchPages } from './summarize.js';
|
|
24
25
|
|
|
26
|
+
type ProductRow = WithoutTenant<Product>;
|
|
27
|
+
type DocumentRow = WithoutTenant<ProductDocumentListItem>;
|
|
28
|
+
|
|
25
29
|
/** What a product row needs to say for the model to pick one and read it in full. */
|
|
26
30
|
// Not `kind`: it is parked behind a flag that is on nowhere, so every row said null.
|
|
27
31
|
export const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'discoveryStep'] as const;
|
|
@@ -72,11 +76,11 @@ const originInput = z
|
|
|
72
76
|
* than being folded into a truncated list that would read as a complete one.
|
|
73
77
|
*/
|
|
74
78
|
async function linkedProducts(client: TaigaClient, repository: string, repositoryHost: string) {
|
|
75
|
-
const products:
|
|
79
|
+
const products: ProductRow[] = [];
|
|
76
80
|
let cursor: string | undefined;
|
|
77
81
|
for (let page = 0; page < RESOLVE_PAGE_LIMIT; page += 1) {
|
|
78
82
|
const result = await client.listProducts({ repository, repositoryHost, limit: PAGE_SIZE, cursor });
|
|
79
|
-
products.push(...
|
|
83
|
+
products.push(...result.data);
|
|
80
84
|
if (!result.nextCursor) return { products, truncated: false };
|
|
81
85
|
cursor = result.nextCursor;
|
|
82
86
|
}
|
|
@@ -84,15 +88,14 @@ async function linkedProducts(client: TaigaClient, repository: string, repositor
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
/** A document list row as the model sees it: the summary fields plus a display name and the staleness fact. */
|
|
87
|
-
function documentSummaries(rows:
|
|
88
|
-
|
|
89
|
-
return pickFields(typed, DOCUMENT_SUMMARY_FIELDS).map((picked, index) => {
|
|
91
|
+
function documentSummaries(rows: DocumentRow[]) {
|
|
92
|
+
return pickFields(rows, DOCUMENT_SUMMARY_FIELDS).map((picked, index) => {
|
|
90
93
|
// `outdated` on the wire is the list of sources that moved on; the model
|
|
91
94
|
// needs the fact, not the tree. Published plan rows carry it: null when
|
|
92
95
|
// current, a source list when not. A draft has nothing published to be
|
|
93
96
|
// outdated (the API stamps it null too) and reality rows carry no such
|
|
94
97
|
// field: no fact for either.
|
|
95
|
-
const outdated =
|
|
98
|
+
const outdated = rows[index]!.outdated;
|
|
96
99
|
const fact =
|
|
97
100
|
picked.isDraft !== false
|
|
98
101
|
? undefined
|
|
@@ -110,12 +113,14 @@ function documentSummaries(rows: unknown[]): Record<string, unknown>[] {
|
|
|
110
113
|
});
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
type DocumentSummary = ReturnType<typeof documentSummaries>[number];
|
|
117
|
+
|
|
113
118
|
interface Timelines {
|
|
114
119
|
projectType: 'greenfield' | 'brownfield';
|
|
115
120
|
defaultOrigin: ProductDocumentOrigin;
|
|
116
121
|
/** The side's summaries, or null when the API could not list it (see `unlistable`). */
|
|
117
|
-
plan:
|
|
118
|
-
reality:
|
|
122
|
+
plan: DocumentSummary[] | null;
|
|
123
|
+
reality: DocumentSummary[] | null;
|
|
119
124
|
/** Why a null side is null, in the words the notes and a miss message use. */
|
|
120
125
|
unlistable: Partial<Record<ProductDocumentOrigin, string>>;
|
|
121
126
|
notes: string[];
|
|
@@ -134,7 +139,7 @@ function unlistableNote(origin: ProductDocumentOrigin, reason: unknown): string
|
|
|
134
139
|
* so a caller that already holds it passes it in.
|
|
135
140
|
*/
|
|
136
141
|
async function documentTimelines(client: TaigaClient, id: string, product?: ProductSettingsView): Promise<Timelines> {
|
|
137
|
-
const loaded = product ?? (
|
|
142
|
+
const loaded = product ?? (await client.getProduct(id)).data;
|
|
138
143
|
// Each side on its own: one side the API cannot list is reported as
|
|
139
144
|
// unreadable, not as empty, and never hides the side that did answer.
|
|
140
145
|
// Both failing is the API failing, and that is an error like any other.
|
|
@@ -142,7 +147,7 @@ async function documentTimelines(client: TaigaClient, id: string, product?: Prod
|
|
|
142
147
|
PRODUCT_DOCUMENT_ORIGINS.map((o) => client.listProductDocuments(id, { origin: o, limit: PAGE_SIZE }))
|
|
143
148
|
);
|
|
144
149
|
if (settled.every((result) => result.status === 'rejected')) throw settled[0]!.reason;
|
|
145
|
-
const sides: Partial<Record<ProductDocumentOrigin,
|
|
150
|
+
const sides: Partial<Record<ProductDocumentOrigin, DocumentSummary[] | null>> = {};
|
|
146
151
|
const unlistable: Partial<Record<ProductDocumentOrigin, string>> = {};
|
|
147
152
|
PRODUCT_DOCUMENT_ORIGINS.forEach((o, index) => {
|
|
148
153
|
const result = settled[index]!;
|
|
@@ -167,10 +172,10 @@ async function documentTimelines(client: TaigaClient, id: string, product?: Prod
|
|
|
167
172
|
};
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
const isPublished = (row:
|
|
175
|
+
const isPublished = (row: { isDraft?: boolean }) => row.isDraft === false;
|
|
171
176
|
|
|
172
177
|
/** `specification (v2), architecture (draft)` for one side. */
|
|
173
|
-
function sideList(rows:
|
|
178
|
+
function sideList(rows: DocumentSummary[]): string {
|
|
174
179
|
return rows
|
|
175
180
|
.map((row) => `${String(row.type)} (${isPublished(row) ? `v${String(row.version)}` : 'draft'})`)
|
|
176
181
|
.join(', ');
|
|
@@ -218,7 +223,7 @@ export async function resolveProductRef(
|
|
|
218
223
|
accepted: readonly string[],
|
|
219
224
|
/** How the caller's arguments are passed, for the message when none was given (a slash command's usage). */
|
|
220
225
|
usage?: string
|
|
221
|
-
): Promise<{ product:
|
|
226
|
+
): Promise<{ product: ProductRow } | { refusal: { code: string; message: string } }> {
|
|
222
227
|
const refusal = (code: string, message: string) => ({ refusal: { code, message } });
|
|
223
228
|
const given = [ref.productId, ref.slug, ref.remoteUrl].filter((v) => v !== undefined).length;
|
|
224
229
|
if (given !== 1) {
|
|
@@ -228,7 +233,7 @@ export async function resolveProductRef(
|
|
|
228
233
|
}
|
|
229
234
|
if (ref.productId) {
|
|
230
235
|
try {
|
|
231
|
-
return { product: (await client.getProduct(ref.productId)).data
|
|
236
|
+
return { product: (await client.getProduct(ref.productId)).data };
|
|
232
237
|
} catch (error) {
|
|
233
238
|
if (!isMissOrRefusal(error)) throw error;
|
|
234
239
|
return refusal(
|
|
@@ -238,7 +243,7 @@ export async function resolveProductRef(
|
|
|
238
243
|
}
|
|
239
244
|
}
|
|
240
245
|
if (ref.slug) {
|
|
241
|
-
const [product] = (await client.listProducts({ slug: ref.slug, limit: 1 })).data
|
|
246
|
+
const [product] = (await client.listProducts({ slug: ref.slug, limit: 1 })).data;
|
|
242
247
|
if (!product) {
|
|
243
248
|
return refusal(
|
|
244
249
|
'NOT_FOUND',
|
|
@@ -341,9 +346,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
341
346
|
const { query: text, ...filters } = params;
|
|
342
347
|
if (text === undefined) {
|
|
343
348
|
const result = await client.listProducts(filters);
|
|
344
|
-
return toolResult(
|
|
345
|
-
page(pickFields(result.data as Record<string, unknown>[], PRODUCT_SUMMARY_FIELDS), result.nextCursor)
|
|
346
|
-
);
|
|
349
|
+
return toolResult(page(pickFields(result.data, PRODUCT_SUMMARY_FIELDS), result.nextCursor));
|
|
347
350
|
}
|
|
348
351
|
if (params.cursor !== undefined) return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
|
|
349
352
|
const found = await searchPages(
|
|
@@ -374,8 +377,10 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
374
377
|
'(application, infrastructure, deployment) and its settings; the list and resolve tools carry ' +
|
|
375
378
|
'summaries; taiga_get_product_context carries this summary with the rest a session starts from. Use ' +
|
|
376
379
|
'taiga_list_product_documents for its specification and design, ' +
|
|
377
|
-
'
|
|
378
|
-
'still in setup carries
|
|
380
|
+
'taiga_list_initiatives for its work and taiga_list_runs for what the coding agent did. A product ' +
|
|
381
|
+
'still in setup carries readiness: whether it can finish setup now, its repository and import, the ' +
|
|
382
|
+
'required documents not yet published, the checks not yet stamped, and nextStep, the one move that ' +
|
|
383
|
+
'gets it closer.',
|
|
379
384
|
inputSchema: z.object({
|
|
380
385
|
productId: productId.optional(),
|
|
381
386
|
slug: slug.optional(),
|
|
@@ -386,8 +391,8 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
386
391
|
runTool(async () => {
|
|
387
392
|
const resolved = await resolveProductRef(client, params, ['productId', 'slug']);
|
|
388
393
|
if ('refusal' in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
|
|
389
|
-
const
|
|
390
|
-
return toolResult(
|
|
394
|
+
const readiness = await setupReadiness(client, resolved.product);
|
|
395
|
+
return toolResult(readiness ? { ...resolved.product, readiness } : resolved.product);
|
|
391
396
|
})
|
|
392
397
|
);
|
|
393
398
|
|
|
@@ -483,11 +488,11 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
483
488
|
// The product's own timeline when none is asked for; the API's
|
|
484
489
|
// default is plan, which is the wrong side of an imported product.
|
|
485
490
|
let product: ProductSettingsView | undefined;
|
|
486
|
-
if (!params.origin) product = (await client.getProduct(params.productId)).data
|
|
491
|
+
if (!params.origin) product = (await client.getProduct(params.productId)).data;
|
|
487
492
|
const origin = params.origin ?? defaultOriginFor(product, params.type);
|
|
488
493
|
try {
|
|
489
494
|
const result = await client.getProductDocument(params.productId, params.type, origin);
|
|
490
|
-
const doc = stripProvenance(result.data
|
|
495
|
+
const doc = stripProvenance(result.data);
|
|
491
496
|
if (!params.sections) return toolResult(doc);
|
|
492
497
|
return sectionReadResult(doc, params.sections, `${origin} ${params.type}`);
|
|
493
498
|
} catch (error) {
|
|
@@ -496,7 +501,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
496
501
|
// miss, so the product read propagates. The lists after it are
|
|
497
502
|
// best effort per side (documentTimelines); only both failing
|
|
498
503
|
// propagates, as the API failing.
|
|
499
|
-
product ??= (await client.getProduct(params.productId)).data
|
|
504
|
+
product ??= (await client.getProduct(params.productId)).data;
|
|
500
505
|
const timelines = await documentTimelines(client, params.productId, product);
|
|
501
506
|
return toolError('NOT_FOUND', describeMiss(params.type, origin, timelines, product));
|
|
502
507
|
}
|