@taiga-ai/mcp-server 0.18.0-dev.446 → 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 +109 -39
- package/package.json +1 -1
- package/src/client.test.ts +2 -2
- package/src/client.ts +190 -145
- package/src/server.integration.test.ts +15 -11
- 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 +32 -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.446.tgz +0 -0
package/src/tools/jobs.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { createdInitiativeIdsOf, createdInitiativesOf, isBacklogGenerationEmptyResult } from '@taiga/types';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import type { AgentJob } from '@taiga/types';
|
|
4
5
|
import { idInput } from './inputs.js';
|
|
5
|
-
import type { TaigaClient } from '../client.js';
|
|
6
|
+
import type { TaigaClient, WithoutTenant } from '../client.js';
|
|
6
7
|
import { READ_ONLY } from './annotations.js';
|
|
7
8
|
import { runTool, toolResult } from './errors.js';
|
|
8
9
|
import { pickFields } from './summarize.js';
|
|
@@ -13,7 +14,7 @@ import { pickFields } from './summarize.js';
|
|
|
13
14
|
* it is, and what came of it.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
|
-
type
|
|
17
|
+
type Job = WithoutTenant<AgentJob>;
|
|
17
18
|
|
|
18
19
|
/** What a job says about itself: its kind, how far it got, and why it failed. */
|
|
19
20
|
const JOB_FIELDS = [
|
|
@@ -43,7 +44,7 @@ export const SETTLED_JOB_STATUSES: ReadonlySet<string> = new Set(['completed', '
|
|
|
43
44
|
* own words and the way to decide otherwise). Null while the job runs, and
|
|
44
45
|
* for a job that is not an initiative request.
|
|
45
46
|
*/
|
|
46
|
-
function requestOutcome(job:
|
|
47
|
+
function requestOutcome(job: Job) {
|
|
47
48
|
if (job.type !== 'generate_backlog' || job.status !== 'completed') return null;
|
|
48
49
|
const created = createdInitiativesOf(job.result);
|
|
49
50
|
if (created) return { outcome: 'created', initiatives: created };
|
|
@@ -63,7 +64,7 @@ function requestOutcome(job: Row): Row | null {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/** A job as the tools hand it over: its own fields, and for an initiative request, what came of it. */
|
|
66
|
-
export function jobReport(job:
|
|
67
|
+
export function jobReport(job: Job) {
|
|
67
68
|
const outcome = requestOutcome(job);
|
|
68
69
|
return { ...pickFields([job], JOB_FIELDS)[0], ...(outcome ? { outcome } : {}) };
|
|
69
70
|
}
|
|
@@ -85,6 +86,6 @@ export function registerJobTools(server: McpServer, client: TaigaClient) {
|
|
|
85
86
|
inputSchema: z.object({ jobId: idInput('job', 'the tool that started the job returned it') }),
|
|
86
87
|
annotations: READ_ONLY,
|
|
87
88
|
},
|
|
88
|
-
(params) => runTool(async () => toolResult(jobReport((await client.getAgentJob(params.jobId)).data
|
|
89
|
+
(params) => runTool(async () => toolResult(jobReport((await client.getAgentJob(params.jobId)).data)))
|
|
89
90
|
);
|
|
90
91
|
}
|
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',
|
|
@@ -39,6 +38,22 @@ const DEPLOYMENT_FIELDS = [
|
|
|
39
38
|
|
|
40
39
|
const ENVIRONMENT_FIELDS = ['id', 'name', 'slug', 'type', 'ordinal', 'status', 'isProtected', 'url'] as const;
|
|
41
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Deployments as lines, each naming the repository that deployed it: every
|
|
43
|
+
* repository runs its own deployment in an environment, so two running in
|
|
44
|
+
* production are two repositories, not a contradiction (PRO-2528).
|
|
45
|
+
*/
|
|
46
|
+
function deploymentLines(rows: WithoutTenant<Deployment>[]) {
|
|
47
|
+
return rows.map((row) => {
|
|
48
|
+
// `metadata` is an untyped jsonb column until PRO-2515 types it.
|
|
49
|
+
const repository = row.metadata?.['repositoryFullName'];
|
|
50
|
+
return {
|
|
51
|
+
...pickFields([row], DEPLOYMENT_FIELDS)[0],
|
|
52
|
+
repository: typeof repository === 'string' ? repository : null,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
const environmentId = idInput('environment', 'taiga_list_environments lists them; a product ID is not one');
|
|
43
58
|
/** The windows the pages offer, as the schema's own values rather than a number checked afterwards. */
|
|
44
59
|
const windowDays = z.literal([30, 60, 90]).optional().describe('Days to look back: 30, 60 or 90 (default 30)');
|
|
@@ -62,12 +77,15 @@ async function part<T>(read: () => Promise<T>): Promise<{ value: T } | { unavail
|
|
|
62
77
|
const ENVIRONMENT_PAGES = 5;
|
|
63
78
|
|
|
64
79
|
/** Every environment of a product, page by page, and whether any were left past the guard. */
|
|
65
|
-
async function allEnvironments(
|
|
66
|
-
|
|
80
|
+
async function allEnvironments(
|
|
81
|
+
client: TaigaClient,
|
|
82
|
+
product: string
|
|
83
|
+
): Promise<{ rows: WithoutTenant<Environment>[]; more: boolean }> {
|
|
84
|
+
const rows: WithoutTenant<Environment>[] = [];
|
|
67
85
|
let cursor: string | undefined;
|
|
68
86
|
for (let pageNo = 0; pageNo < ENVIRONMENT_PAGES; pageNo++) {
|
|
69
87
|
const result = await client.listEnvironments(product, { limit: 100, cursor });
|
|
70
|
-
rows.push(...
|
|
88
|
+
rows.push(...result.data);
|
|
71
89
|
if (!result.nextCursor) return { rows, more: false };
|
|
72
90
|
cursor = result.nextCursor;
|
|
73
91
|
}
|
|
@@ -104,23 +122,23 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
104
122
|
}),
|
|
105
123
|
part(() => client.getDeploymentSyncStatus(params.productId)),
|
|
106
124
|
]);
|
|
107
|
-
const now = current.data
|
|
125
|
+
const now = current.data;
|
|
108
126
|
// An empty answer says why, as the maintenance read does: no
|
|
109
127
|
// environment, or none deployed to yet, is not "nothing is wrong".
|
|
110
128
|
const note =
|
|
111
129
|
environments.rows.length === 0
|
|
112
130
|
? 'This product has no environments yet: an environment is defined with taiga_define_environment, ' +
|
|
113
131
|
'and its deployments are mirrored from the provider once it is.'
|
|
114
|
-
:
|
|
132
|
+
: history.data.length === 0 && !params.cursor
|
|
115
133
|
? 'No deployments recorded yet: none was mirrored from the provider (syncStatus says when it last ' +
|
|
116
134
|
'synced), or nothing has been deployed.'
|
|
117
135
|
: null;
|
|
118
136
|
return toolResult({
|
|
119
137
|
environments: pickFields(environments.rows, ENVIRONMENT_FIELDS),
|
|
120
138
|
...(environments.more ? { moreEnvironments: true } : {}),
|
|
121
|
-
running:
|
|
122
|
-
deploying:
|
|
123
|
-
history: page(
|
|
139
|
+
running: deploymentLines(now.current ?? []),
|
|
140
|
+
deploying: deploymentLines(now.deploying ?? []),
|
|
141
|
+
history: page(deploymentLines(history.data), history.nextCursor),
|
|
124
142
|
...('value' in sync
|
|
125
143
|
? { syncStatus: sync.value.data }
|
|
126
144
|
: { syncStatus: null, syncUnavailable: sync.unavailable }),
|
|
@@ -226,7 +244,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
226
244
|
}
|
|
227
245
|
const value = (p: { value: { data: unknown } } | { unavailable: string }) =>
|
|
228
246
|
'value' in p ? p.value.data : { unavailable: p.unavailable };
|
|
229
|
-
const envHealth =
|
|
247
|
+
const envHealth = health.data.filter((row) => row.environmentId === env);
|
|
230
248
|
return toolResult({
|
|
231
249
|
health: envHealth,
|
|
232
250
|
...(envHealth.length === 0
|
|
@@ -267,7 +285,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
267
285
|
(params) =>
|
|
268
286
|
runTool(async () => {
|
|
269
287
|
const created = (await client.createEnvironment(params.productId, { name: params.name, type: params.type }))
|
|
270
|
-
.data
|
|
288
|
+
.data;
|
|
271
289
|
return toolResult(pickFields([created], ENVIRONMENT_FIELDS)[0]);
|
|
272
290
|
})
|
|
273
291
|
);
|
|
@@ -295,7 +313,7 @@ export function registerObserveTools(server: McpServer, client: TaigaClient) {
|
|
|
295
313
|
if (Object.values(changes).every((value) => value === undefined)) {
|
|
296
314
|
return toolError('VALIDATION_ERROR', 'Change at least one of name, type or url.');
|
|
297
315
|
}
|
|
298
|
-
const updated = (await client.updateEnvironment(id, changes)).data
|
|
316
|
+
const updated = (await client.updateEnvironment(id, changes)).data;
|
|
299
317
|
return toolResult(pickFields([updated], ENVIRONMENT_FIELDS)[0]);
|
|
300
318
|
})
|
|
301
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
|