@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/sections.ts
CHANGED
|
@@ -15,15 +15,42 @@ import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { toolError, toolResult } from './errors.js';
|
|
17
17
|
|
|
18
|
-
/** A section
|
|
18
|
+
/** A document's section map, keyed by section id: each value a `StoredSection` or (older rows) a bare string. */
|
|
19
19
|
export type Sections = Record<string, unknown>;
|
|
20
20
|
|
|
21
|
+
/** A section stored as an object: its text and the metadata an edit keeps. */
|
|
22
|
+
export interface StoredSection {
|
|
23
|
+
content?: unknown;
|
|
24
|
+
title?: unknown;
|
|
25
|
+
priority?: unknown;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A document's `data`: a section-shaped document keeps its content under `sections`. */
|
|
30
|
+
export interface DocumentData {
|
|
31
|
+
sections?: unknown;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A document as these helpers read it: its `data` and the provenance a reader does not need. */
|
|
36
|
+
export interface StoredDocument {
|
|
37
|
+
data?: unknown;
|
|
38
|
+
derivedFrom?: unknown;
|
|
39
|
+
outdated?: unknown;
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
44
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
45
|
}
|
|
24
46
|
|
|
47
|
+
/** A section stored as an object rather than a bare string. */
|
|
48
|
+
export function isSectionObject(value: unknown): value is StoredSection {
|
|
49
|
+
return isRecord(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
25
52
|
/** The `sections` map of a document, or null when the document is not section-shaped. */
|
|
26
|
-
export function sectionsOf(data:
|
|
53
|
+
export function sectionsOf(data: DocumentData): Sections | null {
|
|
27
54
|
return isRecord(data.sections) ? data.sections : null;
|
|
28
55
|
}
|
|
29
56
|
|
|
@@ -32,7 +59,7 @@ export function sectionContent(sections: Sections, sectionId: string): string |
|
|
|
32
59
|
const section = sections[sectionId];
|
|
33
60
|
if (section === undefined || section === null) return null;
|
|
34
61
|
if (typeof section === 'string') return section;
|
|
35
|
-
return
|
|
62
|
+
return isSectionObject(section) && typeof section.content === 'string' ? section.content : '';
|
|
36
63
|
}
|
|
37
64
|
|
|
38
65
|
/** The map with one section's text replaced (or the section added); every other section untouched. */
|
|
@@ -48,10 +75,12 @@ export function applySectionEdit(sections: Sections, sectionId: string, content:
|
|
|
48
75
|
* the editor needs and a reader does not; `source` and `priority` stay, the
|
|
49
76
|
* server instructions explain them.
|
|
50
77
|
*/
|
|
51
|
-
export function stripProvenance(doc:
|
|
78
|
+
export function stripProvenance<T extends StoredDocument>(doc: T): Omit<T, 'derivedFrom' | 'outdated'> {
|
|
52
79
|
const { derivedFrom: _derivedFrom, outdated: _outdated, ...rest } = doc;
|
|
53
|
-
|
|
54
|
-
const
|
|
80
|
+
// `rest` holds the same `data`; read through `doc`, whose type still names it.
|
|
81
|
+
const { data } = doc;
|
|
82
|
+
if (!isRecord(data)) return rest;
|
|
83
|
+
const sections = sectionsOf(data);
|
|
55
84
|
if (!sections) return rest;
|
|
56
85
|
const stripped = Object.fromEntries(
|
|
57
86
|
Object.entries(sections).map(([id, section]) => {
|
|
@@ -60,7 +89,7 @@ export function stripProvenance(doc: Record<string, unknown>): Record<string, un
|
|
|
60
89
|
return [id, keep];
|
|
61
90
|
})
|
|
62
91
|
);
|
|
63
|
-
return { ...rest, data: { ...
|
|
92
|
+
return { ...rest, data: { ...data, sections: stripped } };
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
/**
|
|
@@ -73,7 +102,7 @@ export function instructionsText(sections: Sections): string | null {
|
|
|
73
102
|
for (const section of Object.values(sections)) {
|
|
74
103
|
// Object sections only, as the API's extractor reads them: a bare-string
|
|
75
104
|
// row the agents never receive is not handed to the model here either.
|
|
76
|
-
const content =
|
|
105
|
+
const content = isSectionObject(section) ? section.content : undefined;
|
|
77
106
|
if (typeof content === 'string' && content.trim()) parts.push(content.trim());
|
|
78
107
|
}
|
|
79
108
|
const text = parts.join('\n\n').trim();
|
|
@@ -82,15 +111,15 @@ export function instructionsText(sections: Sections): string | null {
|
|
|
82
111
|
|
|
83
112
|
/** A document cut to some of its sections, and the ones left out with their sizes. */
|
|
84
113
|
export interface SectionCut {
|
|
85
|
-
doc:
|
|
114
|
+
doc: StoredDocument;
|
|
86
115
|
omittedSections: { id: string; characters: number }[];
|
|
87
116
|
}
|
|
88
117
|
|
|
89
|
-
function cutTo(doc:
|
|
118
|
+
function cutTo(doc: StoredDocument, sections: Sections, kept: Sections): SectionCut {
|
|
90
119
|
const omittedSections = Object.keys(sections)
|
|
91
120
|
.filter((id) => !Object.hasOwn(kept, id))
|
|
92
121
|
.map((id) => ({ id, characters: sectionContent(sections, id)?.length ?? 0 }));
|
|
93
|
-
return { doc: { ...doc, data: { ...(doc.data as
|
|
122
|
+
return { doc: { ...doc, data: { ...(doc.data as DocumentData), sections: kept } }, omittedSections };
|
|
94
123
|
}
|
|
95
124
|
|
|
96
125
|
/**
|
|
@@ -100,7 +129,7 @@ function cutTo(doc: Record<string, unknown>, sections: Sections, kept: Sections)
|
|
|
100
129
|
* every other section is listed with its size so the reader knows what else
|
|
101
130
|
* there is. Null when the document is not made of sections.
|
|
102
131
|
*/
|
|
103
|
-
export function pickSections(doc:
|
|
132
|
+
export function pickSections(doc: StoredDocument, ids: ReadonlySet<string>): SectionCut | null {
|
|
104
133
|
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
105
134
|
if (!sections) return null;
|
|
106
135
|
return cutTo(doc, sections, Object.fromEntries(Object.entries(sections).filter(([id]) => ids.has(id))));
|
|
@@ -115,7 +144,7 @@ export function pickSections(doc: Record<string, unknown>, ids: ReadonlySet<stri
|
|
|
115
144
|
* and a later, smaller one may still be carried. Null when the document is
|
|
116
145
|
* not made of sections.
|
|
117
146
|
*/
|
|
118
|
-
export function budgetSections(doc:
|
|
147
|
+
export function budgetSections(doc: StoredDocument, budget: number): SectionCut | null {
|
|
119
148
|
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
120
149
|
if (!sections) return null;
|
|
121
150
|
// Entries, not assignment: a section id is text, and `kept["__proto__"] = x` would set a prototype.
|
|
@@ -139,7 +168,7 @@ export const sectionsInput = z
|
|
|
139
168
|
.max(50)
|
|
140
169
|
.optional()
|
|
141
170
|
.describe(
|
|
142
|
-
'Only these sections, by id; [] for the outline (every section id and its size, no text); omit for the whole document'
|
|
171
|
+
'Only these sections, by id; [] for the outline (under outline: every section id and its size, no text); omit for the whole document'
|
|
143
172
|
);
|
|
144
173
|
|
|
145
174
|
/**
|
|
@@ -150,11 +179,23 @@ export const sectionsInput = z
|
|
|
150
179
|
* real id with its size, so the retry needs nothing else. No ids is the
|
|
151
180
|
* outline.
|
|
152
181
|
*/
|
|
153
|
-
export function sectionReadResult(doc:
|
|
182
|
+
export function sectionReadResult(doc: StoredDocument, ids: readonly string[], label: string): CallToolResult {
|
|
154
183
|
const cut = pickSections(doc, new Set(ids));
|
|
155
184
|
if (!cut) return toolError('VALIDATION_ERROR', `The ${label} is not made of sections; read it without sections.`);
|
|
156
|
-
const all = Object.keys(sectionsOf(doc.data as
|
|
185
|
+
const all = Object.keys(sectionsOf(doc.data as DocumentData) ?? {});
|
|
157
186
|
const unknownSections = ids.filter((id) => !all.includes(id));
|
|
187
|
+
const known = ids.filter((id) => all.includes(id));
|
|
188
|
+
if (known.length === 0) {
|
|
189
|
+
// The outline, or a read naming only ids the document lacks: no text to
|
|
190
|
+
// carry, so no empty data.sections beside the ids, which read as a
|
|
191
|
+
// document with no sections (PRO-2800).
|
|
192
|
+
const { data: _data, ...meta } = cut.doc;
|
|
193
|
+
return toolResult({
|
|
194
|
+
...meta,
|
|
195
|
+
outline: cut.omittedSections,
|
|
196
|
+
...(unknownSections.length > 0 ? { unknownSections } : {}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
158
199
|
return toolResult({
|
|
159
200
|
...cut.doc,
|
|
160
201
|
omittedSections: cut.omittedSections,
|
package/src/tools/setup.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { DEPLOYMENT_NOTES_MAX_LENGTH, REALITY_DERIVED_DOCUMENT_TYPES } from '@taiga/types';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import {
|
|
4
|
+
import type { Product } from '@taiga/types';
|
|
5
|
+
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
5
6
|
import { OPERATE, OPERATE_SETTING, READ_ONLY, WRITE } from './annotations.js';
|
|
6
|
-
import { isImportedProduct
|
|
7
|
-
import {
|
|
7
|
+
import { isImportedProduct } from './catalogue.js';
|
|
8
|
+
import { runTool, toolError, toolResult } from './errors.js';
|
|
9
|
+
import { setupReadiness } from './readiness.js';
|
|
8
10
|
import { withStop } from './hints.js';
|
|
9
11
|
import { idInput, productId } from './inputs.js';
|
|
10
12
|
import { pickFields } from './summarize.js';
|
|
@@ -18,8 +20,6 @@ import { pickFields } from './summarize.js';
|
|
|
18
20
|
* a product stay in the web.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
|
-
type Row = Record<string, unknown>;
|
|
22
|
-
|
|
23
23
|
const PRODUCT_FIELDS = [
|
|
24
24
|
'id',
|
|
25
25
|
'name',
|
|
@@ -63,42 +63,25 @@ const MISSING_DOCUMENTS_HOW =
|
|
|
63
63
|
'specification from the Discovery conversation in the web and the rest from taiga_generate_product_documents. ' +
|
|
64
64
|
'A draft is published with taiga_publish_product_document.';
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
type ProductRow = WithoutTenant<Product>;
|
|
67
|
+
|
|
68
|
+
/** A product row the model can act on: how it started, and while in setup what it still needs. */
|
|
69
|
+
async function productRow(client: TaigaClient, product: ProductRow) {
|
|
68
70
|
const { settings: _settings, ...rest } = pickFields([product], PRODUCT_FIELDS)[0]!;
|
|
69
|
-
const
|
|
71
|
+
const readiness = await setupReadiness(client, product);
|
|
70
72
|
return {
|
|
71
73
|
...rest,
|
|
72
|
-
projectType: isImportedProduct(product
|
|
73
|
-
...(
|
|
74
|
+
projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
|
|
75
|
+
...(readiness ? { readiness } : {}),
|
|
74
76
|
};
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
/**
|
|
78
|
-
* The product with its discoveryStep. The API derives it on reads only, not
|
|
79
|
-
* on the answer to a write (products.ts), and a draft started from scratch
|
|
80
|
-
* needs it to say what comes next; an imported one reads its import status
|
|
81
|
-
* instead. Best effort: a failed read leaves the product without a nextStep
|
|
82
|
-
* rather than failing the write that already happened.
|
|
83
|
-
*/
|
|
84
|
-
async function withDiscoveryStep(client: TaigaClient, product: Row): Promise<Row> {
|
|
85
|
-
if (product.status !== 'draft' || 'discoveryStep' in product || isImportedProduct(product as ProductSettingsView)) {
|
|
86
|
-
return product;
|
|
87
|
-
}
|
|
88
|
-
try {
|
|
89
|
-
return (await client.getProduct(String(product.id))).data as Row;
|
|
90
|
-
} catch (error) {
|
|
91
|
-
if (error instanceof TaigaApiError || isNetworkFailure(error)) return product;
|
|
92
|
-
throw error;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
79
|
/**
|
|
97
80
|
* A half turned off with nothing said about how it is done today. Allowed (the
|
|
98
81
|
* web asks for the sentence but does not insist), so a warning rather than a
|
|
99
82
|
* refusal: plans then design against a gap.
|
|
100
83
|
*/
|
|
101
|
-
function missingNotesWarning(product:
|
|
84
|
+
function missingNotesWarning(product: ProductRow): string[] {
|
|
102
85
|
const off = product.ownsInfrastructure === false || product.ownsDeployment === false;
|
|
103
86
|
const notes = typeof product.deploymentNotes === 'string' ? product.deploymentNotes.trim() : '';
|
|
104
87
|
return off && !notes
|
|
@@ -144,7 +127,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
144
127
|
},
|
|
145
128
|
(params) =>
|
|
146
129
|
runTool(async () =>
|
|
147
|
-
toolResult(pickFields([(await client.createFactory({ name: params.name })).data
|
|
130
|
+
toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0])
|
|
148
131
|
)
|
|
149
132
|
);
|
|
150
133
|
|
|
@@ -161,10 +144,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
161
144
|
(params) =>
|
|
162
145
|
runTool(async () =>
|
|
163
146
|
toolResult(
|
|
164
|
-
pickFields(
|
|
165
|
-
[(await client.updateFactory(params.factoryId, { name: params.name })).data as Row],
|
|
166
|
-
FACTORY_FIELDS
|
|
167
|
-
)[0]
|
|
147
|
+
pickFields([(await client.updateFactory(params.factoryId, { name: params.name })).data], FACTORY_FIELDS)[0]
|
|
168
148
|
)
|
|
169
149
|
)
|
|
170
150
|
);
|
|
@@ -193,8 +173,8 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
193
173
|
'Move a product to another factory. Access follows the factory, so this changes who can reach the ' +
|
|
194
174
|
"product (taiga_preview_product_move says who); the product's own knowledge files are re-filed onto " +
|
|
195
175
|
'the new factory, and factory knowledge and instructions stay with the factory that owns them. Refused ' +
|
|
196
|
-
'(
|
|
197
|
-
'interrupts that work.',
|
|
176
|
+
'(WORK_IN_FLIGHT) while a plan or build is in flight. Then ask the person: stopLine: true stops the ' +
|
|
177
|
+
'line first and then moves, which interrupts that work.',
|
|
198
178
|
inputSchema: z.object({
|
|
199
179
|
productId,
|
|
200
180
|
factoryId: factoryId.describe('The factory to move it to'),
|
|
@@ -207,12 +187,30 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
207
187
|
},
|
|
208
188
|
(params) =>
|
|
209
189
|
runTool(async () => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
190
|
+
let moved;
|
|
191
|
+
try {
|
|
192
|
+
moved = await client.moveProduct(params.productId, {
|
|
193
|
+
factoryId: params.factoryId,
|
|
194
|
+
...(params.stopLine ? { stopLine: true } : {}),
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// The API's own words say "Stop the line, then move the product",
|
|
198
|
+
// which reads as an instruction to the agent; whether to interrupt
|
|
199
|
+
// the work is the person's call (PRO-2800).
|
|
200
|
+
if (error instanceof TaigaApiError && error.code === 'WORK_IN_FLIGHT') {
|
|
201
|
+
return toolError(
|
|
202
|
+
'WORK_IN_FLIGHT',
|
|
203
|
+
// The API's first sentence says what is in flight; its second ("Stop the line, then move the product")
|
|
204
|
+
// is the instruction this replaces.
|
|
205
|
+
`${withStop((error.apiMessage ?? 'Work is in flight on this product').split(/(?<=[.!?])\s/)[0]!)} Moving it now means stopping ` +
|
|
206
|
+
'the line, which interrupts that work: ask the person, and pass stopLine: true only if they say so.',
|
|
207
|
+
error.details
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
214
212
|
return toolResult({
|
|
215
|
-
product: await productRow(client, moved.data
|
|
213
|
+
product: await productRow(client, moved.data),
|
|
216
214
|
changed: moved.changed,
|
|
217
215
|
previousFactoryId: moved.previousFactoryId,
|
|
218
216
|
lineStopped: moved.lineStopped,
|
|
@@ -260,7 +258,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
260
258
|
...(params.ownsDeployment !== undefined ? { ownsDeployment: params.ownsDeployment } : {}),
|
|
261
259
|
...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
|
|
262
260
|
})
|
|
263
|
-
).data
|
|
261
|
+
).data;
|
|
264
262
|
return toolResult(await productRow(client, product));
|
|
265
263
|
})
|
|
266
264
|
);
|
|
@@ -297,7 +295,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
297
295
|
}
|
|
298
296
|
const contradiction = notesWithNothingToDescribe(params);
|
|
299
297
|
if (contradiction) return contradiction;
|
|
300
|
-
const updated = (await client.updateProduct(id, changes)).data
|
|
298
|
+
const updated = (await client.updateProduct(id, changes)).data;
|
|
301
299
|
const warnings = missingNotesWarning(updated);
|
|
302
300
|
return toolResult({ ...(await productRow(client, updated)), ...(warnings.length > 0 ? { warnings } : {}) });
|
|
303
301
|
})
|
|
@@ -347,11 +345,11 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
347
345
|
(params) =>
|
|
348
346
|
runTool(async () => {
|
|
349
347
|
try {
|
|
350
|
-
return toolResult(await productRow(client, (await client.completeSetup(params.productId)).data
|
|
348
|
+
return toolResult(await productRow(client, (await client.completeSetup(params.productId)).data));
|
|
351
349
|
} catch (error) {
|
|
352
350
|
// The refusal names what is missing; it does not say how a missing
|
|
353
351
|
// document comes to exist, which is what the agent asks next.
|
|
354
|
-
if (!(error instanceof TaigaApiError) || error.details?.reason !== 'documents_incomplete') throw error;
|
|
352
|
+
if (!(error instanceof TaigaApiError) || error.details?.['reason'] !== 'documents_incomplete') throw error;
|
|
355
353
|
return toolError(
|
|
356
354
|
error.code ?? 'CONFLICT',
|
|
357
355
|
`${withStop(error.apiMessage ?? 'Required documents are not published yet')} ${MISSING_DOCUMENTS_HOW}`,
|
|
@@ -372,9 +370,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
372
370
|
annotations: OPERATE,
|
|
373
371
|
},
|
|
374
372
|
(params) =>
|
|
375
|
-
runTool(async () =>
|
|
376
|
-
toolResult(await productRow(client, (await client.reopenSetup(params.productId)).data as Row))
|
|
377
|
-
)
|
|
373
|
+
runTool(async () => toolResult(await productRow(client, (await client.reopenSetup(params.productId)).data)))
|
|
378
374
|
);
|
|
379
375
|
|
|
380
376
|
if (!client.hasCapability('products:read')) return;
|
|
@@ -396,8 +392,8 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
396
392
|
},
|
|
397
393
|
(params) =>
|
|
398
394
|
runTool(async () => {
|
|
399
|
-
const product = (await client.getProduct(params.productId)).data
|
|
400
|
-
if (!isImportedProduct(product
|
|
395
|
+
const product = (await client.getProduct(params.productId)).data;
|
|
396
|
+
if (!isImportedProduct(product)) {
|
|
401
397
|
return toolError(
|
|
402
398
|
'CONFLICT',
|
|
403
399
|
'This product was started from scratch, so it has no import: the choice is made when a product is created.'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { pickFields } from './summarize.js';
|
|
3
|
+
|
|
4
|
+
describe('pickFields', () => {
|
|
5
|
+
const rows: { id: string; name?: string; content?: string }[] = [
|
|
6
|
+
{ id: 'a', name: 'A', content: 'the prose a list leaves out' },
|
|
7
|
+
{ id: 'b' },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
it('keeps the named fields and leaves out one an item does not carry', () => {
|
|
11
|
+
expect(pickFields(rows, ['id', 'name'])).toEqual([{ id: 'a', name: 'A' }, { id: 'b' }]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('refuses a field the row type does not have', () => {
|
|
15
|
+
// The *_FIELDS lists are checked against their rows, so a misspelled field
|
|
16
|
+
// fails to compile instead of silently picking nothing (tsc covers tests).
|
|
17
|
+
// @ts-expect-error -- 'nmae' is not a field of the row.
|
|
18
|
+
expect(pickFields([{ id: 'a', name: 'A' }], ['id', 'nmae'])).toEqual([{ id: 'a' }]);
|
|
19
|
+
});
|
|
20
|
+
});
|
package/src/tools/summarize.ts
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* fields a model needs to pick an item) and get tools return everything;
|
|
4
4
|
* this is how a list drops the prose and the provenance trees.
|
|
5
5
|
*/
|
|
6
|
-
export function pickFields<T extends
|
|
7
|
-
items: T[],
|
|
8
|
-
fields: readonly
|
|
9
|
-
):
|
|
6
|
+
export function pickFields<T extends object, K extends keyof T>(
|
|
7
|
+
items: readonly T[],
|
|
8
|
+
fields: readonly K[]
|
|
9
|
+
): Partial<Pick<T, K>>[] {
|
|
10
10
|
return items.map((item) => {
|
|
11
|
-
|
|
11
|
+
// Partial: a field the item does not carry is left out, not set to undefined.
|
|
12
|
+
const picked: Partial<Pick<T, K>> = {};
|
|
12
13
|
for (const field of fields) {
|
|
13
14
|
if (field in item) picked[field] = item[field];
|
|
14
15
|
}
|
|
@@ -26,7 +27,7 @@ export function page<T>(data: T[], nextCursor: string | null | undefined): { dat
|
|
|
26
27
|
* carry, so "done with two unmet" shows in a list without reading the item.
|
|
27
28
|
* Null while it has none (an unplanned initiative).
|
|
28
29
|
*/
|
|
29
|
-
export function criteriaCounts(item:
|
|
30
|
+
export function criteriaCounts(item: { acceptanceCriteria?: unknown }): { met: number; total: number } | null {
|
|
30
31
|
const criteria = item.acceptanceCriteria;
|
|
31
32
|
if (!Array.isArray(criteria) || criteria.length === 0) return null;
|
|
32
33
|
const met = criteria.filter((c) => typeof c === 'object' && c !== null && (c as { met?: unknown }).met === true);
|
|
@@ -38,7 +39,10 @@ export function criteriaCounts(item: Record<string, unknown>): { met: number; to
|
|
|
38
39
|
* does not carry `acceptanceCriteria` at all gets no count rather than a
|
|
39
40
|
* null that would read as "has none".
|
|
40
41
|
*/
|
|
41
|
-
export function itemRows
|
|
42
|
+
export function itemRows<T extends { acceptanceCriteria?: unknown }, K extends keyof T>(
|
|
43
|
+
items: readonly T[],
|
|
44
|
+
fields: readonly K[]
|
|
45
|
+
): (Partial<Pick<T, K>> & { criteria?: { met: number; total: number } | null })[] {
|
|
42
46
|
return pickFields(items, fields).map((row, index) =>
|
|
43
47
|
'acceptanceCriteria' in items[index]! ? { ...row, criteria: criteriaCounts(items[index]!) } : row
|
|
44
48
|
);
|
|
@@ -53,18 +57,18 @@ const SEARCH_PAGE_LIMIT = 10;
|
|
|
53
57
|
* told the person's word for a thing ("the Sandbox factory") otherwise pages
|
|
54
58
|
* the whole list to find its ID. `more` is true when it stopped before the end.
|
|
55
59
|
*/
|
|
56
|
-
export async function searchPages(
|
|
57
|
-
fetchPage: (cursor: string | undefined) => Promise<{ data:
|
|
60
|
+
export async function searchPages<T>(
|
|
61
|
+
fetchPage: (cursor: string | undefined) => Promise<{ data: T[]; nextCursor: string | null }>,
|
|
58
62
|
query: string,
|
|
59
|
-
text: (row:
|
|
63
|
+
text: (row: T) => unknown[],
|
|
60
64
|
limit: number
|
|
61
|
-
): Promise<{ rows:
|
|
65
|
+
): Promise<{ rows: T[]; more: boolean }> {
|
|
62
66
|
const needle = query.toLowerCase();
|
|
63
|
-
const rows:
|
|
67
|
+
const rows: T[] = [];
|
|
64
68
|
let cursor: string | undefined;
|
|
65
69
|
for (let index = 0; index < SEARCH_PAGE_LIMIT; index += 1) {
|
|
66
70
|
const result = await fetchPage(cursor);
|
|
67
|
-
const page = result.data
|
|
71
|
+
const page = result.data;
|
|
68
72
|
for (const [at, row] of page.entries()) {
|
|
69
73
|
if (!text(row).some((value) => typeof value === 'string' && value.toLowerCase().includes(needle))) continue;
|
|
70
74
|
rows.push(row);
|
package/src/tools/wait.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { BUILD_RUN_TERMINAL_STATUSES, resolveAutoBuild } from '@taiga/types';
|
|
3
|
+
import type { BacklogItem, BacklogItemLifecycle, BuildRunWithContext } from '@taiga/types';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { idInput } from './inputs.js';
|
|
5
|
-
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
6
|
+
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
6
7
|
import { READ_ONLY } from './annotations.js';
|
|
7
8
|
import { isNetworkFailure, runTool, toolResult } from './errors.js';
|
|
8
9
|
import { SETTLED_JOB_STATUSES, jobReport } from './jobs.js';
|
|
9
10
|
import { criteriaCounts, pickFields } from './summarize.js';
|
|
11
|
+
import { situationOf } from './backlogs.js';
|
|
12
|
+
import { readableRun } from './builds.js';
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* Waiting without a timer. A model in an editor has no clock: to learn that
|
|
@@ -43,7 +46,11 @@ const timeoutSeconds = z
|
|
|
43
46
|
'many stop a call at 60 seconds.'
|
|
44
47
|
);
|
|
45
48
|
|
|
46
|
-
type
|
|
49
|
+
type Item = WithoutTenant<BacklogItem>;
|
|
50
|
+
type Run = WithoutTenant<BuildRunWithContext>;
|
|
51
|
+
|
|
52
|
+
/** The pull-request fields an initiative's latest run and a build run both carry. */
|
|
53
|
+
type PullRequestFields = Pick<Run, 'prUrl' | 'prMerged' | 'prState'>;
|
|
47
54
|
|
|
48
55
|
/** Why a wait ended: the condition held, it can no longer hold on its own, or time ran out. */
|
|
49
56
|
type Verdict = { reached: true } | { reached: false; stoppedBecause: string } | null;
|
|
@@ -91,17 +98,17 @@ function isTransient(error: unknown): boolean {
|
|
|
91
98
|
* it, which also keeps a client that resets its timeout on progress from
|
|
92
99
|
* cutting the wait short.
|
|
93
100
|
*/
|
|
94
|
-
async function waitFor(
|
|
95
|
-
read: (signal: AbortSignal) => Promise<
|
|
96
|
-
judge: (now:
|
|
101
|
+
async function waitFor<T>(
|
|
102
|
+
read: (signal: AbortSignal) => Promise<T>,
|
|
103
|
+
judge: (now: T, first: T) => Verdict,
|
|
97
104
|
timeoutMs: number,
|
|
98
105
|
options: WaitOptions,
|
|
99
106
|
progress: Progress
|
|
100
107
|
) {
|
|
101
108
|
const started = Date.now();
|
|
102
109
|
const deadline = started + timeoutMs;
|
|
103
|
-
let first:
|
|
104
|
-
let now:
|
|
110
|
+
let first: T | null = null;
|
|
111
|
+
let now: T | null = null;
|
|
105
112
|
let verdict: Verdict = null;
|
|
106
113
|
let failures = 0;
|
|
107
114
|
for (let attempt = 0; !progress.signal.aborted; attempt += 1) {
|
|
@@ -156,7 +163,7 @@ const SETTLED_RUN: ReadonlySet<string> = new Set(BUILD_RUN_TERMINAL_STATUSES);
|
|
|
156
163
|
* is open; the mirrored state and the merged latch say which. An unknown
|
|
157
164
|
* state (null, just opened) counts as open.
|
|
158
165
|
*/
|
|
159
|
-
function prIsOpen(run:
|
|
166
|
+
function prIsOpen(run: PullRequestFields): boolean {
|
|
160
167
|
return typeof run.prUrl === 'string' && run.prUrl !== '' && run.prMerged !== true && run.prState !== 'closed';
|
|
161
168
|
}
|
|
162
169
|
|
|
@@ -167,11 +174,13 @@ function prIsOpen(run: Row): boolean {
|
|
|
167
174
|
const ITEM_CONDITIONS = ['changed', 'planned', 'your_turn', 'pr_open', 'run_settled', 'done'] as const;
|
|
168
175
|
type ItemCondition = (typeof ITEM_CONDITIONS)[number];
|
|
169
176
|
|
|
170
|
-
|
|
171
|
-
|
|
177
|
+
// The API sends a lifecycle on every backlog item (backlogItemSchema); a
|
|
178
|
+
// response without one reads as no lifecycle rather than failing the wait.
|
|
179
|
+
const latestBuildOf = (item: Item) => item.lifecycle?.latestBuild ?? null;
|
|
180
|
+
const lifecycleOf = (item: Item): Partial<BacklogItemLifecycle> => item.lifecycle ?? {};
|
|
172
181
|
|
|
173
182
|
/** What "changed" compares: where the item is, whose turn it is and how its latest run stands. */
|
|
174
|
-
function itemSignature(item:
|
|
183
|
+
function itemSignature(item: Item): string {
|
|
175
184
|
const lifecycle = lifecycleOf(item);
|
|
176
185
|
const run = latestBuildOf(item);
|
|
177
186
|
return JSON.stringify([
|
|
@@ -209,7 +218,7 @@ interface GateDefault {
|
|
|
209
218
|
* the gate cannot be resolved (no gate of its own, and the product's default
|
|
210
219
|
* unreadable) the answer is unknown, and the wait says so rather than guess.
|
|
211
220
|
*/
|
|
212
|
-
function personTurn(item:
|
|
221
|
+
function personTurn(item: Item, gate: GateDefault): 'yes' | 'no' | 'unknown' {
|
|
213
222
|
const lifecycle = lifecycleOf(item);
|
|
214
223
|
if (lifecycle.turn !== 'you') return 'no';
|
|
215
224
|
if (lifecycle.cta !== 'start_build') return 'yes';
|
|
@@ -219,7 +228,7 @@ function personTurn(item: Row, gate: GateDefault): 'yes' | 'no' | 'unknown' {
|
|
|
219
228
|
return resolveAutoBuild({ initiative: own, product: product ?? true }) ? 'no' : 'yes';
|
|
220
229
|
}
|
|
221
230
|
|
|
222
|
-
function itemHolds(until: ItemCondition, item:
|
|
231
|
+
function itemHolds(until: ItemCondition, item: Item, first: Item, gate: GateDefault): boolean {
|
|
223
232
|
const lifecycle = lifecycleOf(item);
|
|
224
233
|
const run = latestBuildOf(item);
|
|
225
234
|
switch (until) {
|
|
@@ -239,7 +248,7 @@ function itemHolds(until: ItemCondition, item: Row, first: Row, gate: GateDefaul
|
|
|
239
248
|
}
|
|
240
249
|
|
|
241
250
|
function judgeItem(until: ItemCondition, gate: GateDefault) {
|
|
242
|
-
return (item:
|
|
251
|
+
return (item: Item, first: Item): Verdict => {
|
|
243
252
|
if (itemHolds(until, item, first, gate)) return { reached: true };
|
|
244
253
|
// "changed" is how a model waits for a person to act on a flagged or
|
|
245
254
|
// finished initiative, so none of the stops below apply to it.
|
|
@@ -276,9 +285,11 @@ function judgeItem(until: ItemCondition, gate: GateDefault) {
|
|
|
276
285
|
}
|
|
277
286
|
|
|
278
287
|
/** The initiative as a wait reports it: where it is, whose turn, its latest run and its criteria. */
|
|
279
|
-
function itemReport(item:
|
|
288
|
+
function itemReport(item: Item) {
|
|
280
289
|
const run = latestBuildOf(item);
|
|
290
|
+
const situation = situationOf(item);
|
|
281
291
|
return {
|
|
292
|
+
...(situation ? { situation } : {}),
|
|
282
293
|
...pickFields(
|
|
283
294
|
[item],
|
|
284
295
|
['id', 'title', 'state', 'planningStatus', 'needsHumanCause', 'needsHumanDetail', 'autoBuild']
|
|
@@ -313,14 +324,16 @@ const RUN_REPORT_FIELDS = [
|
|
|
313
324
|
'prState',
|
|
314
325
|
'prDraft',
|
|
315
326
|
'prMerged',
|
|
327
|
+
'prNumber',
|
|
328
|
+
'baseBranch',
|
|
316
329
|
'actions',
|
|
317
330
|
] as const;
|
|
318
331
|
|
|
319
|
-
const runSignature = (run:
|
|
332
|
+
const runSignature = (run: Run) =>
|
|
320
333
|
JSON.stringify([run.status, run.completedSteps, run.prUrl, run.prState, run.prMerged]);
|
|
321
334
|
|
|
322
335
|
function judgeBuild(until: BuildCondition) {
|
|
323
|
-
return (run:
|
|
336
|
+
return (run: Run, first: Run): Verdict => {
|
|
324
337
|
const holds =
|
|
325
338
|
until === 'changed'
|
|
326
339
|
? runSignature(run) !== runSignature(first)
|
|
@@ -341,7 +354,7 @@ function judgeBuild(until: BuildCondition) {
|
|
|
341
354
|
export function registerWaitTools(server: McpServer, client: TaigaClient, options: WaitOptions) {
|
|
342
355
|
if (client.hasCapability('backlog:read')) {
|
|
343
356
|
server.registerTool(
|
|
344
|
-
'
|
|
357
|
+
'taiga_wait_for_initiative',
|
|
345
358
|
{
|
|
346
359
|
title: 'Wait for an initiative',
|
|
347
360
|
description:
|
|
@@ -357,7 +370,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
|
|
|
357
370
|
'settled answers run_settled at once; item is null when not even the first read answered in time. ' +
|
|
358
371
|
'Planning takes minutes and a build up to an hour.',
|
|
359
372
|
inputSchema: z.object({
|
|
360
|
-
|
|
373
|
+
initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it'),
|
|
361
374
|
until: z.enum(ITEM_CONDITIONS).describe('What to wait for'),
|
|
362
375
|
timeoutSeconds,
|
|
363
376
|
}),
|
|
@@ -367,14 +380,14 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
|
|
|
367
380
|
runTool(async () => {
|
|
368
381
|
const gate: GateDefault = { product: undefined };
|
|
369
382
|
const read = async (signal: AbortSignal) => {
|
|
370
|
-
const item = (await client.getBacklogItem(params.
|
|
383
|
+
const item = (await client.getBacklogItem(params.initiativeId, signal)).data;
|
|
371
384
|
// The product's default, once, when this initiative has no gate of its own to answer with.
|
|
372
385
|
if (gate.product === undefined && typeof item.autoBuild !== 'boolean') {
|
|
373
386
|
gate.product = client.hasCapability('products:read')
|
|
374
387
|
? await client
|
|
375
388
|
.getProduct(String(item.productId), signal)
|
|
376
389
|
.then((p) => {
|
|
377
|
-
const value =
|
|
390
|
+
const value = p.data.autoBuild;
|
|
378
391
|
return typeof value === 'boolean' ? value : true;
|
|
379
392
|
})
|
|
380
393
|
// Not allowed, or gone: unknown for good. Anything transient
|
|
@@ -406,7 +419,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
|
|
|
406
419
|
|
|
407
420
|
if (client.hasCapability('products:read')) {
|
|
408
421
|
server.registerTool(
|
|
409
|
-
'
|
|
422
|
+
'taiga_wait_for_run',
|
|
410
423
|
{
|
|
411
424
|
title: 'Wait for a run',
|
|
412
425
|
description:
|
|
@@ -418,7 +431,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
|
|
|
418
431
|
'call again to keep waiting. Conditions are states: a run already settled answers settled at once; run ' +
|
|
419
432
|
'is null when not even the first read answered in time.',
|
|
420
433
|
inputSchema: z.object({
|
|
421
|
-
|
|
434
|
+
runId: idInput('run', 'taiga_list_runs gives it'),
|
|
422
435
|
until: z.enum(BUILD_CONDITIONS).describe('What to wait for'),
|
|
423
436
|
timeoutSeconds,
|
|
424
437
|
}),
|
|
@@ -427,7 +440,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
|
|
|
427
440
|
(params, ctx) =>
|
|
428
441
|
runTool(async () => {
|
|
429
442
|
const result = await waitFor(
|
|
430
|
-
async (signal) => (await client.getBuildRun(params.
|
|
443
|
+
async (signal) => (await client.getBuildRun(params.runId, signal)).data,
|
|
431
444
|
judgeBuild(params.until),
|
|
432
445
|
(params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
433
446
|
options,
|
|
@@ -441,7 +454,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
|
|
|
441
454
|
return toolResult({
|
|
442
455
|
until: params.until,
|
|
443
456
|
...outcome,
|
|
444
|
-
run: now ? pickFields([now], RUN_REPORT_FIELDS)[0] : null,
|
|
457
|
+
run: now ? readableRun(pickFields([now], RUN_REPORT_FIELDS)[0]!) : null,
|
|
445
458
|
});
|
|
446
459
|
})
|
|
447
460
|
);
|
|
@@ -466,7 +479,7 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
|
|
|
466
479
|
(params, ctx) =>
|
|
467
480
|
runTool(async () => {
|
|
468
481
|
const result = await waitFor(
|
|
469
|
-
async (signal) => (await client.getAgentJob(params.jobId, signal)).data
|
|
482
|
+
async (signal) => (await client.getAgentJob(params.jobId, signal)).data,
|
|
470
483
|
(job) => (SETTLED_JOB_STATUSES.has(String(job.status)) ? { reached: true } : null),
|
|
471
484
|
(params.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
472
485
|
options,
|
|
Binary file
|