@taiga-ai/mcp-server 0.21.0-rc.456 → 0.22.0-dev.457
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 +225 -53
- package/package.json +1 -1
- package/src/server.integration.test.ts +328 -3
- package/src/tools/backlogs.ts +35 -47
- package/src/tools/builds.ts +52 -1
- package/src/tools/context.ts +2 -2
- package/src/tools/errors.ts +15 -6
- package/src/tools/inbox.ts +60 -6
- package/src/tools/index.ts +51 -7
- package/src/tools/inputs.ts +1 -1
- package/src/tools/instructions.ts +4 -3
- package/src/tools/maintenance.ts +56 -6
- package/src/tools/products.ts +3 -1
- package/src/tools/repositories.ts +26 -3
- package/src/tools/setup.ts +23 -11
- package/taiga-ai-mcp-server-0.22.0-dev.457.tgz +0 -0
- package/taiga-ai-mcp-server-0.21.0-rc.456.tgz +0 -0
package/src/tools/inbox.ts
CHANGED
|
@@ -52,8 +52,11 @@ function itemRow(row: Row, now: number, full: boolean): Row {
|
|
|
52
52
|
const { snoozedUntil, resolvedAt, ...rest } = picked;
|
|
53
53
|
const metadata = (row['metadata'] ?? {}) as Row;
|
|
54
54
|
const created = typeof row['createdAt'] === 'string' ? Date.parse(row['createdAt']) : NaN;
|
|
55
|
+
// A pull request review's summary is one sentence per row ("The coding agent finished its turn on X. Review PR
|
|
56
|
+
// #N...") that the title and kind already say: the org-wide page was 5k tokens of it (PRO-2800).
|
|
57
|
+
const { summary, ...lean } = rest;
|
|
55
58
|
return {
|
|
56
|
-
...
|
|
59
|
+
...(row['kind'] === 'review_pr' && !full ? lean : { ...lean, summary }),
|
|
57
60
|
...(snoozedUntil ? { snoozedUntil } : {}),
|
|
58
61
|
...(resolvedAt ? { resolvedAt } : {}),
|
|
59
62
|
...(typeof metadata['prUrl'] === 'string' ? { prUrl: metadata['prUrl'] } : {}),
|
|
@@ -63,7 +66,10 @@ function itemRow(row: Row, now: number, full: boolean): Row {
|
|
|
63
66
|
};
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* What is open, counted by product and kind: the answer to "what needs attention" without reading every row. An item
|
|
71
|
+
* that no longer stands is counted under stale, not open or byKind, so nobody subtracts by hand (PRO-2800).
|
|
72
|
+
*/
|
|
67
73
|
function summaryOf(rows: Row[], stale: Map<string, string>): Row[] {
|
|
68
74
|
const byProduct = new Map<
|
|
69
75
|
string,
|
|
@@ -77,9 +83,12 @@ function summaryOf(rows: Row[], stale: Map<string, string>): Row[] {
|
|
|
77
83
|
(row['productName']
|
|
78
84
|
? { productId: key, product: row['productName'], open: 0, byKind: {} }
|
|
79
85
|
: { productId: key, open: 0, byKind: {} });
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
if (stale.has(String(row['id']))) {
|
|
87
|
+
entry.stale = (entry.stale ?? 0) + 1;
|
|
88
|
+
} else {
|
|
89
|
+
entry.open += 1;
|
|
90
|
+
entry.byKind[String(row['kind'])] = (entry.byKind[String(row['kind'])] ?? 0) + 1;
|
|
91
|
+
}
|
|
83
92
|
byProduct.set(key, entry);
|
|
84
93
|
}
|
|
85
94
|
return [...byProduct.values()].sort((a, b) => b.open - a.open);
|
|
@@ -130,9 +139,54 @@ async function staleReviews(
|
|
|
130
139
|
}
|
|
131
140
|
})
|
|
132
141
|
);
|
|
142
|
+
unchecked += await staleBacklogReviews(client, rows, out);
|
|
133
143
|
return { stale: out, unchecked };
|
|
134
144
|
}
|
|
135
145
|
|
|
146
|
+
/**
|
|
147
|
+
* "Review Initiatives" items whose product has no open initiative left (PRO-2800): two reviews found one
|
|
148
|
+
* waiting 15 days on a product whose initiatives had all moved on, with nothing an agent could act on. Best effort,
|
|
149
|
+
* a handful of products; returns how many could not be checked.
|
|
150
|
+
*/
|
|
151
|
+
async function staleBacklogReviews(client: TaigaClient, rows: Row[], out: Map<string, string>): Promise<number> {
|
|
152
|
+
// The page and the summary's read can carry the same row: each item once.
|
|
153
|
+
const asks = [
|
|
154
|
+
...new Map(
|
|
155
|
+
rows
|
|
156
|
+
.filter((row) => row['kind'] === 'review_backlog' && typeof row['productId'] === 'string')
|
|
157
|
+
.map((row) => [String(row['id']), row])
|
|
158
|
+
).values(),
|
|
159
|
+
];
|
|
160
|
+
if (asks.length === 0) return 0;
|
|
161
|
+
if (!client.hasCapability('backlog:read')) return asks.length;
|
|
162
|
+
const products = [...new Set(asks.map((row) => String(row['productId'])))];
|
|
163
|
+
const checked = products.slice(0, STALE_CHECKS);
|
|
164
|
+
let unchecked = asks.filter((row) => !checked.includes(String(row['productId']))).length;
|
|
165
|
+
const empty = new Set<string>();
|
|
166
|
+
await Promise.all(
|
|
167
|
+
checked.map(async (productId) => {
|
|
168
|
+
try {
|
|
169
|
+
// Any open initiative keeps the item standing: status stays in_progress after a first run even when the
|
|
170
|
+
// initiative is moved back to todo, so backlog alone would call waiting work gone.
|
|
171
|
+
const [waiting, started] = await Promise.all([
|
|
172
|
+
client.listBacklogItems(productId, { status: 'backlog', limit: 1 }),
|
|
173
|
+
client.listBacklogItems(productId, { status: 'in_progress', limit: 1 }),
|
|
174
|
+
]);
|
|
175
|
+
if (waiting.data.length === 0 && started.data.length === 0) empty.add(productId);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
178
|
+
unchecked += asks.filter((row) => row['productId'] === productId).length;
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
for (const row of asks) {
|
|
183
|
+
if (empty.has(String(row['productId']))) {
|
|
184
|
+
out.set(String(row['id']), 'no initiative is open now: the ones it asked about are all done or canceled');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return unchecked;
|
|
188
|
+
}
|
|
189
|
+
|
|
136
190
|
/** Pages read for the summary; past this it says it counted only those. */
|
|
137
191
|
const SUMMARY_PAGE = 100;
|
|
138
192
|
|
|
@@ -291,7 +345,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
291
345
|
// Past the cap a review is not checked, so no stale on it is not "live".
|
|
292
346
|
...(checked.unchecked > 0
|
|
293
347
|
? {
|
|
294
|
-
staleNote: `${checked.unchecked}
|
|
348
|
+
staleNote: `${checked.unchecked} review${checked.unchecked === 1 ? ' was' : 's were'} not checked for staleness (past the cap, or the read was refused).`,
|
|
295
349
|
}
|
|
296
350
|
: {}),
|
|
297
351
|
});
|
package/src/tools/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { McpServer } from '@modelcontextprotocol/server';
|
|
1
|
+
import type { CallToolResult, McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import type
|
|
3
|
+
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
4
4
|
import { registerProductTools } from './products.js';
|
|
5
5
|
import { registerDocumentTools } from './documents.js';
|
|
6
6
|
import { registerBacklogTools } from './backlogs.js';
|
|
@@ -19,7 +19,7 @@ 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';
|
|
22
|
+
import { isNetworkFailure, runTool, toolResult } from './errors.js';
|
|
23
23
|
import { READ_ONLY } from './annotations.js';
|
|
24
24
|
import { DEFAULT_WAIT_OPTIONS, registerWaitTools, type WaitOptions } from './wait.js';
|
|
25
25
|
|
|
@@ -32,7 +32,11 @@ import { DEFAULT_WAIT_OPTIONS, registerWaitTools, type WaitOptions } from './wai
|
|
|
32
32
|
* model learns the tool's real shape. Applied here, once, so no tool can be
|
|
33
33
|
* registered without it.
|
|
34
34
|
*/
|
|
35
|
-
function withStrictInputs(
|
|
35
|
+
function withStrictInputs(
|
|
36
|
+
server: McpServer,
|
|
37
|
+
client: TaigaClient,
|
|
38
|
+
registered: Map<string, ToolAnnotations | undefined>
|
|
39
|
+
): McpServer {
|
|
36
40
|
const register = server.registerTool.bind(server) as (...args: unknown[]) => unknown;
|
|
37
41
|
server.registerTool = ((
|
|
38
42
|
name: string,
|
|
@@ -47,8 +51,8 @@ function withStrictInputs(server: McpServer, registered: Map<string, ToolAnnotat
|
|
|
47
51
|
// .strict() keeps the object's own checks (a refine stays a refine).
|
|
48
52
|
// The call runs with its not-found hint in scope, for runTool to add to a 404.
|
|
49
53
|
const handler = callback as (args: unknown, ctx: unknown) => unknown;
|
|
50
|
-
const withHint = (args: unknown, ctx: unknown) =>
|
|
51
|
-
notFoundHints.run(notFoundHint(name, args), () => handler(args, ctx));
|
|
54
|
+
const withHint = async (args: unknown, ctx: unknown) =>
|
|
55
|
+
withIdKind(client, args, await notFoundHints.run(notFoundHint(name, args), () => handler(args, ctx)));
|
|
52
56
|
const described = {
|
|
53
57
|
...config,
|
|
54
58
|
description: `${writeNotice(name, config.annotations)}${config.description ?? ''}`,
|
|
@@ -59,6 +63,46 @@ function withStrictInputs(server: McpServer, registered: Map<string, ToolAnnotat
|
|
|
59
63
|
return server;
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
/**
|
|
67
|
+
* A product the API does not know may be a factory's ID passed as one (PRO-2800): the hint can only say a product
|
|
68
|
+
* ID is wanted, so on that miss alone one read names what the ID is.
|
|
69
|
+
*/
|
|
70
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
71
|
+
|
|
72
|
+
async function withIdKind(client: TaigaClient, args: unknown, result: unknown): Promise<unknown> {
|
|
73
|
+
const productId = typeof args === 'object' && args !== null ? (args as Record<string, unknown>)['productId'] : null;
|
|
74
|
+
const answer = result as CallToolResult;
|
|
75
|
+
const first = answer?.isError ? answer.content?.[0] : undefined;
|
|
76
|
+
if (typeof productId !== 'string' || first?.type !== 'text') return result;
|
|
77
|
+
let body: { error?: unknown; message?: unknown };
|
|
78
|
+
try {
|
|
79
|
+
body = JSON.parse(first.text) as typeof body;
|
|
80
|
+
} catch {
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
// The API's own miss for the product, not a hint that mentions products; one read, with a key that can make it.
|
|
84
|
+
if (
|
|
85
|
+
body.error !== 'NOT_FOUND' ||
|
|
86
|
+
!/\b(product|project) not found|No product with that ID/i.test(String(body.message))
|
|
87
|
+
) {
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
if (!UUID.test(productId) || !client.hasCapability('factories:read')) return result;
|
|
91
|
+
try {
|
|
92
|
+
const factory = (await client.getFactory(productId)).data;
|
|
93
|
+
const message =
|
|
94
|
+
`${String(body.message)} That ID is the factory "${factory.name}", not a product: taiga_list_products ` +
|
|
95
|
+
'with factoryId lists its products.';
|
|
96
|
+
return {
|
|
97
|
+
...answer,
|
|
98
|
+
content: [{ type: 'text', text: JSON.stringify({ ...body, message }) }, ...answer.content.slice(1)],
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof TaigaApiError || isNetworkFailure(error)) return result;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
62
106
|
type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean; openWorldHint?: boolean };
|
|
63
107
|
|
|
64
108
|
/**
|
|
@@ -122,7 +166,7 @@ export const PAID_WORK = new Set([
|
|
|
122
166
|
*/
|
|
123
167
|
export function registerAllTools(server: McpServer, client: TaigaClient, options: { wait?: WaitOptions } = {}): void {
|
|
124
168
|
const registered = new Map<string, ToolAnnotations | undefined>();
|
|
125
|
-
withStrictInputs(server, registered);
|
|
169
|
+
withStrictInputs(server, client, registered);
|
|
126
170
|
registerProductTools(server, client);
|
|
127
171
|
registerDocumentTools(server, client);
|
|
128
172
|
registerBacklogTools(server, client);
|
package/src/tools/inputs.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { z } from 'zod';
|
|
|
10
10
|
export function idInput(what: string, from: string) {
|
|
11
11
|
return z
|
|
12
12
|
.string()
|
|
13
|
-
.uuid({ message:
|
|
13
|
+
.uuid({ message: `${/^[aeiou]/i.test(what) ? 'An' : 'A'} ${what} ID is a UUID, not a name: ${from}` })
|
|
14
14
|
.describe(`${what[0]!.toUpperCase()}${what.slice(1)} ID -- ${from}`);
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -16,7 +16,7 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
16
16
|
'**Which read.** Where a product stands: taiga_get_product_context (by git remote URL, slug or ID). What waits on',
|
|
17
17
|
'people: taiga_list_action_required. A policy: taiga_get_document (sections: [] for the outline, or a control id',
|
|
18
18
|
'such as SEC-5.1). Findings: taiga_list_findings. One initiative or run: taiga_get_initiative, taiga_get_run.',
|
|
19
|
-
"This key's role and tools: taiga_whoami.",
|
|
19
|
+
"This key's role and which tools write: taiga_whoami.",
|
|
20
20
|
'',
|
|
21
21
|
'**Words.** An initiative is a unit of work; its board columns are backlog, todo, queue, plan, build, done, canceled. The',
|
|
22
22
|
"line is the product's queue, worked one initiative at a time through plan and build. A run is one attempt by the",
|
|
@@ -28,8 +28,9 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
28
28
|
'(derived from the code). outcome, situation and nextStep are one-sentence summaries of their record.',
|
|
29
29
|
'',
|
|
30
30
|
'**Writing.** A tool that writes opens with "Writes.", "Ask first (paid agent work)." or "Ask first (others see',
|
|
31
|
-
'it)."; before an ask-first tool, tell the person what you will do and wait for their yes.
|
|
32
|
-
'product name is refused unless allowDuplicateName
|
|
31
|
+
'it)."; before an ask-first tool, tell the person what you will do and wait for their yes. Others share this',
|
|
32
|
+
'organization: a taken factory name (or product name in its factory) is refused unless allowDuplicateName, and',
|
|
33
|
+
"the one that exists may not be this person's.",
|
|
33
34
|
'',
|
|
34
35
|
'**Only in the web:** connecting GitHub, members and roles, the Discovery conversation of a product started from',
|
|
35
36
|
'scratch, stamping checks, starting a run over, and deleting factories and products.',
|
package/src/tools/maintenance.ts
CHANGED
|
@@ -45,6 +45,48 @@ const FINDING_ROW_FIELDS = [
|
|
|
45
45
|
'ignoreExpiresAt',
|
|
46
46
|
] as const;
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Whether taiga_fix_finding would take a finding, said on the row (PRO-2800): three reviews found a dependency
|
|
50
|
+
* marked remediableBy coding_agent with no fixed version, which the API refuses to fix. The API's own refusals,
|
|
51
|
+
* read off the row; remediableBy stays as the sweep recorded it.
|
|
52
|
+
*/
|
|
53
|
+
function withFixability(row: Row): Row {
|
|
54
|
+
const dependency = row['kind'] === 'vulnerable_dependency' || row['kind'] === 'outdated_dependency';
|
|
55
|
+
if (dependency && !row['recommendedVersion'] && row['remediableBy'] === 'coding_agent') {
|
|
56
|
+
return {
|
|
57
|
+
...row,
|
|
58
|
+
fixable: false,
|
|
59
|
+
fixNote: 'No fixed version is known yet: taiga_fix_finding refuses it until one is published.',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return row;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The parts of an advisory record an agent reads; the rest (every affected range, every reference) is full: true. */
|
|
66
|
+
const ADVISORY_KEPT = ['id', 'aliases', 'summary', 'details', 'severity', 'published'];
|
|
67
|
+
const ADVISORY_TEXT_LIMIT = 1_500;
|
|
68
|
+
|
|
69
|
+
function leanAdvisory(data: unknown): unknown {
|
|
70
|
+
if (typeof data !== 'object' || data === null || !('affected' in data || 'references' in data)) return data;
|
|
71
|
+
const record = data as Row;
|
|
72
|
+
const kept = Object.fromEntries(ADVISORY_KEPT.filter((key) => key in record).map((key) => [key, record[key]]));
|
|
73
|
+
const details = kept['details'];
|
|
74
|
+
if (typeof details === 'string' && details.length > ADVISORY_TEXT_LIMIT) {
|
|
75
|
+
kept['details'] =
|
|
76
|
+
`${details.slice(0, ADVISORY_TEXT_LIMIT)}... [cut at ${ADVISORY_TEXT_LIMIT} of ${details.length} characters; full: true reads it whole]`;
|
|
77
|
+
}
|
|
78
|
+
const references = Array.isArray(record['references']) ? record['references'] : [];
|
|
79
|
+
return {
|
|
80
|
+
...kept,
|
|
81
|
+
...(references.length > 0
|
|
82
|
+
? {
|
|
83
|
+
references: references.slice(0, 3),
|
|
84
|
+
...(references.length > 3 ? { referencesNote: `3 of ${references.length}; full: true has every one.` } : {}),
|
|
85
|
+
}
|
|
86
|
+
: {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
48
90
|
/** A sweep as the record of when the product was last checked, and whether that check held. */
|
|
49
91
|
const SWEEP_FIELDS = [
|
|
50
92
|
'id',
|
|
@@ -206,7 +248,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
206
248
|
: sweep
|
|
207
249
|
? { sweep }
|
|
208
250
|
: {}),
|
|
209
|
-
...page(pickFields(result.data, FINDING_ROW_FIELDS), result.nextCursor),
|
|
251
|
+
...page(pickFields(result.data, FINDING_ROW_FIELDS).map(withFixability), result.nextCursor),
|
|
210
252
|
...(typeof result.totalCount === 'number' ? { totalCount: result.totalCount } : {}),
|
|
211
253
|
});
|
|
212
254
|
})
|
|
@@ -217,18 +259,26 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
217
259
|
{
|
|
218
260
|
title: 'Get a maintenance finding',
|
|
219
261
|
description:
|
|
220
|
-
'Read one maintenance finding
|
|
262
|
+
'Read one maintenance finding: exploit likelihood (EPSS, known-exploited list), the advisory summarized ' +
|
|
263
|
+
'(full: true for its whole record: every affected range and reference) and ' +
|
|
221
264
|
'fixed version, code location, breached policy controls, why it ranks where it does, history ' +
|
|
222
|
-
|
|
223
|
-
'only the rule and location.
|
|
224
|
-
inputSchema: z.object({
|
|
265
|
+
'(reintroduced, previously resolved) and the remediation under way. fixable: false with fixNote says ' +
|
|
266
|
+
"taiga_fix_finding would refuse it. A secret's value is never stored, only the rule and location.",
|
|
267
|
+
inputSchema: z.object({
|
|
268
|
+
findingId,
|
|
269
|
+
full: z
|
|
270
|
+
.boolean()
|
|
271
|
+
.optional()
|
|
272
|
+
.describe("Carry the advisory's whole record: every affected range and reference (default false)"),
|
|
273
|
+
}),
|
|
225
274
|
annotations: READ_ONLY,
|
|
226
275
|
},
|
|
227
276
|
(params) =>
|
|
228
277
|
runTool(async () => {
|
|
229
278
|
// The finding's runId is the sweep that found it, not a coding run:
|
|
230
279
|
// in the product's words runId is what taiga_get_run takes (PRO-2800).
|
|
231
|
-
const { runId, ...
|
|
280
|
+
const { runId, ...raw } = (await client.getFinding(params.findingId)).data as Row;
|
|
281
|
+
const finding = withFixability(params.full ? raw : { ...raw, data: leanAdvisory(raw['data']) });
|
|
232
282
|
return toolResult(runId === undefined ? finding : { sweepId: runId, ...finding });
|
|
233
283
|
})
|
|
234
284
|
);
|
package/src/tools/products.ts
CHANGED
|
@@ -278,7 +278,9 @@ export async function resolveProductRef(
|
|
|
278
278
|
if (!isMissOrRefusal(error)) throw error;
|
|
279
279
|
return refusal(
|
|
280
280
|
apiFailureCode(error),
|
|
281
|
-
|
|
281
|
+
error instanceof TaigaApiError && error.status === 404
|
|
282
|
+
? 'No product with that ID the key can see; taiga_list_products shows the ones it can.'
|
|
283
|
+
: `No product with that ID the key can see (${apiFailureText(error)}); taiga_list_products shows the ones it can.`
|
|
282
284
|
);
|
|
283
285
|
}
|
|
284
286
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { GithubRepository } from '@taiga/types';
|
|
4
|
-
import type
|
|
4
|
+
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
5
5
|
import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
6
|
-
import { runTool, toolError, toolResult } from './errors.js';
|
|
6
|
+
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
7
|
+
import { setupReadiness } from './readiness.js';
|
|
7
8
|
import { idInput, pagination, productId } from './inputs.js';
|
|
8
9
|
import { SEARCH_WITH_CURSOR, page, pickFields } from './summarize.js';
|
|
9
10
|
|
|
@@ -65,6 +66,24 @@ export function interleave<T>(lists: T[][]): T[] {
|
|
|
65
66
|
return out;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A product in setup says what it still needs after a repository write, as create_product and get_product do
|
|
71
|
+
* (PRO-2800): a reviewer had to read the product again to learn that the import came next. Best effort: the write
|
|
72
|
+
* is done, so a failed read leaves readiness out rather than failing it.
|
|
73
|
+
*/
|
|
74
|
+
async function readinessAfter(client: TaigaClient, productId: string): Promise<{ readiness?: unknown }> {
|
|
75
|
+
try {
|
|
76
|
+
const readiness = await setupReadiness(
|
|
77
|
+
client,
|
|
78
|
+
(await client.getProduct(productId)).data as unknown as Record<string, unknown>
|
|
79
|
+
);
|
|
80
|
+
return readiness ? { readiness } : {};
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof TaigaApiError || isNetworkFailure(error)) return {};
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
export function registerRepositoryTools(server: McpServer, client: TaigaClient) {
|
|
69
88
|
if (client.hasCapability('products:read')) {
|
|
70
89
|
server.registerTool(
|
|
@@ -226,7 +245,10 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
226
245
|
githubRepositoryId: params.githubRepositoryId,
|
|
227
246
|
...(params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}),
|
|
228
247
|
});
|
|
229
|
-
return toolResult(
|
|
248
|
+
return toolResult({
|
|
249
|
+
...pickFields([linked.data], LINK_FIELDS)[0],
|
|
250
|
+
...(await readinessAfter(client, params.productId)),
|
|
251
|
+
});
|
|
230
252
|
})
|
|
231
253
|
);
|
|
232
254
|
|
|
@@ -267,6 +289,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
267
289
|
return toolResult({
|
|
268
290
|
...pickFields([updated], LINK_FIELDS)[0],
|
|
269
291
|
baseBranchProtection: updated.baseBranchProtection ?? null,
|
|
292
|
+
...(await readinessAfter(client, params.productId)),
|
|
270
293
|
});
|
|
271
294
|
})
|
|
272
295
|
);
|
package/src/tools/setup.ts
CHANGED
|
@@ -85,7 +85,7 @@ const NAME_PAGES = 5;
|
|
|
85
85
|
* The first row with this name, across pages. Best effort: a list the API
|
|
86
86
|
* refuses or fails leaves the check out rather than the create.
|
|
87
87
|
*/
|
|
88
|
-
async function firstNamed<T extends { id: string; name: string; slug?: string }>(
|
|
88
|
+
async function firstNamed<T extends { id: string; name: string; slug?: string; createdAt?: unknown }>(
|
|
89
89
|
name: string,
|
|
90
90
|
read: (cursor: string | undefined) => Promise<{ data: T[]; nextCursor: string | null }>
|
|
91
91
|
): Promise<T | null> {
|
|
@@ -104,11 +104,20 @@ async function firstNamed<T extends { id: string; name: string; slug?: string }>
|
|
|
104
104
|
return null;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
/**
|
|
108
|
+
* The refusal names the one that exists and leaves the choice to the person: another session may have made it, so
|
|
109
|
+
* "use it" would hand the agent someone else's work (PRO-2800, review 6).
|
|
110
|
+
*/
|
|
111
|
+
function duplicateName(
|
|
112
|
+
kind: 'factory' | 'product',
|
|
113
|
+
existing: { id: string; name: string; slug?: string; createdAt?: unknown },
|
|
114
|
+
where: string
|
|
115
|
+
) {
|
|
116
|
+
const made = typeof existing.createdAt === 'string' ? `, created ${existing.createdAt}` : '';
|
|
108
117
|
return toolError(
|
|
109
118
|
'DUPLICATE_NAME',
|
|
110
|
-
`A ${kind} named "${existing.name}" already exists (${existing.id}).
|
|
111
|
-
'
|
|
119
|
+
`A ${kind} named "${existing.name}" already exists ${where} (${existing.id}${made}). It may not be this ` +
|
|
120
|
+
"person's: ask whether they meant that one, want another name, or want a second (allowDuplicateName: true).",
|
|
112
121
|
{ existingId: existing.id, ...(existing.slug ? { existingSlug: existing.slug } : {}) }
|
|
113
122
|
);
|
|
114
123
|
}
|
|
@@ -179,7 +188,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
179
188
|
runTool(async () => {
|
|
180
189
|
if (!params.allowDuplicateName) {
|
|
181
190
|
const same = await firstNamed(params.name, async (cursor) => client.listFactories({ limit: 100, cursor }));
|
|
182
|
-
if (same) return duplicateName('factory', same);
|
|
191
|
+
if (same) return duplicateName('factory', same, 'in this organization');
|
|
183
192
|
}
|
|
184
193
|
return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
|
|
185
194
|
})
|
|
@@ -279,10 +288,10 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
279
288
|
{
|
|
280
289
|
title: 'Create a product',
|
|
281
290
|
description:
|
|
282
|
-
'Create a product in a factory
|
|
283
|
-
'
|
|
284
|
-
'
|
|
285
|
-
'
|
|
291
|
+
'Create a product in a factory and return it with readiness (what it needs next). start cannot change ' +
|
|
292
|
+
'later. import (brownfield): Taiga drafts the documents from existing code; link it with ' +
|
|
293
|
+
'taiga_link_repository (owners and admins only), then taiga_start_import. scratch (greenfield): Discovery ' +
|
|
294
|
+
'is a conversation in the web that writes the specification. Infrastructure code and CI/CD ' +
|
|
286
295
|
'pipelines follow the organization unless set here; when one is off, deploymentNotes says how that half ' +
|
|
287
296
|
'is done today. Add source material first with taiga_add_knowledge (productId): Discovery writes from it.',
|
|
288
297
|
inputSchema: z.object({
|
|
@@ -300,8 +309,11 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
300
309
|
const contradiction = notesWithNothingToDescribe(params);
|
|
301
310
|
if (contradiction) return contradiction;
|
|
302
311
|
if (!params.allowDuplicateName) {
|
|
303
|
-
|
|
304
|
-
|
|
312
|
+
// Within the factory it goes into: the same name in another factory is a different product.
|
|
313
|
+
const same = await firstNamed(params.name, async (cursor) =>
|
|
314
|
+
client.listProducts({ factoryId: params.factoryId, limit: 100, cursor })
|
|
315
|
+
);
|
|
316
|
+
if (same) return duplicateName('product', same, 'in this factory');
|
|
305
317
|
}
|
|
306
318
|
const product = (
|
|
307
319
|
await client.createProduct({
|
|
Binary file
|
|
Binary file
|