@taiga-ai/mcp-server 0.18.0-dev.447 → 0.18.0-dev.449
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.449.tgz +0 -0
- package/tsconfig.json +1 -5
- package/taiga-ai-mcp-server-0.18.0-dev.447.tgz +0 -0
package/src/tools/knowledge.ts
CHANGED
|
@@ -15,8 +15,6 @@ import { page, pickFields } from './summarize.js';
|
|
|
15
15
|
* admin, which the API enforces.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
type Row = Record<string, unknown>;
|
|
19
|
-
|
|
20
18
|
const KNOWLEDGE_FIELDS = [
|
|
21
19
|
'id',
|
|
22
20
|
'factoryId',
|
|
@@ -123,7 +121,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
|
|
|
123
121
|
? { factoryId: params.factoryId, productId: null }
|
|
124
122
|
: { factoryId: null, productId: null };
|
|
125
123
|
const result = await client.listKnowledge({ ...scope, limit: params.limit, cursor: params.cursor });
|
|
126
|
-
return toolResult(page(pickFields(result.data
|
|
124
|
+
return toolResult(page(pickFields(result.data, KNOWLEDGE_FIELDS), result.nextCursor));
|
|
127
125
|
})
|
|
128
126
|
);
|
|
129
127
|
|
|
@@ -143,7 +141,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
|
|
|
143
141
|
(params) =>
|
|
144
142
|
runTool(async () => {
|
|
145
143
|
const file = await client.getKnowledge(params.knowledgeId);
|
|
146
|
-
const row = pickFields([file.data
|
|
144
|
+
const row = pickFields([file.data], KNOWLEDGE_FIELDS)[0]!;
|
|
147
145
|
if (row.status !== 'ready') {
|
|
148
146
|
return toolResult({
|
|
149
147
|
...row,
|
|
@@ -213,7 +211,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
|
|
|
213
211
|
// there: the flag says so, so that file is not taken for a new one
|
|
214
212
|
// (and deleted as one in a clean-up).
|
|
215
213
|
return toolResult({
|
|
216
|
-
...pickFields([result.data
|
|
214
|
+
...pickFields([result.data], KNOWLEDGE_FIELDS)[0],
|
|
217
215
|
deduplicated: result.deduplicated === true,
|
|
218
216
|
});
|
|
219
217
|
})
|
|
@@ -231,7 +229,7 @@ export function registerKnowledgeTools(server: McpServer, client: TaigaClient) {
|
|
|
231
229
|
},
|
|
232
230
|
(params) =>
|
|
233
231
|
runTool(async () =>
|
|
234
|
-
toolResult(pickFields([(await client.retryKnowledge(params.knowledgeId)).data
|
|
232
|
+
toolResult(pickFields([(await client.retryKnowledge(params.knowledgeId)).data], KNOWLEDGE_FIELDS)[0])
|
|
235
233
|
)
|
|
236
234
|
);
|
|
237
235
|
|
package/src/tools/maintenance.ts
CHANGED
|
@@ -20,8 +20,6 @@ import { page, pickFields } from './summarize.js';
|
|
|
20
20
|
* next sweep no longer detects it, so there is no tool for either.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
type Row = Record<string, unknown>;
|
|
24
|
-
|
|
25
23
|
/** A finding as a list row: enough to decide whether to open, fix or ignore it. */
|
|
26
24
|
const FINDING_ROW_FIELDS = [
|
|
27
25
|
'id',
|
|
@@ -99,11 +97,11 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
99
97
|
client.getMaintenanceSummary(params.productId),
|
|
100
98
|
client.listMaintenanceSweeps(params.productId, { limit: 3 }),
|
|
101
99
|
]);
|
|
102
|
-
const latest = pickFields(sweeps.data
|
|
100
|
+
const latest = pickFields(sweeps.data, SWEEP_FIELDS);
|
|
103
101
|
// The sweeps are reported as `latestSweeps`, narrowed to SWEEP_FIELDS;
|
|
104
102
|
// the summary's own run rows (PRO-2527: one per repository) would
|
|
105
103
|
// repeat them with every column.
|
|
106
|
-
const { lastRun: _lastRun, lastRuns: _lastRuns, ...standing } = summary.data
|
|
104
|
+
const { lastRun: _lastRun, lastRuns: _lastRuns, ...standing } = summary.data;
|
|
107
105
|
return toolResult({
|
|
108
106
|
...standing,
|
|
109
107
|
latestSweeps: latest,
|
|
@@ -141,7 +139,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
141
139
|
const { productId: id, ...filters } = params;
|
|
142
140
|
const result = await client.listFindings(id, filters);
|
|
143
141
|
return toolResult({
|
|
144
|
-
...page(pickFields(result.data
|
|
142
|
+
...page(pickFields(result.data, FINDING_ROW_FIELDS), result.nextCursor),
|
|
145
143
|
...(typeof result.totalCount === 'number' ? { totalCount: result.totalCount } : {}),
|
|
146
144
|
});
|
|
147
145
|
})
|
|
@@ -186,9 +184,9 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
186
184
|
},
|
|
187
185
|
(params) =>
|
|
188
186
|
runTool(async () => {
|
|
189
|
-
const standing = (await client.getPolicyStanding(params.productId)).data
|
|
190
|
-
const controls = Array.isArray(standing.controls) ?
|
|
191
|
-
const violations = Array.isArray(standing.divergences) ?
|
|
187
|
+
const standing = (await client.getPolicyStanding(params.productId)).data;
|
|
188
|
+
const controls = Array.isArray(standing.controls) ? standing.controls : [];
|
|
189
|
+
const violations = Array.isArray(standing.divergences) ? standing.divergences : [];
|
|
192
190
|
const resolved = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences.length : 0;
|
|
193
191
|
const listed = controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0);
|
|
194
192
|
const bySeverity: Record<string, number> = {};
|
|
@@ -268,7 +266,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
268
266
|
...(params.expiresAt !== undefined ? { ignoreExpiresAt: params.expiresAt } : {}),
|
|
269
267
|
}
|
|
270
268
|
: { status: 'open' as const };
|
|
271
|
-
const finding = (await client.updateFinding(params.findingId, body)).data
|
|
269
|
+
const finding = (await client.updateFinding(params.findingId, body)).data;
|
|
272
270
|
return toolResult(pickFields([finding], [...FINDING_ROW_FIELDS, 'ignoreNote'])[0]);
|
|
273
271
|
})
|
|
274
272
|
);
|
|
@@ -299,10 +297,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
299
297
|
(params) =>
|
|
300
298
|
runTool(async () => {
|
|
301
299
|
if (!params.wholeGroup) {
|
|
302
|
-
const fixed = (await client.remediateFinding(params.productId, params.findingId)).data
|
|
303
|
-
finding?: Row;
|
|
304
|
-
backlogItemId?: unknown;
|
|
305
|
-
};
|
|
300
|
+
const fixed = (await client.remediateFinding(params.productId, params.findingId)).data;
|
|
306
301
|
return toolResult({
|
|
307
302
|
backlogItemId: fixed.backlogItemId,
|
|
308
303
|
finding: fixed.finding ? pickFields([fixed.finding], FINDING_ROW_FIELDS)[0] : null,
|
|
@@ -311,7 +306,7 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
311
306
|
// The group is the finding's own: the route matches the package (or
|
|
312
307
|
// rule), the installed version and the repository exactly, so they are
|
|
313
308
|
// taken from the finding rather than asked of the caller.
|
|
314
|
-
const finding = (await client.getFinding(params.findingId)).data
|
|
309
|
+
const finding = (await client.getFinding(params.findingId)).data;
|
|
315
310
|
// The finding is read by its own id, so it is held to the product the
|
|
316
311
|
// group fix is sent to, as the single-finding route holds it.
|
|
317
312
|
if (finding.productId !== params.productId) {
|
package/src/tools/markdown.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
2
2
|
import { toMarkdown } from 'mdast-util-to-markdown';
|
|
3
3
|
import type { Heading, Node, Parent, PhrasingContent } from 'mdast';
|
|
4
|
-
import { isRecord, sectionContent, sectionsOf } from './sections.js';
|
|
4
|
+
import { isRecord, isSectionObject, sectionContent, sectionsOf, type StoredDocument } from './sections.js';
|
|
5
|
+
|
|
6
|
+
/** A document as rendered: its data, and the facts its title and header line name. */
|
|
7
|
+
interface RenderedDocument extends StoredDocument {
|
|
8
|
+
name?: unknown;
|
|
9
|
+
type?: unknown;
|
|
10
|
+
}
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* A document as text an IDE can attach: the name as the title, one line of
|
|
@@ -11,7 +17,7 @@ import { isRecord, sectionContent, sectionsOf } from './sections.js';
|
|
|
11
17
|
* described, and a document that is no section map at all is handed over as
|
|
12
18
|
* JSON.
|
|
13
19
|
*/
|
|
14
|
-
export function renderDocument(doc:
|
|
20
|
+
export function renderDocument(doc: RenderedDocument): { mimeType: string; text: string } {
|
|
15
21
|
const data = isRecord(doc.data) ? doc.data : null;
|
|
16
22
|
const sections = data ? sectionsOf(data) : null;
|
|
17
23
|
if (!data || !sections) return { mimeType: 'application/json', text: JSON.stringify(doc, null, 2) };
|
|
@@ -25,7 +31,7 @@ export function renderDocument(doc: Record<string, unknown>): { mimeType: string
|
|
|
25
31
|
// the headings: a heading is text a section could forge, this line is not.
|
|
26
32
|
const core = Object.keys(sections).filter((id) => {
|
|
27
33
|
const section = sections[id];
|
|
28
|
-
return
|
|
34
|
+
return isSectionObject(section) && section.priority === 'core';
|
|
29
35
|
});
|
|
30
36
|
if (core.length > 0) facts.push(`core sections: ${core.join(', ')}`);
|
|
31
37
|
if (facts.length > 0) lines.push('', facts.join(' | '));
|
|
@@ -120,11 +126,13 @@ function atxOf(depth: Heading['depth'], children: PhrasingContent[]): string {
|
|
|
120
126
|
|
|
121
127
|
/** The section's title when it carries one, else its id. */
|
|
122
128
|
function sectionTitle(section: unknown, id: string): string {
|
|
123
|
-
return
|
|
129
|
+
return isSectionObject(section) && typeof section.title === 'string' && section.title.trim()
|
|
130
|
+
? section.title.trim()
|
|
131
|
+
: id;
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
/** The mime type a read will answer with, for a list entry that must not disagree with it. */
|
|
127
|
-
export function mimeTypeOf(doc:
|
|
135
|
+
export function mimeTypeOf(doc: StoredDocument): string {
|
|
128
136
|
const data = isRecord(doc.data) ? doc.data : null;
|
|
129
137
|
return data && sectionsOf(data) ? 'text/markdown' : 'application/json';
|
|
130
138
|
}
|
package/src/tools/observe.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { ENVIRONMENT_TYPES } from '@taiga/types';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import {
|
|
4
|
+
import type { Deployment, Environment } from '@taiga/types';
|
|
5
|
+
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
5
6
|
import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
6
7
|
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
7
8
|
import { withNotFoundHint } from './hints.js';
|
|
@@ -16,8 +17,6 @@ import { page, pickFields } from './summarize.js';
|
|
|
16
17
|
* provider and is an administrator's action in the web.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
|
-
type Row = Record<string, unknown>;
|
|
20
|
-
|
|
21
20
|
/** A deployment as a line of history: what shipped where, how it ended, and who shipped it. */
|
|
22
21
|
const DEPLOYMENT_FIELDS = [
|
|
23
22
|
'id',
|
|
@@ -44,9 +43,10 @@ const ENVIRONMENT_FIELDS = ['id', 'name', 'slug', 'type', 'ordinal', 'status', '
|
|
|
44
43
|
* repository runs its own deployment in an environment, so two running in
|
|
45
44
|
* production are two repositories, not a contradiction (PRO-2528).
|
|
46
45
|
*/
|
|
47
|
-
function deploymentLines(rows:
|
|
46
|
+
function deploymentLines(rows: WithoutTenant<Deployment>[]) {
|
|
48
47
|
return rows.map((row) => {
|
|
49
|
-
|
|
48
|
+
// `metadata` is an untyped jsonb column until PRO-2515 types it.
|
|
49
|
+
const repository = row.metadata?.['repositoryFullName'];
|
|
50
50
|
return {
|
|
51
51
|
...pickFields([row], DEPLOYMENT_FIELDS)[0],
|
|
52
52
|
repository: typeof repository === 'string' ? repository : null,
|
|
@@ -77,12 +77,15 @@ async function part<T>(read: () => Promise<T>): Promise<{ value: T } | { unavail
|
|
|
77
77
|
const ENVIRONMENT_PAGES = 5;
|
|
78
78
|
|
|
79
79
|
/** Every environment of a product, page by page, and whether any were left past the guard. */
|
|
80
|
-
async function allEnvironments(
|
|
81
|
-
|
|
80
|
+
async function allEnvironments(
|
|
81
|
+
client: TaigaClient,
|
|
82
|
+
product: string
|
|
83
|
+
): Promise<{ rows: WithoutTenant<Environment>[]; more: boolean }> {
|
|
84
|
+
const rows: WithoutTenant<Environment>[] = [];
|
|
82
85
|
let cursor: string | undefined;
|
|
83
86
|
for (let pageNo = 0; pageNo < ENVIRONMENT_PAGES; pageNo++) {
|
|
84
87
|
const result = await client.listEnvironments(product, { limit: 100, cursor });
|
|
85
|
-
rows.push(...
|
|
88
|
+
rows.push(...result.data);
|
|
86
89
|
if (!result.nextCursor) return { rows, more: false };
|
|
87
90
|
cursor = result.nextCursor;
|
|
88
91
|
}
|
|
@@ -119,14 +122,14 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
119
122
|
}),
|
|
120
123
|
part(() => client.getDeploymentSyncStatus(params.productId)),
|
|
121
124
|
]);
|
|
122
|
-
const now = current.data
|
|
125
|
+
const now = current.data;
|
|
123
126
|
// An empty answer says why, as the maintenance read does: no
|
|
124
127
|
// environment, or none deployed to yet, is not "nothing is wrong".
|
|
125
128
|
const note =
|
|
126
129
|
environments.rows.length === 0
|
|
127
130
|
? 'This product has no environments yet: an environment is defined with taiga_define_environment, ' +
|
|
128
131
|
'and its deployments are mirrored from the provider once it is.'
|
|
129
|
-
:
|
|
132
|
+
: history.data.length === 0 && !params.cursor
|
|
130
133
|
? 'No deployments recorded yet: none was mirrored from the provider (syncStatus says when it last ' +
|
|
131
134
|
'synced), or nothing has been deployed.'
|
|
132
135
|
: null;
|
|
@@ -135,7 +138,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
135
138
|
...(environments.more ? { moreEnvironments: true } : {}),
|
|
136
139
|
running: deploymentLines(now.current ?? []),
|
|
137
140
|
deploying: deploymentLines(now.deploying ?? []),
|
|
138
|
-
history: page(deploymentLines(history.data
|
|
141
|
+
history: page(deploymentLines(history.data), history.nextCursor),
|
|
139
142
|
...('value' in sync
|
|
140
143
|
? { syncStatus: sync.value.data }
|
|
141
144
|
: { syncStatus: null, syncUnavailable: sync.unavailable }),
|
|
@@ -241,7 +244,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
241
244
|
}
|
|
242
245
|
const value = (p: { value: { data: unknown } } | { unavailable: string }) =>
|
|
243
246
|
'value' in p ? p.value.data : { unavailable: p.unavailable };
|
|
244
|
-
const envHealth =
|
|
247
|
+
const envHealth = health.data.filter((row) => row.environmentId === env);
|
|
245
248
|
return toolResult({
|
|
246
249
|
health: envHealth,
|
|
247
250
|
...(envHealth.length === 0
|
|
@@ -282,7 +285,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
282
285
|
(params) =>
|
|
283
286
|
runTool(async () => {
|
|
284
287
|
const created = (await client.createEnvironment(params.productId, { name: params.name, type: params.type }))
|
|
285
|
-
.data
|
|
288
|
+
.data;
|
|
286
289
|
return toolResult(pickFields([created], ENVIRONMENT_FIELDS)[0]);
|
|
287
290
|
})
|
|
288
291
|
);
|
|
@@ -310,7 +313,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
310
313
|
if (Object.values(changes).every((value) => value === undefined)) {
|
|
311
314
|
return toolError('VALIDATION_ERROR', 'Change at least one of name, type or url.');
|
|
312
315
|
}
|
|
313
|
-
const updated = (await client.updateEnvironment(id, changes)).data
|
|
316
|
+
const updated = (await client.updateEnvironment(id, changes)).data;
|
|
314
317
|
return toolResult(pickFields([updated], ENVIRONMENT_FIELDS)[0]);
|
|
315
318
|
})
|
|
316
319
|
);
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
SETUP_REQUIRED_DOCUMENT_TYPES,
|
|
9
9
|
} from '@taiga/types';
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
-
import type { TaigaClient
|
|
11
|
+
import type { TaigaClient } from '../client.js';
|
|
12
12
|
import { OPERATE, WRITE } from './annotations.js';
|
|
13
13
|
import { type DocumentScope, publishDraft, sectionContentInput, sectionIdFrom, updateSection } from './documents.js';
|
|
14
14
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
@@ -25,8 +25,6 @@ import { jobReport } from './jobs.js';
|
|
|
25
25
|
* code and is not edited.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
type Row = Record<string, unknown>;
|
|
29
|
-
|
|
30
28
|
/** The plan-side documents people edit; the codebase snapshot is derived from the code, never written. */
|
|
31
29
|
type EditableType = Exclude<ProductDocumentType, 'codebase_snapshot'>;
|
|
32
30
|
const EDITABLE_TYPES = PRODUCT_DOCUMENT_TYPES.filter((type): type is EditableType => type !== 'codebase_snapshot') as [
|
|
@@ -56,7 +54,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
|
|
|
56
54
|
|
|
57
55
|
const productScope = (product: string, type: string): DocumentScope => ({
|
|
58
56
|
getDraft: () => client.getProductDocumentDraft(product, type),
|
|
59
|
-
getPublished:
|
|
57
|
+
getPublished: () => client.getProductDocument(product, type, 'plan'),
|
|
60
58
|
saveDraft: (data) => client.saveProductDocumentDraft(product, type, { data }),
|
|
61
59
|
publish: () => client.publishProductDocument(product, type),
|
|
62
60
|
});
|
|
@@ -155,7 +153,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
|
|
|
155
153
|
targetId: params.productId,
|
|
156
154
|
documentType: params.type,
|
|
157
155
|
})
|
|
158
|
-
).data
|
|
156
|
+
).data;
|
|
159
157
|
return toolResult(jobReport(job));
|
|
160
158
|
})
|
|
161
159
|
);
|
package/src/tools/products.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import type { Product, ProductDocumentListItem } from '@taiga/types';
|
|
4
|
+
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
4
5
|
import { READ_ONLY } from './annotations.js';
|
|
5
6
|
import { idInput, pagination, productId, remoteUrl, slug } from './inputs.js';
|
|
6
7
|
import {
|
|
@@ -22,6 +23,9 @@ import { parseRemote } from './remote.js';
|
|
|
22
23
|
import { sectionReadResult, sectionsInput, stripProvenance } from './sections.js';
|
|
23
24
|
import { SEARCH_NOTE, SEARCH_PAGE_SIZE, SEARCH_WITH_CURSOR, page, pickFields, searchPages } from './summarize.js';
|
|
24
25
|
|
|
26
|
+
type ProductRow = WithoutTenant<Product>;
|
|
27
|
+
type DocumentRow = WithoutTenant<ProductDocumentListItem>;
|
|
28
|
+
|
|
25
29
|
/** What a product row needs to say for the model to pick one and read it in full. */
|
|
26
30
|
// Not `kind`: it is parked behind a flag that is on nowhere, so every row said null.
|
|
27
31
|
export const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'discoveryStep'] as const;
|
|
@@ -72,11 +76,11 @@ const originInput = z
|
|
|
72
76
|
* than being folded into a truncated list that would read as a complete one.
|
|
73
77
|
*/
|
|
74
78
|
async function linkedProducts(client: TaigaClient, repository: string, repositoryHost: string) {
|
|
75
|
-
const products:
|
|
79
|
+
const products: ProductRow[] = [];
|
|
76
80
|
let cursor: string | undefined;
|
|
77
81
|
for (let page = 0; page < RESOLVE_PAGE_LIMIT; page += 1) {
|
|
78
82
|
const result = await client.listProducts({ repository, repositoryHost, limit: PAGE_SIZE, cursor });
|
|
79
|
-
products.push(...
|
|
83
|
+
products.push(...result.data);
|
|
80
84
|
if (!result.nextCursor) return { products, truncated: false };
|
|
81
85
|
cursor = result.nextCursor;
|
|
82
86
|
}
|
|
@@ -84,15 +88,14 @@ async function linkedProducts(client: TaigaClient, repository: string, repositor
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
/** A document list row as the model sees it: the summary fields plus a display name and the staleness fact. */
|
|
87
|
-
function documentSummaries(rows:
|
|
88
|
-
|
|
89
|
-
return pickFields(typed, DOCUMENT_SUMMARY_FIELDS).map((picked, index) => {
|
|
91
|
+
function documentSummaries(rows: DocumentRow[]) {
|
|
92
|
+
return pickFields(rows, DOCUMENT_SUMMARY_FIELDS).map((picked, index) => {
|
|
90
93
|
// `outdated` on the wire is the list of sources that moved on; the model
|
|
91
94
|
// needs the fact, not the tree. Published plan rows carry it: null when
|
|
92
95
|
// current, a source list when not. A draft has nothing published to be
|
|
93
96
|
// outdated (the API stamps it null too) and reality rows carry no such
|
|
94
97
|
// field: no fact for either.
|
|
95
|
-
const outdated =
|
|
98
|
+
const outdated = rows[index]!.outdated;
|
|
96
99
|
const fact =
|
|
97
100
|
picked.isDraft !== false
|
|
98
101
|
? undefined
|
|
@@ -110,12 +113,14 @@ function documentSummaries(rows: unknown[]): Record<string, unknown>[] {
|
|
|
110
113
|
});
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
type DocumentSummary = ReturnType<typeof documentSummaries>[number];
|
|
117
|
+
|
|
113
118
|
interface Timelines {
|
|
114
119
|
projectType: 'greenfield' | 'brownfield';
|
|
115
120
|
defaultOrigin: ProductDocumentOrigin;
|
|
116
121
|
/** The side's summaries, or null when the API could not list it (see `unlistable`). */
|
|
117
|
-
plan:
|
|
118
|
-
reality:
|
|
122
|
+
plan: DocumentSummary[] | null;
|
|
123
|
+
reality: DocumentSummary[] | null;
|
|
119
124
|
/** Why a null side is null, in the words the notes and a miss message use. */
|
|
120
125
|
unlistable: Partial<Record<ProductDocumentOrigin, string>>;
|
|
121
126
|
notes: string[];
|
|
@@ -134,7 +139,7 @@ function unlistableNote(origin: ProductDocumentOrigin, reason: unknown): string
|
|
|
134
139
|
* so a caller that already holds it passes it in.
|
|
135
140
|
*/
|
|
136
141
|
async function documentTimelines(client: TaigaClient, id: string, product?: ProductSettingsView): Promise<Timelines> {
|
|
137
|
-
const loaded = product ?? (
|
|
142
|
+
const loaded = product ?? (await client.getProduct(id)).data;
|
|
138
143
|
// Each side on its own: one side the API cannot list is reported as
|
|
139
144
|
// unreadable, not as empty, and never hides the side that did answer.
|
|
140
145
|
// Both failing is the API failing, and that is an error like any other.
|
|
@@ -142,7 +147,7 @@ async function documentTimelines(client: TaigaClient, id: string, product?: Prod
|
|
|
142
147
|
PRODUCT_DOCUMENT_ORIGINS.map((o) => client.listProductDocuments(id, { origin: o, limit: PAGE_SIZE }))
|
|
143
148
|
);
|
|
144
149
|
if (settled.every((result) => result.status === 'rejected')) throw settled[0]!.reason;
|
|
145
|
-
const sides: Partial<Record<ProductDocumentOrigin,
|
|
150
|
+
const sides: Partial<Record<ProductDocumentOrigin, DocumentSummary[] | null>> = {};
|
|
146
151
|
const unlistable: Partial<Record<ProductDocumentOrigin, string>> = {};
|
|
147
152
|
PRODUCT_DOCUMENT_ORIGINS.forEach((o, index) => {
|
|
148
153
|
const result = settled[index]!;
|
|
@@ -167,10 +172,10 @@ async function documentTimelines(client: TaigaClient, id: string, product?: Prod
|
|
|
167
172
|
};
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
const isPublished = (row:
|
|
175
|
+
const isPublished = (row: { isDraft?: boolean }) => row.isDraft === false;
|
|
171
176
|
|
|
172
177
|
/** `specification (v2), architecture (draft)` for one side. */
|
|
173
|
-
function sideList(rows:
|
|
178
|
+
function sideList(rows: DocumentSummary[]): string {
|
|
174
179
|
return rows
|
|
175
180
|
.map((row) => `${String(row.type)} (${isPublished(row) ? `v${String(row.version)}` : 'draft'})`)
|
|
176
181
|
.join(', ');
|
|
@@ -218,7 +223,7 @@ export async function resolveProductRef(
|
|
|
218
223
|
accepted: readonly string[],
|
|
219
224
|
/** How the caller's arguments are passed, for the message when none was given (a slash command's usage). */
|
|
220
225
|
usage?: string
|
|
221
|
-
): Promise<{ product:
|
|
226
|
+
): Promise<{ product: ProductRow } | { refusal: { code: string; message: string } }> {
|
|
222
227
|
const refusal = (code: string, message: string) => ({ refusal: { code, message } });
|
|
223
228
|
const given = [ref.productId, ref.slug, ref.remoteUrl].filter((v) => v !== undefined).length;
|
|
224
229
|
if (given !== 1) {
|
|
@@ -228,7 +233,7 @@ export async function resolveProductRef(
|
|
|
228
233
|
}
|
|
229
234
|
if (ref.productId) {
|
|
230
235
|
try {
|
|
231
|
-
return { product: (await client.getProduct(ref.productId)).data
|
|
236
|
+
return { product: (await client.getProduct(ref.productId)).data };
|
|
232
237
|
} catch (error) {
|
|
233
238
|
if (!isMissOrRefusal(error)) throw error;
|
|
234
239
|
return refusal(
|
|
@@ -238,7 +243,7 @@ export async function resolveProductRef(
|
|
|
238
243
|
}
|
|
239
244
|
}
|
|
240
245
|
if (ref.slug) {
|
|
241
|
-
const [product] = (await client.listProducts({ slug: ref.slug, limit: 1 })).data
|
|
246
|
+
const [product] = (await client.listProducts({ slug: ref.slug, limit: 1 })).data;
|
|
242
247
|
if (!product) {
|
|
243
248
|
return refusal(
|
|
244
249
|
'NOT_FOUND',
|
|
@@ -341,9 +346,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
341
346
|
const { query: text, ...filters } = params;
|
|
342
347
|
if (text === undefined) {
|
|
343
348
|
const result = await client.listProducts(filters);
|
|
344
|
-
return toolResult(
|
|
345
|
-
page(pickFields(result.data as Record<string, unknown>[], PRODUCT_SUMMARY_FIELDS), result.nextCursor)
|
|
346
|
-
);
|
|
349
|
+
return toolResult(page(pickFields(result.data, PRODUCT_SUMMARY_FIELDS), result.nextCursor));
|
|
347
350
|
}
|
|
348
351
|
if (params.cursor !== undefined) return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
|
|
349
352
|
const found = await searchPages(
|
|
@@ -483,11 +486,11 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
483
486
|
// The product's own timeline when none is asked for; the API's
|
|
484
487
|
// default is plan, which is the wrong side of an imported product.
|
|
485
488
|
let product: ProductSettingsView | undefined;
|
|
486
|
-
if (!params.origin) product = (await client.getProduct(params.productId)).data
|
|
489
|
+
if (!params.origin) product = (await client.getProduct(params.productId)).data;
|
|
487
490
|
const origin = params.origin ?? defaultOriginFor(product, params.type);
|
|
488
491
|
try {
|
|
489
492
|
const result = await client.getProductDocument(params.productId, params.type, origin);
|
|
490
|
-
const doc = stripProvenance(result.data
|
|
493
|
+
const doc = stripProvenance(result.data);
|
|
491
494
|
if (!params.sections) return toolResult(doc);
|
|
492
495
|
return sectionReadResult(doc, params.sections, `${origin} ${params.type}`);
|
|
493
496
|
} catch (error) {
|
|
@@ -496,7 +499,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
496
499
|
// miss, so the product read propagates. The lists after it are
|
|
497
500
|
// best effort per side (documentTimelines); only both failing
|
|
498
501
|
// propagates, as the API failing.
|
|
499
|
-
product ??= (await client.getProduct(params.productId)).data
|
|
502
|
+
product ??= (await client.getProduct(params.productId)).data;
|
|
500
503
|
const timelines = await documentTimelines(client, params.productId, product);
|
|
501
504
|
return toolError('NOT_FOUND', describeMiss(params.type, origin, timelines, product));
|
|
502
505
|
}
|
package/src/tools/prompts.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type GetPromptResult, type McpServer, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
4
|
-
import { POLICY_TYPES, defaultOriginFor
|
|
4
|
+
import { POLICY_TYPES, defaultOriginFor } from './catalogue.js';
|
|
5
5
|
import { productContext } from './context.js';
|
|
6
6
|
import { apiFailureCode, apiFailureText, isMissOrRefusal, isNetworkFailure, networkFailureDetail } from './errors.js';
|
|
7
7
|
import { idInput, productId, remoteUrl, slug } from './inputs.js';
|
|
@@ -126,12 +126,12 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
|
|
|
126
126
|
client.getBacklogItem(args.itemId),
|
|
127
127
|
'No backlog item with that ID the key can see; taiga_list_backlog_items shows what exists'
|
|
128
128
|
)
|
|
129
|
-
).data
|
|
129
|
+
).data;
|
|
130
130
|
const productId = String(item.productId);
|
|
131
131
|
const product = (
|
|
132
132
|
await served(client.getProduct(productId), 'The product of that item is not visible to the key')
|
|
133
|
-
).data
|
|
134
|
-
const origin = defaultOriginFor(product
|
|
133
|
+
).data;
|
|
134
|
+
const origin = defaultOriginFor(product, 'architecture');
|
|
135
135
|
|
|
136
136
|
// The three reads are independent; each answers for itself.
|
|
137
137
|
const [architecture, instructions, policies] = await Promise.allSettled([
|
|
@@ -146,10 +146,7 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
|
|
|
146
146
|
|
|
147
147
|
const architectureText =
|
|
148
148
|
architecture.status === 'fulfilled'
|
|
149
|
-
? fenced(
|
|
150
|
-
'markdown',
|
|
151
|
-
renderDocument(stripProvenance(architecture.value.data as Record<string, unknown>)).text
|
|
152
|
-
)
|
|
149
|
+
? fenced('markdown', renderDocument(stripProvenance(architecture.value.data)).text)
|
|
153
150
|
: isNotFound(architecture.reason)
|
|
154
151
|
? `(No published ${origin} architecture; taiga_list_product_documents shows what exists.)`
|
|
155
152
|
: unreadable('The architecture', architecture.reason);
|
|
@@ -170,14 +167,11 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
|
|
|
170
167
|
policies.status === 'fulfilled'
|
|
171
168
|
? fenced(
|
|
172
169
|
'text',
|
|
173
|
-
|
|
170
|
+
policies.value.data
|
|
174
171
|
.filter(
|
|
175
172
|
(row) => (POLICY_TYPES as readonly string[]).includes(String(row.type)) && row.isDraft === false
|
|
176
173
|
)
|
|
177
|
-
.map(
|
|
178
|
-
(row) =>
|
|
179
|
-
`- ${String(row.type)}: ${Object.keys(sectionsOf((row.data as Record<string, unknown>) ?? {}) ?? {}).join(', ')}`
|
|
180
|
-
)
|
|
174
|
+
.map((row) => `- ${String(row.type)}: ${Object.keys(sectionsOf(row.data ?? {}) ?? {}).join(', ')}`)
|
|
181
175
|
.join('\n') || '(No policy is published.)'
|
|
182
176
|
)
|
|
183
177
|
: policies.reason instanceof NotGranted
|
package/src/tools/queue.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
HALT_CAUSES,
|
|
4
|
+
canResumeHalt,
|
|
5
|
+
haltClearsItself,
|
|
6
|
+
renderHalt,
|
|
7
|
+
type BacklogItem,
|
|
8
|
+
type BacklogItemLifecycle,
|
|
9
|
+
type Halt,
|
|
10
|
+
type HaltCause,
|
|
11
|
+
} from '@taiga/types';
|
|
3
12
|
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
4
13
|
import { z } from 'zod';
|
|
5
14
|
import { OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
@@ -10,7 +19,21 @@ import { criteriaCounts } from './summarize.js';
|
|
|
10
19
|
/** How many entries the read names; past this an entry keeps its backlogItemId only. */
|
|
11
20
|
const NAMED_ENTRIES = 25;
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
/** One queue entry as the read reports it: where it sits, and its initiative when that could be named. */
|
|
23
|
+
interface QueueRow {
|
|
24
|
+
position: number;
|
|
25
|
+
backlogItemId: string;
|
|
26
|
+
entryState: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
state?: BacklogItem['state'];
|
|
29
|
+
planningStatus?: BacklogItem['planningStatus'];
|
|
30
|
+
needsHumanCause?: BacklogItem['needsHumanCause'];
|
|
31
|
+
criteria?: { met: number; total: number } | null;
|
|
32
|
+
lifecycle?: Pick<BacklogItemLifecycle, 'turn' | 'stage' | 'cta'> | null;
|
|
33
|
+
buildId?: string;
|
|
34
|
+
prUrl?: string | null;
|
|
35
|
+
halt?: { cause: string | null; reason: string; nextStep: string };
|
|
36
|
+
}
|
|
14
37
|
|
|
15
38
|
const MOVE_ASIDE =
|
|
16
39
|
'or move it down (taiga_queue_backlog_item with afterId) or off the line (taiga_move_backlog_item) to let the rest pass.';
|
|
@@ -45,7 +68,7 @@ function blockingIndex<T>(entries: T[], stateOf: (entry: T) => unknown): number
|
|
|
45
68
|
}
|
|
46
69
|
|
|
47
70
|
/** The one sentence a person needs: what the line is doing, or what it is waiting on. */
|
|
48
|
-
function waitingOn(rows:
|
|
71
|
+
function waitingOn(rows: QueueRow[]): string | null {
|
|
49
72
|
const at = blockingIndex(rows, (row) => row.entryState);
|
|
50
73
|
if (at < 0) {
|
|
51
74
|
return rows.length > 0
|
|
@@ -59,7 +82,7 @@ function waitingOn(rows: Row[]): string | null {
|
|
|
59
82
|
const ahead = at > 0 ? ' Entries ahead of it are not held by it.' : '';
|
|
60
83
|
return `${name} halted the queue, holding the entries behind it: ${halt.reason} ${halt.nextStep}${ahead}`;
|
|
61
84
|
}
|
|
62
|
-
const lifecycle = head.lifecycle
|
|
85
|
+
const lifecycle = head.lifecycle;
|
|
63
86
|
const held = 'every other queued initiative waits until its pull request merges.';
|
|
64
87
|
if (!lifecycle) return `${name} is the active entry; ${held}`;
|
|
65
88
|
const pr = typeof head.prUrl === 'string' ? ` (${head.prUrl})` : '';
|
|
@@ -86,7 +109,7 @@ export async function queueView(
|
|
|
86
109
|
options: { name: 'all' | 'blocking' } = { name: 'all' }
|
|
87
110
|
) {
|
|
88
111
|
const [queue, paused] = await Promise.all([client.listQueue(product), linePausedSince(client, product)]);
|
|
89
|
-
const entries =
|
|
112
|
+
const entries = queue.data.filter((entry) => entry.state !== 'settled');
|
|
90
113
|
const canName = client.hasCapability('backlog:read');
|
|
91
114
|
// 'blocking' names only the entry the line waits on: a caller after one
|
|
92
115
|
// position and waitingOn need no more reads than that.
|
|
@@ -98,9 +121,7 @@ export async function queueView(
|
|
|
98
121
|
: blocking >= 0
|
|
99
122
|
? [blocking]
|
|
100
123
|
: [];
|
|
101
|
-
const settled = await Promise.allSettled(
|
|
102
|
-
toName.map((index) => client.getBacklogItem(String(entries[index]!.backlogItemId)))
|
|
103
|
-
);
|
|
124
|
+
const settled = await Promise.allSettled(toName.map((index) => client.getBacklogItem(entries[index]!.backlogItemId)));
|
|
104
125
|
const reads = new Map(toName.map((index, at) => [index, settled[at]!]));
|
|
105
126
|
let unnamed = options.name === 'all' ? entries.length - toName.length : 0;
|
|
106
127
|
const rows = entries.map((entry, index) => {
|
|
@@ -110,10 +131,10 @@ export async function queueView(
|
|
|
110
131
|
if (!(read.reason instanceof TaigaApiError || isNetworkFailure(read.reason))) throw read.reason;
|
|
111
132
|
unnamed += 1;
|
|
112
133
|
}
|
|
113
|
-
const item = read?.status === 'fulfilled' ?
|
|
114
|
-
const lifecycle = item?.lifecycle
|
|
115
|
-
const latestBuild = lifecycle?.latestBuild
|
|
116
|
-
const row:
|
|
134
|
+
const item = read?.status === 'fulfilled' ? read.value.data : null;
|
|
135
|
+
const lifecycle = item?.lifecycle;
|
|
136
|
+
const latestBuild = lifecycle?.latestBuild;
|
|
137
|
+
const row: QueueRow = { position: index + 1, backlogItemId: entry.backlogItemId, entryState: entry.state };
|
|
117
138
|
if (item) {
|
|
118
139
|
Object.assign(row, {
|
|
119
140
|
title: item.title,
|
|
@@ -123,7 +144,7 @@ export async function queueView(
|
|
|
123
144
|
criteria: criteriaCounts(item),
|
|
124
145
|
lifecycle: lifecycle ? { turn: lifecycle.turn, stage: lifecycle.stage, cta: lifecycle.cta } : null,
|
|
125
146
|
...(latestBuild ? { buildId: latestBuild.id, prUrl: latestBuild.prUrl ?? null } : {}),
|
|
126
|
-
});
|
|
147
|
+
} satisfies Partial<QueueRow>);
|
|
127
148
|
}
|
|
128
149
|
if (entry.state === 'halted') {
|
|
129
150
|
const cause = typeof entry.haltCause === 'string' ? entry.haltCause : null;
|
|
@@ -165,7 +186,7 @@ export async function queueView(
|
|
|
165
186
|
*/
|
|
166
187
|
async function linePausedSince(client: TaigaClient, product: string): Promise<string | false | null> {
|
|
167
188
|
try {
|
|
168
|
-
const row = (await client.getProduct(product)).data
|
|
189
|
+
const row = (await client.getProduct(product)).data;
|
|
169
190
|
return typeof row.lineStoppedAt === 'string' ? row.lineStoppedAt : false;
|
|
170
191
|
} catch (error) {
|
|
171
192
|
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
@@ -214,7 +235,7 @@ export function registerQueueTools(server: McpServer, client: TaigaClient) {
|
|
|
214
235
|
},
|
|
215
236
|
(params) =>
|
|
216
237
|
runTool(async () => {
|
|
217
|
-
const row = (await client.setLineStopped(params.productId, params.paused)).data
|
|
238
|
+
const row = (await client.setLineStopped(params.productId, params.paused)).data;
|
|
218
239
|
return toolResult({
|
|
219
240
|
productId: row.id,
|
|
220
241
|
linePaused: typeof row.lineStoppedAt === 'string',
|