@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
|
@@ -267,7 +267,7 @@ function toolNames(tools: { name: string }[]) {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
/** Extract parsed JSON from the first text content block of a tool call result. */
|
|
270
|
-
function parseToolResult(result:
|
|
270
|
+
function parseToolResult(result: { content?: unknown }) {
|
|
271
271
|
const content = result.content as { type: string; text: string }[];
|
|
272
272
|
return JSON.parse(content[0]!.text);
|
|
273
273
|
}
|
|
@@ -580,7 +580,7 @@ describe('MCP server integration', () => {
|
|
|
580
580
|
it('should replace one section of the existing draft and keep the rest', async () => {
|
|
581
581
|
const draft = policyDocument({ id: 'draft-1', version: null, isDraft: true });
|
|
582
582
|
const saved = policyDocument({ id: 'draft-1', version: null, isDraft: true });
|
|
583
|
-
|
|
583
|
+
saved.data.sections.summary.content = 'New summary';
|
|
584
584
|
|
|
585
585
|
const { client, fetchSpy } = await bootMcpPair({
|
|
586
586
|
capabilities: ALL_CAPABILITIES,
|
|
@@ -750,9 +750,9 @@ describe('MCP server integration', () => {
|
|
|
750
750
|
const { tools } = await client.listTools();
|
|
751
751
|
const byName = Object.fromEntries(tools.map((t) => [t.name, t.annotations]));
|
|
752
752
|
|
|
753
|
-
expect(byName
|
|
754
|
-
expect(byName
|
|
755
|
-
expect(byName
|
|
753
|
+
expect(byName['taiga_publish_document']).toMatchObject({ destructiveHint: true, idempotentHint: false });
|
|
754
|
+
expect(byName['taiga_publish_factory_document']).toMatchObject({ destructiveHint: true, idempotentHint: false });
|
|
755
|
+
expect(byName['taiga_update_document']).toMatchObject({ destructiveHint: false, idempotentHint: true });
|
|
756
756
|
});
|
|
757
757
|
|
|
758
758
|
it('should hand the model the API message when the publish gate refuses the draft', async () => {
|
|
@@ -2904,8 +2904,9 @@ describe('MCP server integration', () => {
|
|
|
2904
2904
|
}
|
|
2905
2905
|
// A section id points at the read tool of its own scope, which lists the valid ids.
|
|
2906
2906
|
const sectionHelp = (name: string) =>
|
|
2907
|
-
(tools.find((t) => t.name === name)!.inputSchema.properties as Record<string, { description?: string }>)
|
|
2908
|
-
|
|
2907
|
+
(tools.find((t) => t.name === name)!.inputSchema.properties as Record<string, { description?: string }>)[
|
|
2908
|
+
'sectionId'
|
|
2909
|
+
]!.description;
|
|
2909
2910
|
expect(sectionHelp('taiga_update_document')).toContain('taiga_get_document,');
|
|
2910
2911
|
expect(sectionHelp('taiga_update_factory_document')).toContain('taiga_get_factory_document');
|
|
2911
2912
|
expect(sectionHelp('taiga_update_product_document')).toContain('taiga_get_product_document');
|
|
@@ -3323,7 +3324,7 @@ describe('MCP server integration', () => {
|
|
|
3323
3324
|
const move = tools.find((t) => t.name === 'taiga_move_backlog_item')!;
|
|
3324
3325
|
const props = move.inputSchema.properties as Record<string, { enum?: string[] }>;
|
|
3325
3326
|
// The queue is the only way to start work: no "plan this now", nothing dropped into Build.
|
|
3326
|
-
expect(props
|
|
3327
|
+
expect(props['state']!.enum).toEqual(['backlog', 'todo', 'queue', 'done', 'canceled']);
|
|
3327
3328
|
|
|
3328
3329
|
const before = fetchSpy.mock.calls.length;
|
|
3329
3330
|
for (const state of ['build', 'plan']) {
|
|
@@ -5201,7 +5202,7 @@ describe('MCP server integration', () => {
|
|
|
5201
5202
|
commitSha: 'abc',
|
|
5202
5203
|
commitSubject: 'Merge #1',
|
|
5203
5204
|
artifacts: {},
|
|
5204
|
-
metadata: { big: true },
|
|
5205
|
+
metadata: { big: true, repositoryFullName: 'acme/api' },
|
|
5205
5206
|
};
|
|
5206
5207
|
const { client, fetchSpy } = await bootMcpPair({
|
|
5207
5208
|
capabilities: ['products:read'],
|
|
@@ -5246,8 +5247,11 @@ describe('MCP server integration', () => {
|
|
|
5246
5247
|
});
|
|
5247
5248
|
expect(result.environments.map((e: { id: string }) => e.id)).toEqual([ENV_ID, ENV_ID_2]);
|
|
5248
5249
|
expect(result).not.toHaveProperty('moreEnvironments');
|
|
5249
|
-
|
|
5250
|
+
// Named by the repository that deployed it: each runs its own in an
|
|
5251
|
+
// environment (PRO-2528). The rest of the metadata stays behind.
|
|
5252
|
+
expect(result.running[0]).toMatchObject({ id: 'd1', commitSubject: 'Merge #1', repository: 'acme/api' });
|
|
5250
5253
|
expect(result.running[0]).not.toHaveProperty('metadata');
|
|
5254
|
+
expect(result.history.data[0]).toMatchObject({ id: 'd1', repository: 'acme/api' });
|
|
5251
5255
|
expect(result.history).toMatchObject({ nextCursor: 'more' });
|
|
5252
5256
|
expect(result.syncStatus).toBeNull();
|
|
5253
5257
|
expect(result.syncUnavailable).toContain('500');
|
|
@@ -6846,7 +6850,7 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
6846
6850
|
const { tools } = await client.listTools();
|
|
6847
6851
|
const metrics = tools.find((t) => t.name === 'taiga_get_delivery_metrics')!;
|
|
6848
6852
|
const properties = metrics.inputSchema.properties as Record<string, { enum?: unknown }>;
|
|
6849
|
-
expect(properties
|
|
6853
|
+
expect(properties['windowDays']!.enum).toEqual([30, 60, 90]);
|
|
6850
6854
|
});
|
|
6851
6855
|
|
|
6852
6856
|
it('opens every writing tool with what it does, and leaves the reads as they were', async () => {
|
package/src/tools/autonomy.ts
CHANGED
|
@@ -15,8 +15,6 @@ import { pickFields } from './summarize.js';
|
|
|
15
15
|
* is taiga_set_auto_build.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
type Row = Record<string, unknown>;
|
|
19
|
-
|
|
20
18
|
export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
|
|
21
19
|
if (client.hasCapability('products:write')) {
|
|
22
20
|
server.registerTool(
|
|
@@ -54,7 +52,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
|
|
|
54
52
|
...(params.autoBuild !== undefined ? { autoBuild: params.autoBuild } : {}),
|
|
55
53
|
...(autoMerge !== undefined ? { autoMerge } : {}),
|
|
56
54
|
})
|
|
57
|
-
).data
|
|
55
|
+
).data;
|
|
58
56
|
return toolResult(pickFields([product], ['id', 'name', 'slug', 'autoBuild', 'autoMerge'])[0]);
|
|
59
57
|
})
|
|
60
58
|
);
|
|
@@ -82,7 +80,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
|
|
|
82
80
|
},
|
|
83
81
|
(params) =>
|
|
84
82
|
runTool(async () => {
|
|
85
|
-
const item = (await client.updateBacklogItem(params.itemId, { autoMerge: params.autoMerge })).data
|
|
83
|
+
const item = (await client.updateBacklogItem(params.itemId, { autoMerge: params.autoMerge })).data;
|
|
86
84
|
return toolResult(pickFields([item], ['id', 'title', 'state', 'autoMerge', 'autoBuild'])[0]);
|
|
87
85
|
})
|
|
88
86
|
);
|
package/src/tools/backlogs.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 { ActivityEvent, ActivityActor, BacklogItem } from '@taiga/types';
|
|
4
|
+
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
4
5
|
import { PLAN_REJECTION_COMMENT_MAX_CHARS, PR_CLOSING_STATES, USER_DROP_STATES, resolveAutoBuild } from '@taiga/types';
|
|
5
6
|
import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
6
7
|
import { idInput, pagination, productId } from './inputs.js';
|
|
@@ -83,16 +84,16 @@ async function planFailureOf(
|
|
|
83
84
|
itemId: string,
|
|
84
85
|
itemCause: unknown
|
|
85
86
|
): Promise<Record<string, unknown> | null> {
|
|
86
|
-
let events:
|
|
87
|
+
let events: WithoutTenant<ActivityEvent>[];
|
|
87
88
|
try {
|
|
88
|
-
events = (await client.listBacklogItemActivity(itemId)).data
|
|
89
|
+
events = (await client.listBacklogItemActivity(itemId)).data;
|
|
89
90
|
} catch (error) {
|
|
90
91
|
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
91
92
|
throw error;
|
|
92
93
|
}
|
|
93
94
|
// Only a failure of the latest attempt: one before the last plan_started
|
|
94
95
|
// belongs to an earlier attempt, even when it failed for the same reason.
|
|
95
|
-
let failed:
|
|
96
|
+
let failed: WithoutTenant<ActivityEvent> | undefined;
|
|
96
97
|
for (const event of [...events].reverse()) {
|
|
97
98
|
if (event.kind !== 'turn') continue;
|
|
98
99
|
if (event.trigger === 'plan_started') break;
|
|
@@ -102,8 +103,8 @@ async function planFailureOf(
|
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
if (!failed) return null;
|
|
105
|
-
const actor =
|
|
106
|
-
const data =
|
|
106
|
+
const actor: Partial<ActivityActor> = failed.actor ?? {};
|
|
107
|
+
const data: { cause?: unknown } = failed.data ?? {};
|
|
107
108
|
const cause = typeof data.cause === 'string' ? data.cause : null;
|
|
108
109
|
// The event is recorded best effort: one that disagrees with the item's own
|
|
109
110
|
// cause belongs to an earlier failure, and its who and when are not this one's.
|
|
@@ -140,13 +141,12 @@ const PLAN_STEP_SUMMARY_FIELDS = [
|
|
|
140
141
|
'createsResources',
|
|
141
142
|
] as const;
|
|
142
143
|
|
|
144
|
+
type Item = WithoutTenant<BacklogItem>;
|
|
145
|
+
|
|
143
146
|
/** The outcome fields of an initiative, with how many of its criteria are met. */
|
|
144
|
-
const outcomeOf = (item:
|
|
145
|
-
const row = item as Record<string, unknown>;
|
|
146
|
-
return { ...pickFields([row], ITEM_OUTCOME_FIELDS)[0], criteria: criteriaCounts(row) };
|
|
147
|
-
};
|
|
147
|
+
const outcomeOf = (item: Item) => ({ ...pickFields([item], ITEM_OUTCOME_FIELDS)[0], criteria: criteriaCounts(item) });
|
|
148
148
|
|
|
149
|
-
const outcome = (item:
|
|
149
|
+
const outcome = (item: Item) => toolResult(outcomeOf(item));
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
152
|
* Each tool is registered under the capability its route requires, so a
|
|
@@ -218,9 +218,7 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
218
218
|
const { query, ...filters } = params;
|
|
219
219
|
if (query === undefined) {
|
|
220
220
|
const result = await client.listBacklogItems(params.productId, filters);
|
|
221
|
-
return toolResult(
|
|
222
|
-
page(itemRows(result.data as Record<string, unknown>[], BACKLOG_SUMMARY_FIELDS), result.nextCursor)
|
|
223
|
-
);
|
|
221
|
+
return toolResult(page(itemRows(result.data, BACKLOG_SUMMARY_FIELDS), result.nextCursor));
|
|
224
222
|
}
|
|
225
223
|
if (params.cursor !== undefined) {
|
|
226
224
|
return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
|
|
@@ -255,9 +253,12 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
255
253
|
},
|
|
256
254
|
(params) =>
|
|
257
255
|
runTool(async () => {
|
|
258
|
-
const item = (await client.getBacklogItem(params.itemId)).data
|
|
256
|
+
const item = (await client.getBacklogItem(params.itemId)).data;
|
|
259
257
|
if (item.planningStatus !== 'failed') return toolResult(item);
|
|
260
|
-
|
|
258
|
+
// Always undefined: the API sends the cause only under `lifecycle`, so
|
|
259
|
+
// planFailureOf's cross-check never fires (PRO-2799). Kept as it was.
|
|
260
|
+
const ownCause = (item as { planFailureCause?: unknown }).planFailureCause;
|
|
261
|
+
const planFailure = await planFailureOf(client, params.itemId, ownCause);
|
|
261
262
|
return toolResult(planFailure ? { ...item, planFailure } : item);
|
|
262
263
|
})
|
|
263
264
|
);
|
|
@@ -281,9 +282,9 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
281
282
|
},
|
|
282
283
|
(params) =>
|
|
283
284
|
runTool(async () => {
|
|
284
|
-
const plan = (await client.getActivePlan(params.itemId)).data
|
|
285
|
+
const plan = (await client.getActivePlan(params.itemId)).data;
|
|
285
286
|
if (params.full) return toolResult(plan);
|
|
286
|
-
const steps = Array.isArray(plan.steps) ?
|
|
287
|
+
const steps = Array.isArray(plan.steps) ? plan.steps : [];
|
|
287
288
|
return toolResult({
|
|
288
289
|
...pickFields([plan], PLAN_SUMMARY_FIELDS)[0],
|
|
289
290
|
steps: pickFields(steps, PLAN_STEP_SUMMARY_FIELDS),
|
|
@@ -331,7 +332,7 @@ function registerRequest(server: McpServer, client: TaigaClient) {
|
|
|
331
332
|
targetId: params.productId,
|
|
332
333
|
...(userPrompt ? { metadata: { userPrompt } } : {}),
|
|
333
334
|
})
|
|
334
|
-
).data
|
|
335
|
+
).data;
|
|
335
336
|
return toolResult(jobReport(job));
|
|
336
337
|
})
|
|
337
338
|
);
|
|
@@ -415,7 +416,7 @@ function registerPlanAgain(server: McpServer, client: TaigaClient) {
|
|
|
415
416
|
},
|
|
416
417
|
(params) =>
|
|
417
418
|
runTool(async () => {
|
|
418
|
-
const item = (await client.getBacklogItem(params.itemId)).data
|
|
419
|
+
const item = (await client.getBacklogItem(params.itemId)).data;
|
|
419
420
|
if (!BUILD_STATION.has(String(item.state))) {
|
|
420
421
|
return toolError(
|
|
421
422
|
'CONFLICT',
|
|
@@ -437,12 +438,11 @@ const isApiFailure = (error: unknown) => error instanceof TaigaApiError || isNet
|
|
|
437
438
|
* decided it: its own setting, or the product's default when it has none.
|
|
438
439
|
* Null when the product cannot be read; the outcome then says so instead.
|
|
439
440
|
*/
|
|
440
|
-
async function reviewGate(client: TaigaClient, item:
|
|
441
|
+
async function reviewGate(client: TaigaClient, item: Item) {
|
|
441
442
|
const own = typeof item.autoBuild === 'boolean' ? item.autoBuild : null;
|
|
442
443
|
try {
|
|
443
444
|
// The product's default answers only when the initiative has no answer of its own.
|
|
444
|
-
const product =
|
|
445
|
-
own === null ? ((await client.getProduct(String(item.productId))).data as { autoBuild?: unknown }) : {};
|
|
445
|
+
const product: { autoBuild?: boolean | null } = own === null ? (await client.getProduct(item.productId)).data : {};
|
|
446
446
|
// Absent on the product reads as its true default (@taiga/types).
|
|
447
447
|
const productDefault = typeof product.autoBuild === 'boolean' ? product.autoBuild : true;
|
|
448
448
|
const builds = resolveAutoBuild({ initiative: own, product: productDefault });
|
|
@@ -460,9 +460,9 @@ async function reviewGate(client: TaigaClient, item: Record<string, unknown>) {
|
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
/** Where a queued initiative stands in its product's line; null, and the outcome says so, when the queue cannot be read. */
|
|
463
|
-
async function placeInQueue(client: TaigaClient, item:
|
|
463
|
+
async function placeInQueue(client: TaigaClient, item: Item) {
|
|
464
464
|
try {
|
|
465
|
-
const view = await queueView(client,
|
|
465
|
+
const view = await queueView(client, item.productId, { name: 'blocking' });
|
|
466
466
|
const entry = view.entries.find((row) => row.backlogItemId === item.id);
|
|
467
467
|
return { position: entry?.position ?? null, of: view.entries.length, waitingOn: view.waitingOn };
|
|
468
468
|
} catch (error) {
|
|
@@ -519,10 +519,7 @@ function registerQueue(server: McpServer, client: TaigaClient) {
|
|
|
519
519
|
if (autoBuild !== undefined) await client.setAutoBuild(id, autoBuild);
|
|
520
520
|
// The move the web makes: one write that answers with the refreshed
|
|
521
521
|
// item, rather than the queue route plus two reads around it.
|
|
522
|
-
const item = (await client.transitionInitiative(id, { state: 'queue', ...placement })).data
|
|
523
|
-
string,
|
|
524
|
-
unknown
|
|
525
|
-
>;
|
|
522
|
+
const item = (await client.transitionInitiative(id, { state: 'queue', ...placement })).data;
|
|
526
523
|
const [gate, queue] = await Promise.all([reviewGate(client, item), placeInQueue(client, item)]);
|
|
527
524
|
return toolResult({
|
|
528
525
|
...outcomeOf(item),
|
|
@@ -551,7 +548,7 @@ function registerReviewGate(server: McpServer, client: TaigaClient) {
|
|
|
551
548
|
},
|
|
552
549
|
(params) =>
|
|
553
550
|
runTool(async () => {
|
|
554
|
-
const item = (await client.setAutoBuild(params.itemId, params.autoBuild)).data
|
|
551
|
+
const item = (await client.setAutoBuild(params.itemId, params.autoBuild)).data;
|
|
555
552
|
const gate = await reviewGate(client, item);
|
|
556
553
|
return toolResult({
|
|
557
554
|
...outcomeOf(item),
|
package/src/tools/builds.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import type {
|
|
3
|
+
import type { BuildRunStep, BuildRunWithContext } from '@taiga/types';
|
|
4
|
+
import type { TaigaClient, WithoutTenant } from '../client.js';
|
|
4
5
|
import { OPERATE, READ_ONLY } from './annotations.js';
|
|
5
6
|
import { idInput, pagination, productId } from './inputs.js';
|
|
6
7
|
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
@@ -54,7 +55,10 @@ const RUN_OUTCOME_FIELDS = [
|
|
|
54
55
|
* stalled in the hardening pass after the steps and the retry continues it.
|
|
55
56
|
* Null for a run still going or that finished.
|
|
56
57
|
*/
|
|
57
|
-
export function stoppedIn(
|
|
58
|
+
export function stoppedIn(
|
|
59
|
+
run: Pick<WithoutTenant<BuildRunWithContext>, 'prMerged' | 'status' | 'hardeningResult'>,
|
|
60
|
+
steps: readonly Pick<WithoutTenant<BuildRunStep>, 'status' | 'stepOrder' | 'title'>[]
|
|
61
|
+
): string | null {
|
|
58
62
|
// A merged run is finished, whatever its status still says: the merge is
|
|
59
63
|
// latched apart from the status, which can stay at a stale failed or stopped.
|
|
60
64
|
if (run.prMerged === true || (run.status !== 'failed' && run.status !== 'stopped')) return null;
|
|
@@ -131,11 +135,11 @@ async function activityRows(
|
|
|
131
135
|
throw error;
|
|
132
136
|
}),
|
|
133
137
|
]);
|
|
134
|
-
const nameById = new Map<string, { name:
|
|
135
|
-
for (const product of
|
|
136
|
-
nameById.set(
|
|
138
|
+
const nameById = new Map<string, { name: string; slug: string }>();
|
|
139
|
+
for (const product of products?.data ?? []) {
|
|
140
|
+
nameById.set(product.id, { name: product.name, slug: product.slug });
|
|
137
141
|
}
|
|
138
|
-
const rows = pickFields(runs.data
|
|
142
|
+
const rows = pickFields(runs.data, BUILD_SUMMARY_FIELDS).map((row) => {
|
|
139
143
|
const product = nameById.get(String(row.productId));
|
|
140
144
|
return product ? { ...row, product } : row;
|
|
141
145
|
});
|
|
@@ -204,9 +208,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
204
208
|
}
|
|
205
209
|
if (params.productId) {
|
|
206
210
|
const result = await client.listBuildRuns(params.productId, params);
|
|
207
|
-
return toolResult(
|
|
208
|
-
page(pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS), result.nextCursor)
|
|
209
|
-
);
|
|
211
|
+
return toolResult(page(pickFields(result.data, BUILD_SUMMARY_FIELDS), result.nextCursor));
|
|
210
212
|
}
|
|
211
213
|
return toolResult(await activityRows(client, params));
|
|
212
214
|
})
|
|
@@ -243,8 +245,8 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
243
245
|
client.getBuildRun(params.buildId),
|
|
244
246
|
client.listBuildRunSteps(params.buildId),
|
|
245
247
|
]);
|
|
246
|
-
const runRow = run.data
|
|
247
|
-
const rows = steps.data
|
|
248
|
+
const runRow = run.data;
|
|
249
|
+
const rows = steps.data;
|
|
248
250
|
const where = stoppedIn(runRow, rows);
|
|
249
251
|
const stop = where ? { stoppedIn: where } : {};
|
|
250
252
|
if (params.full) return toolResult({ ...runRow, ...stop, steps: steps.data });
|
|
@@ -278,7 +280,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
278
280
|
(params) =>
|
|
279
281
|
runTool(async () => {
|
|
280
282
|
const result = await client.stopBuildRun(params.buildId);
|
|
281
|
-
return toolResult(pickFields([result.data
|
|
283
|
+
return toolResult(pickFields([result.data], RUN_OUTCOME_FIELDS)[0]);
|
|
282
284
|
})
|
|
283
285
|
);
|
|
284
286
|
}
|
|
@@ -104,7 +104,7 @@ describe('tool descriptions against the catalogue', () => {
|
|
|
104
104
|
const tools = await allTools();
|
|
105
105
|
const list = tools.find((t) => t.name === 'taiga_list_documents')!;
|
|
106
106
|
const get = tools.find((t) => t.name === 'taiga_get_document')!;
|
|
107
|
-
const typeEnum = (get.inputSchema.properties as Record<string, { enum?: string[] }>)
|
|
107
|
+
const typeEnum = (get.inputSchema.properties as Record<string, { enum?: string[] }>)['type']!.enum;
|
|
108
108
|
|
|
109
109
|
for (const type of POLICY_TYPES) {
|
|
110
110
|
expect(list.description, type).toContain(type);
|
|
@@ -118,10 +118,10 @@ describe('tool descriptions against the catalogue', () => {
|
|
|
118
118
|
const props = (name: string) =>
|
|
119
119
|
tools.find((t) => t.name === name)!.inputSchema.properties as Record<string, { enum?: string[] }>;
|
|
120
120
|
|
|
121
|
-
expect(props('taiga_list_products')
|
|
122
|
-
expect(props('taiga_get_product_document')
|
|
123
|
-
expect(props('taiga_get_product_document')
|
|
124
|
-
expect(props('taiga_get_factory_document')
|
|
121
|
+
expect(props('taiga_list_products')['status']!.enum).toEqual([...PRODUCT_STATUSES]);
|
|
122
|
+
expect(props('taiga_get_product_document')['type']!.enum).toEqual([...PRODUCT_DOCUMENT_TYPES]);
|
|
123
|
+
expect(props('taiga_get_product_document')['origin']!.enum).toEqual(['plan', 'reality']);
|
|
124
|
+
expect(props('taiga_get_factory_document')['type']!.enum).toEqual([...FACTORY_DOCUMENT_TYPES]);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
it('has a display name for every product document type, the one the web uses', () => {
|
package/src/tools/context.ts
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import type { McpServer, CallToolResult } from '@modelcontextprotocol/server';
|
|
2
2
|
import { isSettledOrArchived } from '@taiga/types';
|
|
3
|
+
import type { BacklogItem, Product } from '@taiga/types';
|
|
3
4
|
import { z } from 'zod';
|
|
4
|
-
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
5
|
+
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
5
6
|
import { READ_ONLY } from './annotations.js';
|
|
6
7
|
import { BACKLOG_SUMMARY_FIELDS } from './backlogs.js';
|
|
7
|
-
import {
|
|
8
|
-
POLICY_TYPES,
|
|
9
|
-
defaultOriginFor,
|
|
10
|
-
isImportedProduct,
|
|
11
|
-
setupNextStep,
|
|
12
|
-
type ProductSettingsView,
|
|
13
|
-
} from './catalogue.js';
|
|
8
|
+
import { POLICY_TYPES, defaultOriginFor, isImportedProduct, setupNextStep } from './catalogue.js';
|
|
14
9
|
import { apiFailureText, isNetworkFailure, networkFailureDetail, runTool, toolError, toolResult } from './errors.js';
|
|
15
10
|
import { productId, remoteUrl, slug } from './inputs.js';
|
|
16
11
|
import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
|
|
17
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
budgetSections,
|
|
14
|
+
instructionsText,
|
|
15
|
+
isRecord,
|
|
16
|
+
sectionContent,
|
|
17
|
+
sectionsOf,
|
|
18
|
+
stripProvenance,
|
|
19
|
+
type DocumentData,
|
|
20
|
+
type StoredDocument,
|
|
21
|
+
} from './sections.js';
|
|
18
22
|
import { itemRows, pickFields } from './summarize.js';
|
|
19
23
|
|
|
20
24
|
/**
|
|
@@ -72,7 +76,7 @@ export const DOCUMENT_BUDGET = 12_000;
|
|
|
72
76
|
* which a session's opening read does not need. The full read keeps them.
|
|
73
77
|
* Says whether any were dropped, so the bundle can tell the reader.
|
|
74
78
|
*/
|
|
75
|
-
function withoutCitations(doc:
|
|
79
|
+
function withoutCitations(doc: StoredDocument): { doc: StoredDocument; dropped: boolean } {
|
|
76
80
|
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
77
81
|
if (!sections) return { doc, dropped: false };
|
|
78
82
|
let dropped = false;
|
|
@@ -84,7 +88,7 @@ function withoutCitations(doc: Record<string, unknown>): { doc: Record<string, u
|
|
|
84
88
|
return [id, rest];
|
|
85
89
|
})
|
|
86
90
|
);
|
|
87
|
-
return { doc: { ...doc, data: { ...(doc.data as
|
|
91
|
+
return { doc: { ...doc, data: { ...(doc.data as DocumentData), sections: kept } }, dropped };
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/**
|
|
@@ -133,7 +137,7 @@ function isNotFound(reason: unknown): boolean {
|
|
|
133
137
|
|
|
134
138
|
/** The factory's standing guidance in full; null, with a note, when none is published or it is not section-shaped. */
|
|
135
139
|
async function factoryPart(client: TaigaClient, factoryId: string) {
|
|
136
|
-
const factory = (await client.getFactory(factoryId)).data
|
|
140
|
+
const factory = (await client.getFactory(factoryId)).data;
|
|
137
141
|
const base = { id: factory.id, name: factory.name, slug: factory.slug };
|
|
138
142
|
try {
|
|
139
143
|
const doc = (await client.getFactoryDocument(factoryId, 'factory_instructions')).data;
|
|
@@ -165,9 +169,9 @@ async function factoryPart(client: TaigaClient, factoryId: string) {
|
|
|
165
169
|
* outline by default, the whole sections up to a budget or in full when
|
|
166
170
|
* asked. Null, with a note, where unpublished.
|
|
167
171
|
*/
|
|
168
|
-
async function documentsPart(client: TaigaClient, product:
|
|
169
|
-
const productId =
|
|
170
|
-
const origin = defaultOriginFor(product
|
|
172
|
+
async function documentsPart(client: TaigaClient, product: WithoutTenant<Product>, depth: DocumentDepth) {
|
|
173
|
+
const productId = product.id;
|
|
174
|
+
const origin = defaultOriginFor(product);
|
|
171
175
|
const reads = await Promise.allSettled(
|
|
172
176
|
CONTEXT_DOCUMENT_TYPES.map((type) => client.getProductDocument(productId, type, origin))
|
|
173
177
|
);
|
|
@@ -176,7 +180,7 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
|
|
|
176
180
|
CONTEXT_DOCUMENT_TYPES.forEach((type, index) => {
|
|
177
181
|
const read = reads[index]!;
|
|
178
182
|
if (read.status === 'fulfilled') {
|
|
179
|
-
const doc = stripProvenance(read.value.data
|
|
183
|
+
const doc = stripProvenance(read.value.data);
|
|
180
184
|
if (depth === 'full') out[type] = doc;
|
|
181
185
|
else if (depth === 'budget') {
|
|
182
186
|
const lean = withoutCitations(doc);
|
|
@@ -200,7 +204,7 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
|
|
|
200
204
|
);
|
|
201
205
|
} else out[type] = cut.doc;
|
|
202
206
|
} else {
|
|
203
|
-
const sections = sectionsOf(
|
|
207
|
+
const sections = sectionsOf(doc.data ?? {});
|
|
204
208
|
if (sections) {
|
|
205
209
|
// Each id with the length of its text, so the next read is sized before it is
|
|
206
210
|
// made (a reality section's citations come on top).
|
|
@@ -224,14 +228,14 @@ async function documentsPart(client: TaigaClient, product: Record<string, unknow
|
|
|
224
228
|
|
|
225
229
|
/** Every published policy as its section ids, so the model knows which one to read with taiga_get_document. */
|
|
226
230
|
async function policiesPart(client: TaigaClient) {
|
|
227
|
-
const rows = (await client.listTenantDocuments({ limit: 100 })).data
|
|
231
|
+
const rows = (await client.listTenantDocuments({ limit: 100 })).data;
|
|
228
232
|
return rows
|
|
229
233
|
.filter((row) => (POLICY_TYPES as readonly string[]).includes(String(row.type)) && row.isDraft === false)
|
|
230
234
|
.map((row) => ({
|
|
231
235
|
type: row.type,
|
|
232
236
|
name: row.name,
|
|
233
237
|
version: row.version,
|
|
234
|
-
sections: Object.keys(sectionsOf(
|
|
238
|
+
sections: Object.keys(sectionsOf(row.data ?? {}) ?? {}),
|
|
235
239
|
}));
|
|
236
240
|
}
|
|
237
241
|
|
|
@@ -249,7 +253,7 @@ const OPEN_BACKLOG_STATUSES = ['backlog', 'in_progress'] as const;
|
|
|
249
253
|
async function backlogPart(client: TaigaClient, productId: string) {
|
|
250
254
|
// Keyed by id: an item that moves from backlog to in_progress between the
|
|
251
255
|
// two reads would otherwise appear twice.
|
|
252
|
-
const open = new Map<string,
|
|
256
|
+
const open = new Map<string, WithoutTenant<BacklogItem>>();
|
|
253
257
|
let truncated = false;
|
|
254
258
|
let pageCapped = false;
|
|
255
259
|
for (const status of OPEN_BACKLOG_STATUSES) {
|
|
@@ -261,10 +265,8 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
261
265
|
break;
|
|
262
266
|
}
|
|
263
267
|
const result = await client.listBacklogItems(productId, { status, limit: 100, cursor });
|
|
264
|
-
for (const row of result.data
|
|
265
|
-
if (
|
|
266
|
-
!isSettledOrArchived({ status: String(row.status), archivedAt: (row.archivedAt as string | null) ?? null })
|
|
267
|
-
) {
|
|
268
|
+
for (const row of result.data) {
|
|
269
|
+
if (!isSettledOrArchived({ status: String(row.status), archivedAt: row.archivedAt ?? null })) {
|
|
268
270
|
open.set(String(row.id), row);
|
|
269
271
|
}
|
|
270
272
|
}
|
|
@@ -301,22 +303,22 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
301
303
|
async function repositoriesPart(client: TaigaClient, productId: string) {
|
|
302
304
|
const result = await client.listProductRepositories(productId);
|
|
303
305
|
return {
|
|
304
|
-
items: pickFields(result.data
|
|
306
|
+
items: pickFields(result.data, REPOSITORY_SUMMARY_FIELDS),
|
|
305
307
|
truncated: result.nextCursor !== null,
|
|
306
308
|
};
|
|
307
309
|
}
|
|
308
310
|
|
|
309
311
|
async function buildsPart(client: TaigaClient, productId: string) {
|
|
310
|
-
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data
|
|
312
|
+
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data;
|
|
311
313
|
return pickFields(rows, CONTEXT_BUILD_FIELDS);
|
|
312
314
|
}
|
|
313
315
|
|
|
314
316
|
export async function productContext(
|
|
315
317
|
client: TaigaClient,
|
|
316
|
-
product:
|
|
318
|
+
product: WithoutTenant<Product>,
|
|
317
319
|
options: { documents: DocumentDepth }
|
|
318
320
|
): Promise<CallToolResult> {
|
|
319
|
-
const productId =
|
|
321
|
+
const productId = product.id;
|
|
320
322
|
const granted = (part: Part) => client.hasCapability(PARTS[part]);
|
|
321
323
|
const notGranted = (Object.keys(PARTS) as Part[]).filter((part) => !granted(part));
|
|
322
324
|
|
|
@@ -335,8 +337,8 @@ export async function productContext(
|
|
|
335
337
|
const bundle: Record<string, unknown> = {
|
|
336
338
|
product: {
|
|
337
339
|
...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
|
|
338
|
-
projectType: isImportedProduct(product
|
|
339
|
-
defaultOrigin: defaultOriginFor(product
|
|
340
|
+
projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
|
|
341
|
+
defaultOrigin: defaultOriginFor(product),
|
|
340
342
|
// A product in setup says what it waits for: nothing else in the bundle does.
|
|
341
343
|
...(nextStep ? { nextStep } : {}),
|
|
342
344
|
},
|
package/src/tools/documents.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import type { FactoryDocument, TenantDocument } from '@taiga/types';
|
|
2
3
|
import { z } from 'zod';
|
|
3
|
-
import { TaigaApiError, type TaigaClient, type TaigaDocument } from '../client.js';
|
|
4
|
+
import { TaigaApiError, type TaigaClient, type TaigaDocument, type WithoutTenant } from '../client.js';
|
|
4
5
|
import { READ_ONLY } from './annotations.js';
|
|
5
6
|
import { idInput, pagination } from './inputs.js';
|
|
6
7
|
import {
|
|
@@ -27,7 +28,10 @@ import { page, pickFields } from './summarize.js';
|
|
|
27
28
|
*/
|
|
28
29
|
const DOCUMENT_SUMMARY_FIELDS = ['type', 'name', 'description', 'version', 'isDraft', 'hasDraft', 'updatedAt'] as const;
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
/** A row of the company or factory document list; product documents list through their own tools. */
|
|
32
|
+
type ListedDocument = WithoutTenant<TenantDocument | FactoryDocument>;
|
|
33
|
+
|
|
34
|
+
const summaries = (rows: ListedDocument[]) => pickFields(rows, DOCUMENT_SUMMARY_FIELDS);
|
|
31
35
|
|
|
32
36
|
/** What a lookup behind a miss said when it failed, so an unknown stays unknown. */
|
|
33
37
|
function failedLookup(what: string, reason: unknown): string {
|
|
@@ -36,10 +40,10 @@ function failedLookup(what: string, reason: unknown): string {
|
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/** `a (v3), b (v1)` for a not-found message. */
|
|
39
|
-
function publishedList(rows:
|
|
43
|
+
function publishedList(rows: ListedDocument[]): string {
|
|
40
44
|
// The list routes return the latest draft rows beside the published ones,
|
|
41
45
|
// and a draft named as published sends the model into a second miss.
|
|
42
|
-
return
|
|
46
|
+
return rows
|
|
43
47
|
.filter((row) => row.isDraft === false)
|
|
44
48
|
.map((row) => `${row.type} (v${String(row.version)})`)
|
|
45
49
|
.join(', ');
|
package/src/tools/hints.ts
CHANGED
|
@@ -6,8 +6,11 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
6
6
|
* hears only that guesses another ID, or a name. Each ID a tool takes names
|
|
7
7
|
* the tool that lists the right ones; the tool's arguments say which apply.
|
|
8
8
|
*/
|
|
9
|
+
const ITEM_ID_HINT =
|
|
10
|
+
"taiga_list_backlog_items lists a product's initiatives with their IDs; a run or plan ID is not one.";
|
|
11
|
+
|
|
9
12
|
const BY_ARGUMENT: Record<string, string> = {
|
|
10
|
-
itemId:
|
|
13
|
+
itemId: ITEM_ID_HINT,
|
|
11
14
|
backlogItemId: "taiga_list_backlog_items lists a product's initiatives with their IDs.",
|
|
12
15
|
buildId: 'taiga_list_builds lists the runs, of one product or of every product the key can see.',
|
|
13
16
|
planId: "taiga_get_plan gives the initiative's active plan and its ID.",
|
|
@@ -40,7 +43,7 @@ const BY_TOOL: Record<string, Hint> = {
|
|
|
40
43
|
/\bplan\b/i.test(said)
|
|
41
44
|
? 'taiga_get_backlog_item says whether one is being generated (planningStatus planning) or failed, and ' +
|
|
42
45
|
'taiga_plan_again asks for a new one.'
|
|
43
|
-
:
|
|
46
|
+
: ITEM_ID_HINT,
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
/** The hint for a 404 from one tool call: the tool's own, else one per ID it was given. */
|
package/src/tools/inbox.ts
CHANGED
|
@@ -14,8 +14,6 @@ import { page, pickFields } from './summarize.js';
|
|
|
14
14
|
* everybody, so these tools say what they clear.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
type Row = Record<string, unknown>;
|
|
18
|
-
|
|
19
17
|
const ITEM_FIELDS = [
|
|
20
18
|
'id',
|
|
21
19
|
'productId',
|
|
@@ -95,7 +93,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
95
93
|
// one until its snooze ends, and so does this, so an agent does not
|
|
96
94
|
// act on what the team put off.
|
|
97
95
|
const now = Date.now();
|
|
98
|
-
const rows = result.data
|
|
96
|
+
const rows = result.data;
|
|
99
97
|
const due = rows.filter(
|
|
100
98
|
(row) =>
|
|
101
99
|
!(row.status === 'snoozed' && typeof row.snoozedUntil === 'string' && Date.parse(row.snoozedUntil) > now)
|
|
@@ -139,7 +137,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
139
137
|
...(params.cursor === undefined ? { withTotal: true } : {}),
|
|
140
138
|
});
|
|
141
139
|
return toolResult({
|
|
142
|
-
...page(pickFields(result.data
|
|
140
|
+
...page(pickFields(result.data, SETUP_TASK_FIELDS), result.nextCursor),
|
|
143
141
|
...totalOf(result),
|
|
144
142
|
});
|
|
145
143
|
})
|
|
@@ -181,7 +179,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
181
179
|
...(params.status !== undefined ? { status: params.status } : {}),
|
|
182
180
|
...(params.snoozedUntil !== undefined ? { snoozedUntil: params.snoozedUntil } : {}),
|
|
183
181
|
});
|
|
184
|
-
return toolResult(pickFields([result.data
|
|
182
|
+
return toolResult(pickFields([result.data], ITEM_FIELDS)[0]);
|
|
185
183
|
})
|
|
186
184
|
);
|
|
187
185
|
}
|