@taiga-ai/mcp-server 0.18.0-dev.447 → 0.18.0-dev.448
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 +93 -37
- package/package.json +1 -1
- package/src/client.test.ts +2 -2
- package/src/client.ts +190 -145
- package/src/server.integration.test.ts +10 -9
- package/src/tools/autonomy.ts +2 -4
- package/src/tools/backlogs.ts +27 -30
- package/src/tools/builds.ts +14 -12
- package/src/tools/catalogue.test.ts +5 -5
- package/src/tools/context.ts +32 -30
- package/src/tools/documents.ts +8 -4
- package/src/tools/hints.ts +5 -2
- package/src/tools/inbox.ts +3 -5
- package/src/tools/jobs.ts +6 -5
- package/src/tools/knowledge.ts +4 -6
- package/src/tools/maintenance.ts +9 -14
- package/src/tools/markdown.ts +13 -5
- package/src/tools/observe.ts +17 -14
- package/src/tools/product-documents.ts +3 -5
- package/src/tools/products.ts +25 -22
- package/src/tools/prompts.ts +7 -13
- package/src/tools/queue.ts +36 -15
- package/src/tools/repositories.ts +7 -8
- package/src/tools/resources.ts +11 -14
- package/src/tools/sections.test.ts +3 -3
- package/src/tools/sections.ts +44 -15
- package/src/tools/setup.ts +21 -25
- package/src/tools/summarize.test.ts +20 -0
- package/src/tools/summarize.ts +17 -13
- package/src/tools/wait.ts +28 -21
- package/taiga-ai-mcp-server-0.18.0-dev.448.tgz +0 -0
- package/tsconfig.json +1 -5
- package/taiga-ai-mcp-server-0.18.0-dev.447.tgz +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import type {
|
|
3
|
+
import type { GithubRepository } from '@taiga/types';
|
|
4
|
+
import type { TaigaClient, WithoutTenant } from '../client.js';
|
|
4
5
|
import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
5
6
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
6
7
|
import { idInput, pagination, productId } from './inputs.js';
|
|
@@ -15,8 +16,6 @@ import { SEARCH_WITH_CURSOR, page, pickFields } from './summarize.js';
|
|
|
15
16
|
* repositories it can reach to a product is a separate act, per product.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
type Row = Record<string, unknown>;
|
|
19
|
-
|
|
20
19
|
const LINK_FIELDS = [
|
|
21
20
|
'id',
|
|
22
21
|
'provider',
|
|
@@ -81,7 +80,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
81
80
|
// The route answers one page of 100 and takes no cursor: past that, the list says it is cut.
|
|
82
81
|
const result = await client.listProductRepositories(params.productId);
|
|
83
82
|
return toolResult({
|
|
84
|
-
data: pickFields(result.data
|
|
83
|
+
data: pickFields(result.data, LINK_FIELDS),
|
|
85
84
|
truncated: result.nextCursor !== null,
|
|
86
85
|
});
|
|
87
86
|
})
|
|
@@ -135,7 +134,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
135
134
|
},
|
|
136
135
|
(params) =>
|
|
137
136
|
runTool(async () => {
|
|
138
|
-
const installations = (await client.listGithubInstallations()).data
|
|
137
|
+
const installations = (await client.listGithubInstallations()).data;
|
|
139
138
|
const accounts = pickFields(installations, INSTALLATION_FIELDS);
|
|
140
139
|
if (installations.length === 0) {
|
|
141
140
|
return toolError(
|
|
@@ -167,7 +166,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
167
166
|
// are read: a search answers its ranked matches whatever the page
|
|
168
167
|
// size, and several accounts add up.
|
|
169
168
|
const max = params.limit ?? 25;
|
|
170
|
-
const rows = (data:
|
|
169
|
+
const rows = (data: WithoutTenant<GithubRepository>[]) => pickFields(data, GITHUB_REPOSITORY_FIELDS);
|
|
171
170
|
if (chosen.length === 1) {
|
|
172
171
|
const result = await client.listInstallationRepositories(String(chosen[0]!.id), {
|
|
173
172
|
q: params.query,
|
|
@@ -227,7 +226,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
227
226
|
githubRepositoryId: params.githubRepositoryId,
|
|
228
227
|
...(params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}),
|
|
229
228
|
});
|
|
230
|
-
return toolResult(pickFields([linked.data
|
|
229
|
+
return toolResult(pickFields([linked.data], LINK_FIELDS)[0]);
|
|
231
230
|
})
|
|
232
231
|
);
|
|
233
232
|
|
|
@@ -265,7 +264,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
|
|
|
265
264
|
...(params.baseBranch !== undefined ? { baseBranch: params.baseBranch } : {}),
|
|
266
265
|
...(params.label !== undefined ? { label: params.label } : {}),
|
|
267
266
|
})
|
|
268
|
-
).data
|
|
267
|
+
).data;
|
|
269
268
|
return toolResult({
|
|
270
269
|
...pickFields([updated], LINK_FIELDS)[0],
|
|
271
270
|
baseBranchProtection: updated.baseBranchProtection ?? null,
|
package/src/tools/resources.ts
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
productDocumentName,
|
|
17
17
|
type ProductDocumentOrigin,
|
|
18
18
|
type ProductDocumentType,
|
|
19
|
-
type ProductSettingsView,
|
|
20
19
|
} from './catalogue.js';
|
|
21
20
|
import { productId as productIdInput } from './inputs.js';
|
|
22
21
|
import { mimeTypeOf, renderDocument } from './markdown.js';
|
|
@@ -69,9 +68,7 @@ async function served<T>(read: Promise<T>, miss: string): Promise<T> {
|
|
|
69
68
|
export function registerResources(server: McpServer, client: TaigaClient) {
|
|
70
69
|
if (client.hasCapability('tenant:documents:read')) {
|
|
71
70
|
const published = async () =>
|
|
72
|
-
(
|
|
73
|
-
(row) => row.isDraft === false
|
|
74
|
-
);
|
|
71
|
+
(await client.listTenantDocuments({ limit: 100 })).data.filter((row) => row.isDraft === false);
|
|
75
72
|
|
|
76
73
|
server.registerResource(
|
|
77
74
|
'organization-document',
|
|
@@ -95,7 +92,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
|
|
|
95
92
|
'tone of voice or the standing instructions. Sections rendered as headings.',
|
|
96
93
|
},
|
|
97
94
|
async (uri, variables) => {
|
|
98
|
-
const type = requireOneOf('Document type', String(variables
|
|
95
|
+
const type = requireOneOf('Document type', String(variables['type']), TENANT_DOCUMENT_TYPES);
|
|
99
96
|
try {
|
|
100
97
|
return contents(uri.href, (await client.getTenantDocument(type)).data);
|
|
101
98
|
} catch (error) {
|
|
@@ -127,7 +124,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
|
|
|
127
124
|
'section map, JSON otherwise (the read says which).',
|
|
128
125
|
},
|
|
129
126
|
async (uri, variables) => {
|
|
130
|
-
const factoryId = requireId('Factory ID', String(variables
|
|
127
|
+
const factoryId = requireId('Factory ID', String(variables['factoryId']));
|
|
131
128
|
const doc = await served(
|
|
132
129
|
client.getFactoryDocument(factoryId, FACTORY_DOCUMENT_TYPES[0]),
|
|
133
130
|
'This factory has not published instructions, or the key cannot see the factory'
|
|
@@ -147,7 +144,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
|
|
|
147
144
|
client.getProductDocument(productId, type, origin),
|
|
148
145
|
`No published ${origin} ${type} for this product, or the key cannot see it; taiga_list_product_documents shows what exists`
|
|
149
146
|
);
|
|
150
|
-
return contents(uri.href, { name: productDocumentName(type), ...
|
|
147
|
+
return contents(uri.href, { name: productDocumentName(type), ...result.data });
|
|
151
148
|
};
|
|
152
149
|
|
|
153
150
|
// The timeline template first: the SDK tries templates in registration
|
|
@@ -162,9 +159,9 @@ export function registerResources(server: McpServer, client: TaigaClient) {
|
|
|
162
159
|
'Markdown for a section map, JSON otherwise (the read says which).',
|
|
163
160
|
},
|
|
164
161
|
async (uri, variables) => {
|
|
165
|
-
const productId = requireId('Product ID', String(variables
|
|
166
|
-
const type = documentType(String(variables
|
|
167
|
-
const origin = requireOneOf('Origin', String(variables
|
|
162
|
+
const productId = requireId('Product ID', String(variables['productId']));
|
|
163
|
+
const type = documentType(String(variables['type']));
|
|
164
|
+
const origin = requireOneOf('Origin', String(variables['origin']), PRODUCT_DOCUMENT_ORIGINS);
|
|
168
165
|
return read(uri, productId, type, origin);
|
|
169
166
|
}
|
|
170
167
|
);
|
|
@@ -181,14 +178,14 @@ export function registerResources(server: McpServer, client: TaigaClient) {
|
|
|
181
178
|
'otherwise (the read says which).',
|
|
182
179
|
},
|
|
183
180
|
async (uri, variables) => {
|
|
184
|
-
const productId = requireId('Product ID', String(variables
|
|
185
|
-
const type = documentType(String(variables
|
|
181
|
+
const productId = requireId('Product ID', String(variables['productId']));
|
|
182
|
+
const type = documentType(String(variables['type']));
|
|
186
183
|
const product = (
|
|
187
184
|
await served(
|
|
188
185
|
client.getProduct(productId),
|
|
189
186
|
'No product with that ID the key can see; taiga_list_products shows them'
|
|
190
187
|
)
|
|
191
|
-
).data
|
|
188
|
+
).data;
|
|
192
189
|
return read(uri, productId, type, defaultOriginFor(product, type));
|
|
193
190
|
}
|
|
194
191
|
);
|
|
@@ -206,7 +203,7 @@ export function registerResources(server: McpServer, client: TaigaClient) {
|
|
|
206
203
|
mimeType: 'application/json',
|
|
207
204
|
},
|
|
208
205
|
async (uri, variables) => {
|
|
209
|
-
const itemId = requireId('Backlog item ID', String(variables
|
|
206
|
+
const itemId = requireId('Backlog item ID', String(variables['itemId']));
|
|
210
207
|
const item = (
|
|
211
208
|
await served(
|
|
212
209
|
client.getBacklogItem(itemId),
|
|
@@ -50,12 +50,12 @@ describe('applySectionEdit', () => {
|
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
it('turns a bare-string or array-valued section into an object with the new text', () => {
|
|
53
|
-
expect(applySectionEdit(sections, 'scope', 'new')
|
|
54
|
-
expect(applySectionEdit(sections, 'list', 'new')
|
|
53
|
+
expect(applySectionEdit(sections, 'scope', 'new')['scope']).toEqual({ content: 'new' });
|
|
54
|
+
expect(applySectionEdit(sections, 'list', 'new')['list']).toEqual({ content: 'new' });
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
it('adds a section that did not exist', () => {
|
|
58
|
-
expect(applySectionEdit(sections, 'retention', 'keep 90 days')
|
|
58
|
+
expect(applySectionEdit(sections, 'retention', 'keep 90 days')['retention']).toEqual({ content: 'keep 90 days' });
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it('does not mutate its input', () => {
|
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.
|
|
@@ -150,10 +179,10 @@ 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));
|
|
158
187
|
return toolResult({
|
|
159
188
|
...cut.doc,
|
package/src/tools/setup.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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, setupNextStep
|
|
7
|
+
import { isImportedProduct, setupNextStep } from './catalogue.js';
|
|
7
8
|
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
8
9
|
import { withStop } from './hints.js';
|
|
9
10
|
import { idInput, productId } from './inputs.js';
|
|
@@ -18,8 +19,6 @@ import { pickFields } from './summarize.js';
|
|
|
18
19
|
* a product stay in the web.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
|
-
type Row = Record<string, unknown>;
|
|
22
|
-
|
|
23
22
|
const PRODUCT_FIELDS = [
|
|
24
23
|
'id',
|
|
25
24
|
'name',
|
|
@@ -63,13 +62,15 @@ const MISSING_DOCUMENTS_HOW =
|
|
|
63
62
|
'specification from the Discovery conversation in the web and the rest from taiga_generate_product_documents. ' +
|
|
64
63
|
'A draft is published with taiga_publish_product_document.';
|
|
65
64
|
|
|
65
|
+
type ProductRow = WithoutTenant<Product>;
|
|
66
|
+
|
|
66
67
|
/** A product row the model can act on, saying how it started as the other product tools say it. */
|
|
67
|
-
async function productRow(client: TaigaClient, product:
|
|
68
|
+
async function productRow(client: TaigaClient, product: ProductRow) {
|
|
68
69
|
const { settings: _settings, ...rest } = pickFields([product], PRODUCT_FIELDS)[0]!;
|
|
69
70
|
const nextStep = setupNextStep(await withDiscoveryStep(client, product));
|
|
70
71
|
return {
|
|
71
72
|
...rest,
|
|
72
|
-
projectType: isImportedProduct(product
|
|
73
|
+
projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
|
|
73
74
|
...(nextStep ? { nextStep } : {}),
|
|
74
75
|
};
|
|
75
76
|
}
|
|
@@ -81,12 +82,12 @@ async function productRow(client: TaigaClient, product: Row): Promise<Row> {
|
|
|
81
82
|
* instead. Best effort: a failed read leaves the product without a nextStep
|
|
82
83
|
* rather than failing the write that already happened.
|
|
83
84
|
*/
|
|
84
|
-
async function withDiscoveryStep(client: TaigaClient, product:
|
|
85
|
-
if (product.status !== 'draft' || 'discoveryStep' in product || isImportedProduct(product
|
|
85
|
+
async function withDiscoveryStep(client: TaigaClient, product: ProductRow): Promise<ProductRow> {
|
|
86
|
+
if (product.status !== 'draft' || 'discoveryStep' in product || isImportedProduct(product)) {
|
|
86
87
|
return product;
|
|
87
88
|
}
|
|
88
89
|
try {
|
|
89
|
-
return (await client.getProduct(String(product.id))).data
|
|
90
|
+
return (await client.getProduct(String(product.id))).data;
|
|
90
91
|
} catch (error) {
|
|
91
92
|
if (error instanceof TaigaApiError || isNetworkFailure(error)) return product;
|
|
92
93
|
throw error;
|
|
@@ -98,7 +99,7 @@ async function withDiscoveryStep(client: TaigaClient, product: Row): Promise<Row
|
|
|
98
99
|
* web asks for the sentence but does not insist), so a warning rather than a
|
|
99
100
|
* refusal: plans then design against a gap.
|
|
100
101
|
*/
|
|
101
|
-
function missingNotesWarning(product:
|
|
102
|
+
function missingNotesWarning(product: ProductRow): string[] {
|
|
102
103
|
const off = product.ownsInfrastructure === false || product.ownsDeployment === false;
|
|
103
104
|
const notes = typeof product.deploymentNotes === 'string' ? product.deploymentNotes.trim() : '';
|
|
104
105
|
return off && !notes
|
|
@@ -144,7 +145,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
144
145
|
},
|
|
145
146
|
(params) =>
|
|
146
147
|
runTool(async () =>
|
|
147
|
-
toolResult(pickFields([(await client.createFactory({ name: params.name })).data
|
|
148
|
+
toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0])
|
|
148
149
|
)
|
|
149
150
|
);
|
|
150
151
|
|
|
@@ -161,10 +162,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
161
162
|
(params) =>
|
|
162
163
|
runTool(async () =>
|
|
163
164
|
toolResult(
|
|
164
|
-
pickFields(
|
|
165
|
-
[(await client.updateFactory(params.factoryId, { name: params.name })).data as Row],
|
|
166
|
-
FACTORY_FIELDS
|
|
167
|
-
)[0]
|
|
165
|
+
pickFields([(await client.updateFactory(params.factoryId, { name: params.name })).data], FACTORY_FIELDS)[0]
|
|
168
166
|
)
|
|
169
167
|
)
|
|
170
168
|
);
|
|
@@ -212,7 +210,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
212
210
|
...(params.stopLine ? { stopLine: true } : {}),
|
|
213
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);
|