@taiga-ai/mcp-server 0.19.0-dev.451 → 0.20.0-dev.454
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 +479 -240
- package/package.json +1 -1
- package/src/server.integration.test.ts +345 -33
- package/src/tools/autonomy.ts +1 -1
- package/src/tools/backlogs.ts +14 -5
- package/src/tools/builds.ts +98 -11
- package/src/tools/catalogue.test.ts +1 -1
- package/src/tools/context.ts +45 -28
- package/src/tools/errors.ts +14 -2
- package/src/tools/inbox.ts +92 -11
- package/src/tools/index.ts +54 -4
- package/src/tools/instructions.ts +14 -9
- package/src/tools/knowledge.ts +1 -1
- package/src/tools/maintenance.ts +34 -9
- package/src/tools/observe.ts +3 -3
- package/src/tools/product-documents.ts +1 -1
- package/src/tools/products.ts +47 -5
- package/src/tools/queue.ts +7 -1
- package/src/tools/readiness.ts +11 -6
- package/src/tools/repositories.ts +2 -2
- package/src/tools/sections.ts +32 -2
- package/src/tools/setup.ts +2 -2
- package/src/tools/wait.ts +6 -4
- package/taiga-ai-mcp-server-0.20.0-dev.454.tgz +0 -0
- package/taiga-ai-mcp-server-0.19.0-dev.451.tgz +0 -0
package/src/tools/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ import { registerRepositoryTools } from './repositories.js';
|
|
|
19
19
|
import { registerResources } from './resources.js';
|
|
20
20
|
import { registerSetupTools } from './setup.js';
|
|
21
21
|
import { notFoundHint, notFoundHints } from './hints.js';
|
|
22
|
+
import { runTool, toolResult } from './errors.js';
|
|
23
|
+
import { READ_ONLY } from './annotations.js';
|
|
22
24
|
import { DEFAULT_WAIT_OPTIONS, registerWaitTools, type WaitOptions } from './wait.js';
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -30,13 +32,14 @@ import { DEFAULT_WAIT_OPTIONS, registerWaitTools, type WaitOptions } from './wai
|
|
|
30
32
|
* model learns the tool's real shape. Applied here, once, so no tool can be
|
|
31
33
|
* registered without it.
|
|
32
34
|
*/
|
|
33
|
-
function withStrictInputs(server: McpServer): McpServer {
|
|
35
|
+
function withStrictInputs(server: McpServer, registered: Map<string, ToolAnnotations | undefined>): McpServer {
|
|
34
36
|
const register = server.registerTool.bind(server) as (...args: unknown[]) => unknown;
|
|
35
37
|
server.registerTool = ((
|
|
36
38
|
name: string,
|
|
37
39
|
config: { inputSchema?: unknown; description?: string; annotations?: ToolAnnotations },
|
|
38
40
|
callback: unknown
|
|
39
41
|
) => {
|
|
42
|
+
registered.set(name, config.annotations);
|
|
40
43
|
const schema = config.inputSchema;
|
|
41
44
|
if (schema !== undefined && !(schema instanceof z.ZodObject)) {
|
|
42
45
|
throw new Error(`Tool ${name}: inputSchema must be a z.object() so unknown arguments can be refused`);
|
|
@@ -56,7 +59,7 @@ function withStrictInputs(server: McpServer): McpServer {
|
|
|
56
59
|
return server;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean };
|
|
62
|
+
type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean; openWorldHint?: boolean };
|
|
60
63
|
|
|
61
64
|
/**
|
|
62
65
|
* The entry points, loaded with their descriptions at session start
|
|
@@ -79,7 +82,7 @@ export function writeNotice(name: string, annotations: ToolAnnotations | undefin
|
|
|
79
82
|
throw new Error(`Tool ${name}: annotations must say whether it is read-only`);
|
|
80
83
|
}
|
|
81
84
|
if (annotations.readOnlyHint) return '';
|
|
82
|
-
return annotations.destructiveHint ? '
|
|
85
|
+
return annotations.destructiveHint ? 'Ask first: paid or shared work. ' : 'Writes. ';
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
/**
|
|
@@ -88,7 +91,8 @@ export function writeNotice(name: string, annotations: ToolAnnotations | undefin
|
|
|
88
91
|
* never sees tools it can't use.
|
|
89
92
|
*/
|
|
90
93
|
export function registerAllTools(server: McpServer, client: TaigaClient, options: { wait?: WaitOptions } = {}): void {
|
|
91
|
-
|
|
94
|
+
const registered = new Map<string, ToolAnnotations | undefined>();
|
|
95
|
+
withStrictInputs(server, registered);
|
|
92
96
|
registerProductTools(server, client);
|
|
93
97
|
registerDocumentTools(server, client);
|
|
94
98
|
registerBacklogTools(server, client);
|
|
@@ -105,6 +109,52 @@ export function registerAllTools(server: McpServer, client: TaigaClient, options
|
|
|
105
109
|
registerRepositoryTools(server, client);
|
|
106
110
|
registerWaitTools(server, client, options.wait ?? DEFAULT_WAIT_OPTIONS);
|
|
107
111
|
registerContextTools(server, client);
|
|
112
|
+
// Only beside other tools: a key with no capabilities gets no tools at all.
|
|
113
|
+
if (registered.size > 0) registerWhoami(server, client, registered);
|
|
108
114
|
registerResources(server, client);
|
|
109
115
|
registerPrompts(server, client);
|
|
110
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Who this key is and what it may do (PRO-2800): reviewing agents read tool
|
|
120
|
+
* descriptions saying "owners and admins link repositories" with no way to
|
|
121
|
+
* learn their own role until a write was refused. Registered last, so it can
|
|
122
|
+
* name every tool the key was given.
|
|
123
|
+
*/
|
|
124
|
+
function registerWhoami(
|
|
125
|
+
server: McpServer,
|
|
126
|
+
client: TaigaClient,
|
|
127
|
+
registered: Map<string, ToolAnnotations | undefined>
|
|
128
|
+
): void {
|
|
129
|
+
server.registerTool(
|
|
130
|
+
'taiga_whoami',
|
|
131
|
+
{
|
|
132
|
+
title: 'Who am I',
|
|
133
|
+
description:
|
|
134
|
+
"Who this API key acts as and what it may do: the person's email and organization role, the key's scopes " +
|
|
135
|
+
'(null means every capability the person has), its capabilities, and the tools it was given, split into ' +
|
|
136
|
+
"reads, writes, and the ask-first writes (paid agent work or changes others see). A tool the key's " +
|
|
137
|
+
'capabilities do not cover is not offered at all, rather than refused when called.',
|
|
138
|
+
inputSchema: z.object({}),
|
|
139
|
+
annotations: READ_ONLY,
|
|
140
|
+
},
|
|
141
|
+
() =>
|
|
142
|
+
runTool(async () => {
|
|
143
|
+
const me = client.getIntrospection();
|
|
144
|
+
const tools = [...registered.entries()].filter(([name]) => name !== 'taiga_whoami');
|
|
145
|
+
const named = (pick: (notes: ToolAnnotations | undefined) => boolean) =>
|
|
146
|
+
tools.filter(([, notes]) => pick(notes)).map(([name]) => name);
|
|
147
|
+
return toolResult({
|
|
148
|
+
email: me?.email ?? null,
|
|
149
|
+
role: me?.role ?? null,
|
|
150
|
+
scopes: me?.scopes ?? null,
|
|
151
|
+
capabilities: me?.capabilities ?? [],
|
|
152
|
+
tools: {
|
|
153
|
+
reads: named((notes) => notes?.readOnlyHint === true),
|
|
154
|
+
writes: named((notes) => notes?.readOnlyHint === false && notes.destructiveHint !== true),
|
|
155
|
+
askFirst: named((notes) => notes?.readOnlyHint === false && notes.destructiveHint === true),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -13,20 +13,25 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
13
13
|
"Taiga is a policy-aware software delivery platform: an organization's policies and knowledge, its factories",
|
|
14
14
|
"and products, each product's initiatives (its backlog), the line that plans and builds them one at a time, the",
|
|
15
15
|
"coding agent's runs and pull requests, maintenance findings, and Action Required, where work waits on a person.",
|
|
16
|
-
'
|
|
17
|
-
'or compliance, findings, deployments
|
|
16
|
+
'Use these tools (names start with taiga_) whenever the person asks about a Taiga product, an initiative, a run',
|
|
17
|
+
'or pull request, policy or compliance, findings, deployments, or setting a product up.',
|
|
18
18
|
'',
|
|
19
|
-
'**Start here.** taiga_get_product_context (
|
|
20
|
-
'call: what it still needs while in setup,
|
|
21
|
-
'recent runs with their outcome,
|
|
22
|
-
'taiga_list_products
|
|
19
|
+
'**Start here.** taiga_get_product_context (a product by git remote URL, slug or ID) says where it stands in one',
|
|
20
|
+
'call: what it still needs while in setup, documents as outlines, policies as section ids, open initiatives,',
|
|
21
|
+
'recent runs with their outcome, what waits on a person, findings and policy violations. Then read only what you',
|
|
22
|
+
'need. Across products: taiga_list_products, taiga_list_action_required. taiga_whoami says what this key may do.',
|
|
23
23
|
'',
|
|
24
24
|
'**Words.** An initiative is a unit of work in the backlog; a run is one attempt by the coding agent to build it;',
|
|
25
25
|
"the line is the product's queue; a factory owns a set of products. IDs are UUIDs, never names; a NOT_FOUND names",
|
|
26
|
-
'the tool that lists the right ones. A
|
|
26
|
+
'the tool that lists the right ones. A GitHub repository id is not a repository link id. Say a refusal as it is.',
|
|
27
27
|
'',
|
|
28
|
-
'**
|
|
29
|
-
'
|
|
28
|
+
'**Reading.** Lists page with nextCursor (Action Required, setup tasks, findings: totalCount). A document has two',
|
|
29
|
+
'timelines: plan (what was meant) and reality (derived from the code). outcome, situation and nextStep are',
|
|
30
|
+
'one-sentence summaries of the record they sit on; read the record for the detail.',
|
|
31
|
+
'',
|
|
32
|
+
'**Before you write.** A tool that writes opens with "Writes." or "Ask first:". Before an "Ask first" tool, tell',
|
|
33
|
+
'the person what you are about to do and wait for their yes: it starts paid agent work, interrupts it, or changes',
|
|
34
|
+
'what others see.',
|
|
30
35
|
'',
|
|
31
36
|
'**Only in the web:** connecting GitHub, members and roles, the Discovery conversation of a product started from',
|
|
32
37
|
'scratch, stamping checks, starting a run over, and deleting anything.',
|
package/src/tools/knowledge.ts
CHANGED
|
@@ -173,7 +173,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
|
|
|
173
173
|
{
|
|
174
174
|
title: 'Add knowledge',
|
|
175
175
|
description:
|
|
176
|
-
'File a
|
|
176
|
+
'File a reference document as knowledge for agents to search: Markdown or plain text, at the level it is relevant: a product ' +
|
|
177
177
|
'(productId), a factory (factoryId), or the organization (neither; that takes an owner or admin, ' +
|
|
178
178
|
'because every factory and product inherits it). Good candidates are what a new engineer would be ' +
|
|
179
179
|
'handed to read: specifications and contracts to conform to, glossaries and code lists, third-party ' +
|
package/src/tools/maintenance.ts
CHANGED
|
@@ -85,7 +85,14 @@ export function standingSummary(standing: {
|
|
|
85
85
|
controls?: unknown;
|
|
86
86
|
divergences?: unknown;
|
|
87
87
|
resolvedDivergences?: unknown;
|
|
88
|
-
}): {
|
|
88
|
+
}): {
|
|
89
|
+
controls: number;
|
|
90
|
+
controlsInPolicies: number;
|
|
91
|
+
controlsWithNothingRecorded: number;
|
|
92
|
+
violations: number;
|
|
93
|
+
bySeverity: Record<string, number>;
|
|
94
|
+
resolvedViolations: number;
|
|
95
|
+
} {
|
|
89
96
|
const controls = Array.isArray(standing.controls) ? (standing.controls as Row[]) : [];
|
|
90
97
|
const violations = Array.isArray(standing.divergences) ? (standing.divergences as Row[]) : [];
|
|
91
98
|
const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
|
|
@@ -95,7 +102,16 @@ export function standingSummary(standing: {
|
|
|
95
102
|
const severity = String(violation['severity'] ?? 'unknown');
|
|
96
103
|
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
|
|
97
104
|
}
|
|
98
|
-
return {
|
|
105
|
+
return {
|
|
106
|
+
controls: listed.length,
|
|
107
|
+
// Every control the product's policies hold, and those nothing is recorded against: the positive side of
|
|
108
|
+
// "how do we stand", which is no evidence against them, not proof they are met.
|
|
109
|
+
controlsInPolicies: controls.length,
|
|
110
|
+
controlsWithNothingRecorded: controls.length - listed.length,
|
|
111
|
+
violations: violations.length,
|
|
112
|
+
bySeverity,
|
|
113
|
+
resolvedViolations: resolved,
|
|
114
|
+
};
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
const findingId = idInput('finding', 'taiga_list_findings gives it');
|
|
@@ -177,7 +193,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
177
193
|
{
|
|
178
194
|
title: 'Get a maintenance finding',
|
|
179
195
|
description:
|
|
180
|
-
'Read one finding in full: what it is, how likely it is to be exploited (EPSS, the known-exploited list), ' +
|
|
196
|
+
'Read one maintenance finding in full: what it is, how likely it is to be exploited (EPSS, the known-exploited list), ' +
|
|
181
197
|
'the advisory and the fixed version, the place in the code, the policy controls it breaches, why it is ' +
|
|
182
198
|
'ranked where it is, its history (reintroduced, previously resolved) and the remediation under way. A ' +
|
|
183
199
|
"secret's value is never stored; the finding names the rule and the location only.",
|
|
@@ -198,7 +214,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
198
214
|
{
|
|
199
215
|
title: "Get a product's policy standing",
|
|
200
216
|
description:
|
|
201
|
-
"
|
|
217
|
+
"How a product stands against the organization's own published policies: the controls it violates, " +
|
|
202
218
|
"each with its violations (critical and high only, each citing the policy's sentence and the document " +
|
|
203
219
|
'passage that contradicts it), what is being done about each (an initiative raised, a fix in progress, ' +
|
|
204
220
|
'awaiting verification), and when the documents were derived and assessed, led by a summary of the ' +
|
|
@@ -208,7 +224,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
208
224
|
'done or canceled without the violation going away, so it needs a person or a new fix. A control ' +
|
|
209
225
|
'breached by scanner findings counts them by tier (findingsByTier): a breach of ' +
|
|
210
226
|
'routine findings only, such as secrets in test fixtures, is not an urgent one. A control with no violation ' +
|
|
211
|
-
'is not listed, and it is not "passing" either: there is no such state. Pass full: true for every ' +
|
|
227
|
+
'is not listed, and it is not "passing" either: there is no such state; summary.controlsWithNothingRecorded counts them. Pass full: true for every ' +
|
|
212
228
|
"violation's evidence and the resolved ones. Fix a control with taiga_fix_policy_violation.",
|
|
213
229
|
inputSchema: z.object({
|
|
214
230
|
productId,
|
|
@@ -228,9 +244,18 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
228
244
|
// The counts first, on both answers: the lists run to thousands of
|
|
229
245
|
// tokens, and "how bad is it" is the first question.
|
|
230
246
|
const summary = standingSummary(standing);
|
|
231
|
-
|
|
247
|
+
// Never assessed reads like a clean bill otherwise (PRO-2800).
|
|
248
|
+
const assessment = standing.assessment as { state?: unknown } | null | undefined;
|
|
249
|
+
const note =
|
|
250
|
+
assessment?.state === 'never'
|
|
251
|
+
? 'Never assessed: the documents have not been checked against the policies yet, so nothing here is an ' +
|
|
252
|
+
'all-clear. An assessment runs when the reality documents are derived or refreshed ' +
|
|
253
|
+
'(taiga_refresh_reality_documents, paid work: ask first).'
|
|
254
|
+
: null;
|
|
255
|
+
if (params.full) return toolResult({ summary, ...(note ? { note } : {}), ...standing });
|
|
232
256
|
return toolResult({
|
|
233
257
|
summary,
|
|
258
|
+
...(note ? { note } : {}),
|
|
234
259
|
productId: standing.productId,
|
|
235
260
|
hasPolicies: standing.hasPolicies,
|
|
236
261
|
assessment: standing.assessment,
|
|
@@ -259,7 +284,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
259
284
|
{
|
|
260
285
|
title: 'Ignore or reopen a finding',
|
|
261
286
|
description:
|
|
262
|
-
'Ignore a finding that does not apply or whose risk is accepted, with the reason (false_positive, ' +
|
|
287
|
+
'Ignore a maintenance finding, with a reason, or reopen it. Ignore one that does not apply or whose risk is accepted, with the reason (false_positive, ' +
|
|
263
288
|
'not_reachable, accepted_risk, wont_fix), an optional note and an optional expiry after which it resurfaces ' +
|
|
264
289
|
'if it still exists; or reopen an ignored one (ignored: false). Ignored findings are kept and reviewable. ' +
|
|
265
290
|
'There is no "mark fixed": a finding resolves when the next sweep no longer detects it. For a secret that ' +
|
|
@@ -302,7 +327,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
302
327
|
{
|
|
303
328
|
title: 'Fix a finding',
|
|
304
329
|
description:
|
|
305
|
-
"Ask
|
|
330
|
+
"Ask Taiga to fix a maintenance finding: it raises an initiative and adds it to the end of the product's queue, where " +
|
|
306
331
|
"the line plans it in turn; whether the build then waits for a person is the initiative's review gate, " +
|
|
307
332
|
'as for any initiative. The fix is a normal run that ends in a pull request you review and merge. With ' +
|
|
308
333
|
"wholeGroup: true it fixes the finding's whole group in one initiative, as the page does: for a " +
|
|
@@ -353,7 +378,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
353
378
|
{
|
|
354
379
|
title: 'Fix policy violations',
|
|
355
380
|
description:
|
|
356
|
-
"Ask
|
|
381
|
+
"Ask Taiga to fix a product's policy violations (it raises initiatives): name a control to cover every violation recorded " +
|
|
357
382
|
'against it that has no initiative yet, or a single violation (violationId) when the control has more ' +
|
|
358
383
|
"than one. Taiga raises one initiative and adds it to the end of the product's queue, exactly as a " +
|
|
359
384
|
'finding fix does. The violation stays listed while the work is under way: only the next assessment ' +
|
package/src/tools/observe.ts
CHANGED
|
@@ -100,7 +100,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
100
100
|
{
|
|
101
101
|
title: 'Get what runs where',
|
|
102
102
|
description:
|
|
103
|
-
"What
|
|
103
|
+
"What runs in each of a product's environments, and recent deployments: what is running now, anything deploying, and the recent " +
|
|
104
104
|
'history, newest first: each deployment with its commit, pull request, status, who deployed it and, ' +
|
|
105
105
|
'for a failure, the error the provider reported. What runs is the most recent deployment that actually ' +
|
|
106
106
|
'succeeded, so a newer failed attempt shows beside the version still running, not in its place. The ' +
|
|
@@ -174,7 +174,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
174
174
|
{
|
|
175
175
|
title: 'Get delivery metrics',
|
|
176
176
|
description:
|
|
177
|
-
"DORA's five
|
|
177
|
+
"DORA's five delivery metrics for one environment (the one marked production, as the page " +
|
|
178
178
|
'reports them), computed from its deployment history over the last 30, 60 or 90 days: deployment ' +
|
|
179
179
|
'frequency, lead time and failed deployment recovery time (throughput), change-failure rate and rework ' +
|
|
180
180
|
'rate (instability), each with its trend against the window before, and a performance band for all but ' +
|
|
@@ -196,7 +196,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
196
196
|
{
|
|
197
197
|
title: "Get a product's monitoring",
|
|
198
198
|
description:
|
|
199
|
-
"How a product's environments are doing: each environment's current health from Taiga's availability " +
|
|
199
|
+
"How a product's environments are doing (health, uptime): each environment's current health from Taiga's availability " +
|
|
200
200
|
'checks of its App URL, with its uptime and when it was last checked; a status Taiga cannot verify now ' +
|
|
201
201
|
'reads as not checked rather than the last thing it knew, and a new environment as baseline learning. ' +
|
|
202
202
|
"With environmentId, that environment's uptime over a 30, 60 or 90-day window, and the experience " +
|
|
@@ -105,7 +105,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
|
|
|
105
105
|
{
|
|
106
106
|
title: 'Mark a product document reviewed',
|
|
107
107
|
description:
|
|
108
|
-
|
|
108
|
+
'Record that a person reviewed a product document: its latest published version, as the Documents page ' +
|
|
109
109
|
"shows it beside each version; once every published document is reviewed, the product's review item in " +
|
|
110
110
|
'Action Required resolves. The mark says a human read it, so make it only when the person tells you they ' +
|
|
111
111
|
'have: reading it yourself is not a review.',
|
package/src/tools/products.ts
CHANGED
|
@@ -17,7 +17,15 @@ import {
|
|
|
17
17
|
type ProductDocumentOrigin,
|
|
18
18
|
type ProductSettingsView,
|
|
19
19
|
} from './catalogue.js';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
apiFailureCode,
|
|
22
|
+
apiFailureText,
|
|
23
|
+
isMissOrRefusal,
|
|
24
|
+
isNetworkFailure,
|
|
25
|
+
runTool,
|
|
26
|
+
toolError,
|
|
27
|
+
toolResult,
|
|
28
|
+
} from './errors.js';
|
|
21
29
|
import { parseRemote } from './remote.js';
|
|
22
30
|
import { sectionReadResult, sectionsInput, stripProvenance } from './sections.js';
|
|
23
31
|
import { setupReadiness } from './readiness.js';
|
|
@@ -30,6 +38,38 @@ type DocumentRow = WithoutTenant<ProductDocumentListItem>;
|
|
|
30
38
|
// Not `kind`: it is parked behind a flag that is on nowhere, so every row said null.
|
|
31
39
|
export const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'discoveryStep'] as const;
|
|
32
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Product rows with their factory's name beside its id (PRO-2800): "what
|
|
43
|
+
* products does the organization have" needed a second list to name the
|
|
44
|
+
* factories. One factories read; without factories:read, or when it fails,
|
|
45
|
+
* the rows keep the id alone.
|
|
46
|
+
*/
|
|
47
|
+
async function withFactoryNames<T extends { factoryId?: unknown }>(client: TaigaClient, rows: T[]) {
|
|
48
|
+
if (!client.hasCapability('factories:read') || rows.length === 0) return rows;
|
|
49
|
+
try {
|
|
50
|
+
// Every page until the rows' factories are named, up to a bound.
|
|
51
|
+
const wanted = new Set(rows.map((row) => String(row.factoryId)));
|
|
52
|
+
const names = new Map<string, string>();
|
|
53
|
+
let cursor: string | undefined;
|
|
54
|
+
for (let pageIndex = 0; pageIndex < FACTORY_PAGES; pageIndex += 1) {
|
|
55
|
+
const result = await client.listFactories({ limit: 100, cursor });
|
|
56
|
+
for (const factory of result.data) names.set(factory.id, factory.name);
|
|
57
|
+
if (!result.nextCursor || [...wanted].every((id) => names.has(id))) break;
|
|
58
|
+
cursor = result.nextCursor;
|
|
59
|
+
}
|
|
60
|
+
return rows.map((row) => {
|
|
61
|
+
const name = names.get(String(row.factoryId));
|
|
62
|
+
return name === undefined ? row : { ...row, factoryName: name };
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof TaigaApiError || isNetworkFailure(error)) return rows;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Factory pages read to name products' factories. */
|
|
71
|
+
const FACTORY_PAGES = 10;
|
|
72
|
+
|
|
33
73
|
/** A list's name search: part of a name, any case. */
|
|
34
74
|
const query = (what: string) =>
|
|
35
75
|
z.string().trim().min(1).max(200).optional().describe(`Part of the ${what}, any case: only the rows that contain it`);
|
|
@@ -322,7 +362,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
322
362
|
{
|
|
323
363
|
title: 'List products',
|
|
324
364
|
description:
|
|
325
|
-
'List products in the organization, as summaries (id, name, slug, status,
|
|
365
|
+
'List products in the organization, as summaries (id, name, slug, status, factoryId and factoryName, discoveryStep). ' +
|
|
326
366
|
'Use this to find a product by name (query); inside a git checkout, taiga_resolve_product finds it ' +
|
|
327
367
|
`from the remote URL instead. taiga_get_product reads one in full. ${DISCOVERY_STEP_MEANING}`,
|
|
328
368
|
inputSchema: z.object({
|
|
@@ -346,7 +386,9 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
346
386
|
const { query: text, ...filters } = params;
|
|
347
387
|
if (text === undefined) {
|
|
348
388
|
const result = await client.listProducts(filters);
|
|
349
|
-
return toolResult(
|
|
389
|
+
return toolResult(
|
|
390
|
+
page(await withFactoryNames(client, pickFields(result.data, PRODUCT_SUMMARY_FIELDS)), result.nextCursor)
|
|
391
|
+
);
|
|
350
392
|
}
|
|
351
393
|
if (params.cursor !== undefined) return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
|
|
352
394
|
const found = await searchPages(
|
|
@@ -362,7 +404,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
362
404
|
params.limit ?? 25
|
|
363
405
|
);
|
|
364
406
|
return toolResult({
|
|
365
|
-
...page(pickFields(found.rows, PRODUCT_SUMMARY_FIELDS), null),
|
|
407
|
+
...page(await withFactoryNames(client, pickFields(found.rows, PRODUCT_SUMMARY_FIELDS)), null),
|
|
366
408
|
...(found.more ? { more: SEARCH_NOTE } : {}),
|
|
367
409
|
});
|
|
368
410
|
})
|
|
@@ -373,7 +415,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
373
415
|
{
|
|
374
416
|
title: 'Get product',
|
|
375
417
|
description:
|
|
376
|
-
'
|
|
418
|
+
'Read one product record and its settings, by ID or by slug: name, slug, factory, what Taiga owns for it ' +
|
|
377
419
|
'(application, infrastructure, deployment) and its settings; the list and resolve tools carry ' +
|
|
378
420
|
'summaries; taiga_get_product_context carries this summary with the rest a session starts from. Use ' +
|
|
379
421
|
'taiga_list_product_documents for its specification and design, ' +
|
package/src/tools/queue.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
|
15
15
|
import { isNetworkFailure, runTool, toolResult } from './errors.js';
|
|
16
16
|
import { productId } from './inputs.js';
|
|
17
17
|
import { criteriaCounts } from './summarize.js';
|
|
18
|
+
import { handbackSaid } from './builds.js';
|
|
18
19
|
|
|
19
20
|
/** How many entries the read names; past this an entry keeps its initiativeId only. */
|
|
20
21
|
const NAMED_ENTRIES = 25;
|
|
@@ -32,6 +33,8 @@ interface QueueRow {
|
|
|
32
33
|
lifecycle?: Pick<BacklogItemLifecycle, 'turn' | 'stage' | 'cta'> | null;
|
|
33
34
|
runId?: string;
|
|
34
35
|
prUrl?: string | null;
|
|
36
|
+
/** Why the agent handed the latest run back to a person, when it did. */
|
|
37
|
+
handedBack?: string;
|
|
35
38
|
halt?: { cause: string | null; reason: string; nextStep: string };
|
|
36
39
|
}
|
|
37
40
|
|
|
@@ -89,6 +92,7 @@ function waitingOn(rows: QueueRow[]): string | null {
|
|
|
89
92
|
// An active entry with a landed plan is past its review gate (a closed
|
|
90
93
|
// gate halts it instead): it waits for the Build slot, not for anyone.
|
|
91
94
|
if (lifecycle.cta === 'start_build') return `${name} has its plan and waits for the Build slot; ${held}`;
|
|
95
|
+
if (head.handedBack) return `${name} was handed back to a person: ${head.handedBack}${pr}; ${held}`;
|
|
92
96
|
if (lifecycle.turn === 'you') {
|
|
93
97
|
return `${name} is waiting on a person (${String(lifecycle.cta ?? lifecycle.stage)})${pr}; ${held}`;
|
|
94
98
|
}
|
|
@@ -145,6 +149,8 @@ export async function queueView(
|
|
|
145
149
|
lifecycle: lifecycle ? { turn: lifecycle.turn, stage: lifecycle.stage, cta: lifecycle.cta } : null,
|
|
146
150
|
...(latestBuild ? { runId: latestBuild.id, prUrl: latestBuild.prUrl ?? null } : {}),
|
|
147
151
|
} satisfies Partial<QueueRow>);
|
|
152
|
+
const handback = handbackSaid(latestBuild?.reviewEscalation);
|
|
153
|
+
if (handback) row.handedBack = handback;
|
|
148
154
|
}
|
|
149
155
|
if (entry.state === 'halted') {
|
|
150
156
|
const cause = typeof entry.haltCause === 'string' ? entry.haltCause : null;
|
|
@@ -202,7 +208,7 @@ export function registerQueueTools(server: McpServer, client: TaigaClient) {
|
|
|
202
208
|
{
|
|
203
209
|
title: 'Get the product queue',
|
|
204
210
|
description:
|
|
205
|
-
"Read a product's
|
|
211
|
+
"Read a product's line, the queue the coding agent works one initiative at a time, and what it waits on: each entry in order " +
|
|
206
212
|
'with its title, where it is (planning, building, in review) and whose turn it is, a halted entry with why ' +
|
|
207
213
|
'it stopped and what lifts it, linePaused (null when the product could not be read), and waitingOn, one ' +
|
|
208
214
|
'sentence saying what the line is waiting for. The ' +
|
package/src/tools/readiness.ts
CHANGED
|
@@ -18,10 +18,10 @@ export interface Readiness {
|
|
|
18
18
|
ready: boolean | null;
|
|
19
19
|
/** Null when the links could not be read: not the same as none linked. */
|
|
20
20
|
repository: { linked: false } | { linked: true; name: unknown; branch: unknown } | null;
|
|
21
|
-
/** An imported product's import: not started, running, complete, failed. */
|
|
21
|
+
/** An imported product's import, as its settings.importStatus says it: pending (not started), analyzing or generating_docs (running), complete, failed, cancelled. */
|
|
22
22
|
import?: string;
|
|
23
23
|
discoveryStep?: unknown;
|
|
24
|
-
/** The required documents not yet published,
|
|
24
|
+
/** The required documents not yet published, as the type taiga_get_product_document takes; null when the API could not say. */
|
|
25
25
|
missingDocuments: string[] | null;
|
|
26
26
|
/** The required checks not yet stamped (stamped in the web); null when the API could not say. */
|
|
27
27
|
openChecks: string[] | null;
|
|
@@ -49,12 +49,17 @@ function named(types: string[]): string {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/** The product's setup readiness, or null once it has finished setup. */
|
|
52
|
-
export async function setupReadiness(
|
|
52
|
+
export async function setupReadiness(
|
|
53
|
+
client: TaigaClient,
|
|
54
|
+
product: Row,
|
|
55
|
+
/** The product's repository links when the caller is reading them anyway (the context), so one read serves both. */
|
|
56
|
+
links?: () => Promise<{ data: unknown[] }>
|
|
57
|
+
): Promise<Readiness | null> {
|
|
53
58
|
if (product['status'] !== 'draft') return null;
|
|
54
59
|
const id = String(product['id']);
|
|
55
60
|
const imported = isImportedProduct(product as ProductSettingsView);
|
|
56
61
|
const [repos, blockers, full] = await Promise.all([
|
|
57
|
-
client.listProductRepositories(id).then(
|
|
62
|
+
(links ? links() : client.listProductRepositories(id)).then(
|
|
58
63
|
(result) => result.data as Row[],
|
|
59
64
|
(error: unknown) => {
|
|
60
65
|
if (isApiSide(error)) return null;
|
|
@@ -97,9 +102,9 @@ export async function setupReadiness(client: TaigaClient, product: Row): Promise
|
|
|
97
102
|
return {
|
|
98
103
|
ready,
|
|
99
104
|
repository,
|
|
100
|
-
...(
|
|
105
|
+
...(imported ? { import: typeof settings.importStatus === 'string' ? settings.importStatus : 'pending' } : {}),
|
|
101
106
|
...(imported ? {} : { discoveryStep: full['discoveryStep'] ?? null }),
|
|
102
|
-
missingDocuments: missing
|
|
107
|
+
missingDocuments: missing,
|
|
103
108
|
openChecks,
|
|
104
109
|
nextStep: nextStepFor({
|
|
105
110
|
imported,
|
|
@@ -211,8 +211,8 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
211
211
|
{
|
|
212
212
|
title: 'Link a repository to a product',
|
|
213
213
|
description:
|
|
214
|
-
'Link a repository the GitHub connection can reach (taiga_list_linkable_repositories gives its id)
|
|
215
|
-
'
|
|
214
|
+
'Link a GitHub repository to a product: one the GitHub connection can reach (taiga_list_linkable_repositories gives its id), ' +
|
|
215
|
+
'or with replacesRepositoryId swap the one linked now for it in one step (naming the same ' +
|
|
216
216
|
'repository there changes nothing; linking one already linked without it is refused). A link Taiga ' +
|
|
217
217
|
'manages (isManaged) cannot be replaced or unlinked. Once linked, planning reads its code, runs open pull requests in it, ' +
|
|
218
218
|
'sweeps scan it, and its deployment history is imported in the background.',
|
package/src/tools/sections.ts
CHANGED
|
@@ -168,9 +168,33 @@ export const sectionsInput = z
|
|
|
168
168
|
.max(50)
|
|
169
169
|
.optional()
|
|
170
170
|
.describe(
|
|
171
|
-
'Only these sections, by id; [] for the outline (under outline: every section id and its size, no text); omit for the whole document'
|
|
171
|
+
'Only these sections, by id, or a policy control id (SEC-5.1) for the sections that state it; [] for the outline (under outline: every section id and its size, no text); omit for the whole document'
|
|
172
172
|
);
|
|
173
173
|
|
|
174
|
+
/** A policy control id as the standing cites it: SEC-5.1, SDLC-3.1, GDPR-2.3. */
|
|
175
|
+
const CONTROL_ID = /^[A-Z][A-Z0-9]*-\d+(\.\d+)*$/;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The sections that state each control id asked for (PRO-2800): policy
|
|
179
|
+
* standing cites SEC-5.1, and the section holding it has an id of its own
|
|
180
|
+
* (secretsManagement) an agent could only guess. A control id is found in
|
|
181
|
+
* the sections' text, matched as a whole id so SEC-5.1 is not SEC-5.10.
|
|
182
|
+
*/
|
|
183
|
+
function controlSections(sections: Sections, ids: readonly string[]): Record<string, string[]> {
|
|
184
|
+
const found: Record<string, string[]> = {};
|
|
185
|
+
for (const id of ids) {
|
|
186
|
+
if (!CONTROL_ID.test(id) || Object.hasOwn(sections, id)) continue;
|
|
187
|
+
// Whole id only: not SEC-5.10, SEC-5.1.2, SEC-5.1A or SEC-5.1-appendix; a sentence's full stop is fine.
|
|
188
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
189
|
+
const pattern = new RegExp(`(^|[^A-Za-z0-9.-])${escaped}(?![A-Za-z0-9-]|\\.[A-Za-z0-9])`);
|
|
190
|
+
const holders = Object.keys(sections).filter((sectionId) =>
|
|
191
|
+
pattern.test(sectionContent(sections, sectionId) ?? '')
|
|
192
|
+
);
|
|
193
|
+
if (holders.length > 0) found[id] = holders;
|
|
194
|
+
}
|
|
195
|
+
return found;
|
|
196
|
+
}
|
|
197
|
+
|
|
174
198
|
/**
|
|
175
199
|
* The answer to a read that names sections: the ones the document has, the
|
|
176
200
|
* rest listed with their sizes, and the asked-for ids it lacks under
|
|
@@ -179,12 +203,17 @@ export const sectionsInput = z
|
|
|
179
203
|
* real id with its size, so the retry needs nothing else. No ids is the
|
|
180
204
|
* outline.
|
|
181
205
|
*/
|
|
182
|
-
export function sectionReadResult(doc: StoredDocument,
|
|
206
|
+
export function sectionReadResult(doc: StoredDocument, asked: readonly string[], label: string): CallToolResult {
|
|
207
|
+
const before = sectionsOf(doc.data as DocumentData);
|
|
208
|
+
const controls = before ? controlSections(before, asked) : {};
|
|
209
|
+
// A control id reads the sections that state it; the id is then not unknown.
|
|
210
|
+
const ids = [...new Set([...asked.filter((id) => !Object.hasOwn(controls, id)), ...Object.values(controls).flat()])];
|
|
183
211
|
const cut = pickSections(doc, new Set(ids));
|
|
184
212
|
if (!cut) return toolError('VALIDATION_ERROR', `The ${label} is not made of sections; read it without sections.`);
|
|
185
213
|
const all = Object.keys(sectionsOf(doc.data as DocumentData) ?? {});
|
|
186
214
|
const unknownSections = ids.filter((id) => !all.includes(id));
|
|
187
215
|
const known = ids.filter((id) => all.includes(id));
|
|
216
|
+
const found = Object.keys(controls).length > 0 ? { controls } : {};
|
|
188
217
|
if (known.length === 0) {
|
|
189
218
|
// The outline, or a read naming only ids the document lacks: no text to
|
|
190
219
|
// carry, so no empty data.sections beside the ids, which read as a
|
|
@@ -198,6 +227,7 @@ export function sectionReadResult(doc: StoredDocument, ids: readonly string[], l
|
|
|
198
227
|
}
|
|
199
228
|
return toolResult({
|
|
200
229
|
...cut.doc,
|
|
230
|
+
...found,
|
|
201
231
|
omittedSections: cut.omittedSections,
|
|
202
232
|
...(unknownSections.length > 0 ? { unknownSections } : {}),
|
|
203
233
|
});
|
package/src/tools/setup.ts
CHANGED
|
@@ -268,7 +268,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
268
268
|
{
|
|
269
269
|
title: "Change a product's settings",
|
|
270
270
|
description:
|
|
271
|
-
"Change a product's name
|
|
271
|
+
"Change a product's name or what Taiga builds for it. A new name changes the slug, so links to the product change. What Taiga " +
|
|
272
272
|
'builds for it: its infrastructure code and CI/CD pipelines (null ' +
|
|
273
273
|
'returns one to the organization default), the sentence on how a half Taiga does not build is done today, ' +
|
|
274
274
|
"and whether it inherits the organization's design standards (null follows the default for how the " +
|
|
@@ -306,7 +306,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
306
306
|
{
|
|
307
307
|
title: "Refresh a product's documents from its code",
|
|
308
308
|
description:
|
|
309
|
-
"Derive the reality documents again from the code on the branch Taiga works on (the link's effectiveBranch: " +
|
|
309
|
+
"Derive the reality documents again from the code, on the branch Taiga works on (the link's effectiveBranch: " +
|
|
310
310
|
"the chosen branch, or the repository's default): all of them, or the ones named (with " +
|
|
311
311
|
'what they are derived from). For a document the import misread, refresh it rather than importing ' +
|
|
312
312
|
'again; unchanged sections are not republished. Runs an agent in the background and returns the job; ' +
|
package/src/tools/wait.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { isNetworkFailure, runTool, toolResult } from './errors.js';
|
|
|
9
9
|
import { SETTLED_JOB_STATUSES, jobReport } from './jobs.js';
|
|
10
10
|
import { criteriaCounts, pickFields } from './summarize.js';
|
|
11
11
|
import { situationOf } from './backlogs.js';
|
|
12
|
-
import {
|
|
12
|
+
import { readableRow } from './builds.js';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Waiting without a timer. A model in an editor has no clock: to learn that
|
|
@@ -324,6 +324,8 @@ const RUN_REPORT_FIELDS = [
|
|
|
324
324
|
'prState',
|
|
325
325
|
'prDraft',
|
|
326
326
|
'prMerged',
|
|
327
|
+
'reviewEscalation',
|
|
328
|
+
'error',
|
|
327
329
|
'prNumber',
|
|
328
330
|
'baseBranch',
|
|
329
331
|
'actions',
|
|
@@ -358,7 +360,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
|
|
|
358
360
|
{
|
|
359
361
|
title: 'Wait for an initiative',
|
|
360
362
|
description:
|
|
361
|
-
'Wait
|
|
363
|
+
'Wait for an initiative to reach a point (planned, building, a pull request, done), instead of reading it again and again: changed (anything ' +
|
|
362
364
|
'about where it is or its latest run), planned (its plan landed), your_turn (it waits on a person: a ' +
|
|
363
365
|
'plan to review, a pull request, a flag), pr_open (a pull request open now; a merged or closed one does ' +
|
|
364
366
|
'not count), run_settled (its latest run stopped moving) or done (finished; a canceled one stops the wait). ' +
|
|
@@ -423,7 +425,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
|
|
|
423
425
|
{
|
|
424
426
|
title: 'Wait for a run',
|
|
425
427
|
description:
|
|
426
|
-
|
|
428
|
+
'Wait for a run to reach a point (a step, a pull request, settled): changed (status, steps or pull request), step_done ' +
|
|
427
429
|
'(one more step finished), pr_open (open now, not merged or closed) or settled (it stopped moving: in ' +
|
|
428
430
|
'review, completed, failed, stopped). ' +
|
|
429
431
|
'Returns as soon as that holds (reached: true), early when the run settled without it (stoppedBecause; ' +
|
|
@@ -454,7 +456,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
|
|
|
454
456
|
return toolResult({
|
|
455
457
|
until: params.until,
|
|
456
458
|
...outcome,
|
|
457
|
-
run: now ?
|
|
459
|
+
run: now ? readableRow(pickFields([now], RUN_REPORT_FIELDS)[0]!) : null,
|
|
458
460
|
});
|
|
459
461
|
})
|
|
460
462
|
);
|
|
Binary file
|
|
Binary file
|