@taiga-ai/mcp-server 0.16.0-rc.431 → 0.17.0-dev.439
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 +821 -181
- package/package.json +1 -1
- package/scripts/prepare-publish.cjs +4 -3
- package/src/client.ts +1 -0
- package/src/server.integration.test.ts +374 -17
- package/src/tools/autonomy.ts +3 -3
- package/src/tools/backlogs.ts +68 -23
- package/src/tools/builds.ts +44 -10
- package/src/tools/catalogue.test.ts +11 -1
- package/src/tools/catalogue.ts +64 -3
- package/src/tools/context.ts +12 -2
- package/src/tools/documents.ts +17 -8
- package/src/tools/errors.ts +18 -9
- package/src/tools/hints.ts +71 -0
- package/src/tools/inbox.ts +16 -7
- package/src/tools/index.ts +8 -2
- package/src/tools/inputs.ts +16 -4
- package/src/tools/instructions.ts +40 -14
- package/src/tools/jobs.ts +2 -1
- package/src/tools/knowledge.ts +6 -6
- package/src/tools/maintenance.ts +36 -13
- package/src/tools/observe.ts +48 -7
- package/src/tools/products.ts +65 -40
- package/src/tools/prompts.ts +5 -3
- package/src/tools/repositories.ts +11 -14
- package/src/tools/sections.ts +24 -0
- package/src/tools/setup.ts +82 -15
- package/src/tools/summarize.ts +44 -0
- package/src/tools/wait.ts +4 -3
- package/taiga-ai-mcp-server-0.17.0-dev.439.tgz +0 -0
- package/taiga-ai-mcp-server-0.16.0-rc.431.tgz +0 -0
package/src/tools/backlogs.ts
CHANGED
|
@@ -3,12 +3,21 @@ import { z } from 'zod';
|
|
|
3
3
|
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
4
4
|
import { PLAN_REJECTION_COMMENT_MAX_CHARS, PR_CLOSING_STATES, USER_DROP_STATES, resolveAutoBuild } from '@taiga/types';
|
|
5
5
|
import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
|
|
6
|
-
import { pagination, productId } from './inputs.js';
|
|
6
|
+
import { idInput, pagination, productId } from './inputs.js';
|
|
7
7
|
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
8
8
|
import { jobReport } from './jobs.js';
|
|
9
9
|
import { requestPrompt } from './request-prompt.js';
|
|
10
10
|
import { queueView } from './queue.js';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
SEARCH_NOTE,
|
|
13
|
+
SEARCH_PAGE_SIZE,
|
|
14
|
+
SEARCH_WITH_CURSOR,
|
|
15
|
+
criteriaCounts,
|
|
16
|
+
itemRows,
|
|
17
|
+
page,
|
|
18
|
+
pickFields,
|
|
19
|
+
searchPages,
|
|
20
|
+
} from './summarize.js';
|
|
12
21
|
|
|
13
22
|
/** What a list row needs to say for the model to pick an item to read in full. */
|
|
14
23
|
export const BACKLOG_SUMMARY_FIELDS = [
|
|
@@ -46,7 +55,20 @@ const ITEM_OUTCOME_FIELDS = [
|
|
|
46
55
|
'lifecycle',
|
|
47
56
|
] as const;
|
|
48
57
|
|
|
49
|
-
|
|
58
|
+
/**
|
|
59
|
+
* What the lifecycle on an initiative says, and the call each next step is.
|
|
60
|
+
* needsHumanCause is only the line's flag; a plan that failed outside the line
|
|
61
|
+
* leaves it null, and the lifecycle is what says why and what moves it on.
|
|
62
|
+
*/
|
|
63
|
+
const LIFECYCLE_MEANING =
|
|
64
|
+
'lifecycle says whose turn it is and what moves it on: cta start_plan is taiga_queue_backlog_item, ' +
|
|
65
|
+
'retry_plan taiga_plan_again (planFailureCause says why the plan stopped), start_build taiga_approve_plan, ' +
|
|
66
|
+
'resume_build taiga_resume_backlog_item, retry_build a fresh run (start over, in the web), review_pr and ' +
|
|
67
|
+
'view_pr the pull request. needsHumanCause is set only when the line stopped on it.';
|
|
68
|
+
|
|
69
|
+
const planId = idInput('plan', "taiga_get_plan gives the initiative's active plan and its ID");
|
|
70
|
+
|
|
71
|
+
const itemId = idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it');
|
|
50
72
|
|
|
51
73
|
/** The plan without its steps' prose: enough to approve it or say what a run will do. */
|
|
52
74
|
const PLAN_SUMMARY_FIELDS = ['id', 'backlogItemId', 'version', 'status', 'createdAt'] as const;
|
|
@@ -107,11 +129,19 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
107
129
|
description:
|
|
108
130
|
"List a product's backlog items (initiatives): titles, category, status, where each one is on the " +
|
|
109
131
|
'line (backlog, todo, queue, plan, build, done) and criteria, how many of its acceptance criteria are met ' +
|
|
110
|
-
'(null before it is planned
|
|
111
|
-
'
|
|
132
|
+
'(null before it is planned; met counts the criteria the agent confirmed with evidence or a person checked ' +
|
|
133
|
+
'off, so a done item can show fewer met than it has). Use this to find the item you are working on (query ' +
|
|
134
|
+
'finds one by title), then read its acceptance criteria and technical context with taiga_get_backlog_item. ' +
|
|
112
135
|
'Archived items are left out unless asked for.',
|
|
113
136
|
inputSchema: z.object({
|
|
114
137
|
productId,
|
|
138
|
+
query: z
|
|
139
|
+
.string()
|
|
140
|
+
.trim()
|
|
141
|
+
.min(1)
|
|
142
|
+
.max(200)
|
|
143
|
+
.optional()
|
|
144
|
+
.describe('Part of the title, any case: only the items that contain it'),
|
|
115
145
|
status: z
|
|
116
146
|
.enum(['backlog', 'in_progress', 'done', 'canceled'])
|
|
117
147
|
.optional()
|
|
@@ -128,10 +158,26 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
128
158
|
},
|
|
129
159
|
(params) =>
|
|
130
160
|
runTool(async () => {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
161
|
+
const { query, ...filters } = params;
|
|
162
|
+
if (query === undefined) {
|
|
163
|
+
const result = await client.listBacklogItems(params.productId, filters);
|
|
164
|
+
return toolResult(
|
|
165
|
+
page(itemRows(result.data as Record<string, unknown>[], BACKLOG_SUMMARY_FIELDS), result.nextCursor)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (params.cursor !== undefined) {
|
|
169
|
+
return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
|
|
170
|
+
}
|
|
171
|
+
const found = await searchPages(
|
|
172
|
+
(cursor) => client.listBacklogItems(params.productId, { ...filters, limit: SEARCH_PAGE_SIZE, cursor }),
|
|
173
|
+
query,
|
|
174
|
+
(row) => [row.title],
|
|
175
|
+
params.limit ?? 25
|
|
134
176
|
);
|
|
177
|
+
return toolResult({
|
|
178
|
+
...page(itemRows(found.rows, BACKLOG_SUMMARY_FIELDS), null),
|
|
179
|
+
...(found.more ? { more: SEARCH_NOTE } : {}),
|
|
180
|
+
});
|
|
135
181
|
})
|
|
136
182
|
);
|
|
137
183
|
|
|
@@ -142,9 +188,10 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
142
188
|
description:
|
|
143
189
|
'Get one backlog item in full: description, acceptance criteria (and which are met), technical ' +
|
|
144
190
|
'context (patterns, constraints, and the assumptions to verify before starting), dependencies and source documents. Read this before ' +
|
|
145
|
-
'implementing an item, and read its runs with taiga_list_builds to see what the coding agent already did.'
|
|
191
|
+
'implementing an item, and read its runs with taiga_list_builds to see what the coding agent already did. ' +
|
|
192
|
+
LIFECYCLE_MEANING,
|
|
146
193
|
inputSchema: z.object({
|
|
147
|
-
itemId
|
|
194
|
+
itemId,
|
|
148
195
|
}),
|
|
149
196
|
annotations: READ_ONLY,
|
|
150
197
|
},
|
|
@@ -250,9 +297,7 @@ function registerMove(server: McpServer, client: TaigaClient) {
|
|
|
250
297
|
inputSchema: z.object({
|
|
251
298
|
itemId,
|
|
252
299
|
state: z.enum(USER_DROP_STATES).describe('The column to move into'),
|
|
253
|
-
afterId:
|
|
254
|
-
.string()
|
|
255
|
-
.uuid()
|
|
300
|
+
afterId: idInput('backlog item', 'taiga_list_backlog_items gives it')
|
|
256
301
|
.nullable()
|
|
257
302
|
.optional()
|
|
258
303
|
.describe('The initiative to follow in that column; null for the head; omit to keep rank order'),
|
|
@@ -392,9 +437,7 @@ function registerQueue(server: McpServer, client: TaigaClient) {
|
|
|
392
437
|
'(CONFLICT). Returns the initiative with its new state, its review gate and its place in the queue.',
|
|
393
438
|
inputSchema: z.object({
|
|
394
439
|
itemId,
|
|
395
|
-
afterId:
|
|
396
|
-
.string()
|
|
397
|
-
.uuid()
|
|
440
|
+
afterId: idInput('backlog item', 'taiga_list_backlog_items gives it')
|
|
398
441
|
.nullable()
|
|
399
442
|
.optional()
|
|
400
443
|
.describe(
|
|
@@ -466,11 +509,13 @@ function registerResume(server: McpServer, client: TaigaClient) {
|
|
|
466
509
|
{
|
|
467
510
|
title: 'Resume backlog item',
|
|
468
511
|
description:
|
|
469
|
-
'Resume an initiative the line stopped on
|
|
470
|
-
'planned again, a stopped or failed run resumes from its first
|
|
471
|
-
'committed are kept), a refused build start is cleared and retried. This is the
|
|
472
|
-
'run is the record of an attempt, and decisions about the work are made on the
|
|
473
|
-
'(CONFLICT) when nothing is flagged or the cause has no resume
|
|
512
|
+
'Resume an initiative the line stopped on, which is when needsHumanCause is set (the line sets it): a ' +
|
|
513
|
+
'failed or cancelled plan is planned again, a stopped or failed run resumes from its first unfinished ' +
|
|
514
|
+
'part (the steps already committed are kept), a refused build start is cleared and retried. This is the ' +
|
|
515
|
+
'way to carry work on: a run is the record of an attempt, and decisions about the work are made on the ' +
|
|
516
|
+
'initiative. Refused (CONFLICT) when nothing is flagged or the cause has no resume: a plan that failed ' +
|
|
517
|
+
'with needsHumanCause null (lifecycle.cta retry_plan, planFailureCause says why) is planned again with ' +
|
|
518
|
+
'taiga_plan_again. Starts paid work; ask the person first.',
|
|
474
519
|
inputSchema: z.object({ itemId }),
|
|
475
520
|
annotations: OPERATE,
|
|
476
521
|
},
|
|
@@ -488,7 +533,7 @@ function registerPlanVerdicts(server: McpServer, client: TaigaClient) {
|
|
|
488
533
|
'button makes. Read the plan first with taiga_get_plan and name its ID; an approval of a plan that ' +
|
|
489
534
|
'was replaced since is refused (CONFLICT), as is one while a new plan is being generated or a build is ' +
|
|
490
535
|
'already running. Starts a coding run that pushes commits and opens a pull request; ask the person first.',
|
|
491
|
-
inputSchema: z.object({ planId
|
|
536
|
+
inputSchema: z.object({ planId }),
|
|
492
537
|
annotations: OPERATE,
|
|
493
538
|
},
|
|
494
539
|
(params) => runTool(async () => outcome((await client.approvePlan(params.planId)).data))
|
|
@@ -503,7 +548,7 @@ function registerPlanVerdicts(server: McpServer, client: TaigaClient) {
|
|
|
503
548
|
'feedback and the rejected plan as its starting point, which supersedes it when the new one lands. ' +
|
|
504
549
|
'Read the plan first with taiga_get_plan and name its ID. Starts a planner run; ask the person first.',
|
|
505
550
|
inputSchema: z.object({
|
|
506
|
-
planId
|
|
551
|
+
planId,
|
|
507
552
|
comment: z
|
|
508
553
|
.string()
|
|
509
554
|
.trim()
|
package/src/tools/builds.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/server';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { TaigaClient } from '../client.js';
|
|
4
4
|
import { OPERATE, READ_ONLY } from './annotations.js';
|
|
5
|
-
import { pagination, productId } from './inputs.js';
|
|
5
|
+
import { idInput, pagination, productId } from './inputs.js';
|
|
6
6
|
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
7
7
|
import { TaigaApiError } from '../client.js';
|
|
8
8
|
import { page, pickFields } from './summarize.js';
|
|
@@ -47,10 +47,40 @@ const RUN_OUTCOME_FIELDS = [
|
|
|
47
47
|
'actions',
|
|
48
48
|
] as const;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Where a run that ended without finishing stopped, from its steps and its
|
|
52
|
+
* hardening result: a failed run with every step completed reads as nothing
|
|
53
|
+
* failed, and retry_from_failed_step then reads as a mistake, when the agent
|
|
54
|
+
* stalled in the hardening pass after the steps and the retry continues it.
|
|
55
|
+
* Null for a run still going or that finished.
|
|
56
|
+
*/
|
|
57
|
+
export function stoppedIn(run: Record<string, unknown>, steps: Record<string, unknown>[]): string | null {
|
|
58
|
+
// A merged run is finished, whatever its status still says: the merge is
|
|
59
|
+
// latched apart from the status, which can stay at a stale failed or stopped.
|
|
60
|
+
if (run.prMerged === true || (run.status !== 'failed' && run.status !== 'stopped')) return null;
|
|
61
|
+
// No step started yet: the run never got past setting up (cloning, the
|
|
62
|
+
// workspace), whatever the first step is called.
|
|
63
|
+
const started = steps.some((step) => step.status !== 'planned');
|
|
64
|
+
if (!started) return 'before the first step started, while setting up the run (cloning, preparing the workspace)';
|
|
65
|
+
const open = steps.find((step) => step.status !== 'completed' && step.status !== 'skipped');
|
|
66
|
+
if (open) {
|
|
67
|
+
return open.status === 'planned'
|
|
68
|
+
? `before step ${String(open.stepOrder)}, ${String(open.title)}, started (the ones before it completed)`
|
|
69
|
+
: `step ${String(open.stepOrder)}, ${String(open.title)} (${String(open.status)})`;
|
|
70
|
+
}
|
|
71
|
+
if (run.hardeningResult == null) {
|
|
72
|
+
return (
|
|
73
|
+
'after the last step, in hardening: the security scanners and their fixes the agent runs before the ' +
|
|
74
|
+
'pull request is ready. A retry continues from there; the steps stay.'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return 'after the steps and hardening, at the pull request';
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
/** How many products the org-wide list names; past this a row keeps its productId only. */
|
|
51
81
|
const NAMED_PRODUCTS = 100;
|
|
52
82
|
|
|
53
|
-
const buildId =
|
|
83
|
+
const buildId = idInput('build run', 'taiga_list_builds gives it');
|
|
54
84
|
|
|
55
85
|
/** A step without its instructions: what it is, how it stands and what it changed. */
|
|
56
86
|
const STEP_SUMMARY_FIELDS = [
|
|
@@ -142,9 +172,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
142
172
|
'A run of a deleted initiative keeps its title.',
|
|
143
173
|
inputSchema: z.object({
|
|
144
174
|
productId: productId.optional(),
|
|
145
|
-
backlogItemId:
|
|
146
|
-
.string()
|
|
147
|
-
.uuid()
|
|
175
|
+
backlogItemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it')
|
|
148
176
|
.optional()
|
|
149
177
|
.describe("Only this backlog item's runs -- get from taiga_list_backlog_items; needs productId"),
|
|
150
178
|
active: z
|
|
@@ -162,7 +190,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
162
190
|
runTool(async () => {
|
|
163
191
|
if (!params.productId && params.backlogItemId) {
|
|
164
192
|
return toolError(
|
|
165
|
-
'
|
|
193
|
+
'VALIDATION_ERROR',
|
|
166
194
|
'backlogItemId narrows the runs of one product: pass its productId as well (the item carries it).'
|
|
167
195
|
);
|
|
168
196
|
}
|
|
@@ -170,7 +198,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
170
198
|
// hand the model finished runs as running.
|
|
171
199
|
if (params.productId && params.active) {
|
|
172
200
|
return toolError(
|
|
173
|
-
'
|
|
201
|
+
'VALIDATION_ERROR',
|
|
174
202
|
"active narrows the org-wide list only: omit productId, or read the product's runs and judge each by its status."
|
|
175
203
|
);
|
|
176
204
|
}
|
|
@@ -193,7 +221,9 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
193
221
|
'status, commit, files changed, checks, any error and headline, the first paragraph of what the agent ' +
|
|
194
222
|
"reported. Pass full: true for each step's complete instructions and report, several thousand tokens on " +
|
|
195
223
|
'a long run. Use this to pick up where the coding agent stopped, or to understand the pull request it ' +
|
|
196
|
-
'opened.
|
|
224
|
+
'opened. A run that failed or was stopped says where (stoppedIn: a step, or the hardening pass after ' +
|
|
225
|
+
'them) and when it last made progress (lastProgressAt). Its actions name what the initiative accepts ' +
|
|
226
|
+
'for it: resume and retry_from_failed_step (which continues from the first unfinished part) are ' +
|
|
197
227
|
'taiga_resume_backlog_item, start_over is in the web only for now; to stop it, move the initiative out of ' +
|
|
198
228
|
'Build. A maintenance run has no initiative and is retried from its finding.',
|
|
199
229
|
inputSchema: z.object({
|
|
@@ -213,10 +243,14 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
213
243
|
client.getBuildRun(params.buildId),
|
|
214
244
|
client.listBuildRunSteps(params.buildId),
|
|
215
245
|
]);
|
|
216
|
-
|
|
246
|
+
const runRow = run.data as Record<string, unknown>;
|
|
217
247
|
const rows = steps.data as Record<string, unknown>[];
|
|
248
|
+
const where = stoppedIn(runRow, rows);
|
|
249
|
+
const stop = where ? { stoppedIn: where } : {};
|
|
250
|
+
if (params.full) return toolResult({ ...runRow, ...stop, steps: steps.data });
|
|
218
251
|
return toolResult({
|
|
219
|
-
...
|
|
252
|
+
...runRow,
|
|
253
|
+
...stop,
|
|
220
254
|
steps: pickFields(rows, STEP_SUMMARY_FIELDS).map((step, index) => ({
|
|
221
255
|
...step,
|
|
222
256
|
headline: summaryHeadline(rows[index]!.summary),
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from '@taiga/types';
|
|
11
11
|
import { TaigaClient } from '../client.js';
|
|
12
12
|
import { registerAllTools } from './index.js';
|
|
13
|
-
import { SERVER_INSTRUCTIONS } from './instructions.js';
|
|
13
|
+
import { INSTRUCTIONS_HEAD, INSTRUCTIONS_HEAD_LIMIT, SERVER_INSTRUCTIONS } from './instructions.js';
|
|
14
14
|
import { PRODUCT_DOCUMENT_NAMES } from './catalogue.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -139,4 +139,14 @@ describe('tool descriptions against the catalogue', () => {
|
|
|
139
139
|
// Nothing writes a `user` source: a hand edit keeps the source it had.
|
|
140
140
|
expect(SERVER_INSTRUCTIONS).not.toMatch(/user: edited/);
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
it('opens the instructions with where to start and when to ask, inside what a client shows before it cuts', () => {
|
|
144
|
+
// Claude Code shows about the first 2,000 characters; the review that
|
|
145
|
+
// found the cut read only the document taxonomy (PRO-2759).
|
|
146
|
+
expect(SERVER_INSTRUCTIONS.startsWith(INSTRUCTIONS_HEAD)).toBe(true);
|
|
147
|
+
expect(INSTRUCTIONS_HEAD.length).toBeLessThanOrEqual(INSTRUCTIONS_HEAD_LIMIT);
|
|
148
|
+
for (const must of ['taiga_get_product_context', 'Before you write', 'NOT_FOUND', 'initiative']) {
|
|
149
|
+
expect(INSTRUCTIONS_HEAD).toContain(must);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
142
152
|
});
|
package/src/tools/catalogue.ts
CHANGED
|
@@ -149,17 +149,78 @@ export function emptyOriginNote(
|
|
|
149
149
|
}
|
|
150
150
|
switch (importStatusOf(product)) {
|
|
151
151
|
case 'pending':
|
|
152
|
-
return 'No reality documents yet: the import has not started. Link the repository
|
|
152
|
+
return 'No reality documents yet: the import has not started. Link the repository, then taiga_start_import.';
|
|
153
153
|
// generating_docs is the retired name for the same in-flight state
|
|
154
154
|
// (PRO-1161); rows that predate it can still carry it.
|
|
155
155
|
case 'analyzing':
|
|
156
156
|
case 'generating_docs':
|
|
157
157
|
return 'No reality documents yet: the import is running and will derive them from the code.';
|
|
158
158
|
case 'cancelled':
|
|
159
|
-
return 'No reality documents: the import was cancelled before it derived any.
|
|
159
|
+
return 'No reality documents: the import was cancelled before it derived any. taiga_start_import runs it again.';
|
|
160
160
|
case 'failed':
|
|
161
|
-
return 'No reality documents: the import failed before it derived any.
|
|
161
|
+
return 'No reality documents: the import failed before it derived any. taiga_start_import runs it again.';
|
|
162
162
|
default:
|
|
163
163
|
return 'No reality documents: the import derives them from the code, and none have been published.';
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What a product in setup waits for, as the next call to make. Nothing on a
|
|
169
|
+
* draft product says it otherwise: discoveryStep names the step and
|
|
170
|
+
* importStatus the import, and the way on from each lives in tool
|
|
171
|
+
* descriptions an agent reads only when it already knows which. Null once the
|
|
172
|
+
* product is active.
|
|
173
|
+
*/
|
|
174
|
+
export function setupNextStep(
|
|
175
|
+
product: (ProductSettingsView & { status?: unknown; discoveryStep?: unknown }) | null | undefined
|
|
176
|
+
): string | null {
|
|
177
|
+
if (product?.status !== 'draft') return null;
|
|
178
|
+
if (isImportedProduct(product)) {
|
|
179
|
+
switch (importStatusOf(product)) {
|
|
180
|
+
case 'analyzing':
|
|
181
|
+
case 'generating_docs':
|
|
182
|
+
return (
|
|
183
|
+
'The import is running and derives the documents from the code (taiga_wait_for_job follows its job). ' +
|
|
184
|
+
'Then review them, and taiga_finish_discovery opens the product.'
|
|
185
|
+
);
|
|
186
|
+
case 'complete':
|
|
187
|
+
return (
|
|
188
|
+
'The import is done: review the reality documents (taiga_list_product_documents), then ' +
|
|
189
|
+
'taiga_finish_discovery opens the product.'
|
|
190
|
+
);
|
|
191
|
+
case 'failed':
|
|
192
|
+
case 'cancelled':
|
|
193
|
+
return 'The last import did not finish: taiga_start_import runs it again.';
|
|
194
|
+
default:
|
|
195
|
+
return (
|
|
196
|
+
'Link its repository if it has none (taiga_list_linkable_repositories, taiga_link_repository), then ' +
|
|
197
|
+
'taiga_start_import: the import derives the documents from the code and runs by itself.'
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Unknown is not "not started": a write's answer leaves the step out, and
|
|
202
|
+
// the default below would send a product at its documents back to the
|
|
203
|
+
// conversation.
|
|
204
|
+
if (product.discoveryStep === undefined) return null;
|
|
205
|
+
switch (product.discoveryStep) {
|
|
206
|
+
case 'documents':
|
|
207
|
+
return (
|
|
208
|
+
'The specification is done; the other documents are generated from it ' +
|
|
209
|
+
'(taiga_generate_product_documents starts the ones still missing). Once every required one is ' +
|
|
210
|
+
'published, taiga_finish_discovery opens the product.'
|
|
211
|
+
);
|
|
212
|
+
case 'checks':
|
|
213
|
+
return 'The documents are published; the checks are stamped in the web, then taiga_finish_discovery opens the product.';
|
|
214
|
+
default:
|
|
215
|
+
return (
|
|
216
|
+
'Discovery is a conversation held in the web: the person describes the product there and the ' +
|
|
217
|
+
'specification comes out of it. The other documents follow, then taiga_finish_discovery opens the product.'
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** What discoveryStep says, for a description that shows it. */
|
|
223
|
+
export const DISCOVERY_STEP_MEANING =
|
|
224
|
+
'discoveryStep is where a product in setup is: context (not started), conversation or import (the ' +
|
|
225
|
+
'specification is being talked out, or derived from the code), documents (the rest being generated and ' +
|
|
226
|
+
'reviewed), checks (stamped in the web); null once it is active.';
|
package/src/tools/context.ts
CHANGED
|
@@ -4,7 +4,13 @@ import { z } from 'zod';
|
|
|
4
4
|
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
5
5
|
import { READ_ONLY } from './annotations.js';
|
|
6
6
|
import { BACKLOG_SUMMARY_FIELDS } from './backlogs.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
POLICY_TYPES,
|
|
9
|
+
defaultOriginFor,
|
|
10
|
+
isImportedProduct,
|
|
11
|
+
setupNextStep,
|
|
12
|
+
type ProductSettingsView,
|
|
13
|
+
} from './catalogue.js';
|
|
8
14
|
import { apiFailureText, isNetworkFailure, networkFailureDetail, runTool, toolError, toolResult } from './errors.js';
|
|
9
15
|
import { productId, remoteUrl, slug } from './inputs.js';
|
|
10
16
|
import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
|
|
@@ -312,11 +318,14 @@ export async function productContext(
|
|
|
312
318
|
const parts = (Object.keys(fetchers) as Part[]).filter(granted);
|
|
313
319
|
const settled = await Promise.allSettled(parts.map((part) => fetchers[part]()));
|
|
314
320
|
|
|
321
|
+
const nextStep = setupNextStep(product);
|
|
315
322
|
const bundle: Record<string, unknown> = {
|
|
316
323
|
product: {
|
|
317
324
|
...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
|
|
318
325
|
projectType: isImportedProduct(product as ProductSettingsView) ? 'brownfield' : 'greenfield',
|
|
319
326
|
defaultOrigin: defaultOriginFor(product as ProductSettingsView),
|
|
327
|
+
// A product in setup says what it waits for: nothing else in the bundle does.
|
|
328
|
+
...(nextStep ? { nextStep } : {}),
|
|
320
329
|
},
|
|
321
330
|
};
|
|
322
331
|
const unavailable: Record<string, string> = {};
|
|
@@ -331,7 +340,8 @@ export async function productContext(
|
|
|
331
340
|
notGranted: notGranted.map((part) => `${part} (needs ${PARTS[part]})`),
|
|
332
341
|
unavailable,
|
|
333
342
|
readNext:
|
|
334
|
-
'Read a policy
|
|
343
|
+
'Read a policy with taiga_get_document (sections: the ids listed here, for only those), another product ' +
|
|
344
|
+
'document with taiga_get_product_document, ' +
|
|
335
345
|
'a backlog item with taiga_get_backlog_item (taiga_list_backlog_items pages a truncated backlog) and a run ' +
|
|
336
346
|
'with taiga_get_build.',
|
|
337
347
|
};
|
package/src/tools/documents.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/server';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { TaigaApiError, type TaigaClient, type TaigaDocument } from '../client.js';
|
|
4
4
|
import { READ_ONLY } from './annotations.js';
|
|
5
|
-
import { pagination } from './inputs.js';
|
|
5
|
+
import { idInput, pagination } from './inputs.js';
|
|
6
6
|
import {
|
|
7
7
|
FACTORY_DOCUMENT_TYPES,
|
|
8
8
|
OTHER_TENANT_DOCUMENT_TYPES,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
named,
|
|
12
12
|
} from './catalogue.js';
|
|
13
13
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
14
|
-
import { applySectionEdit, sectionContent, sectionsOf, stripProvenance } from './sections.js';
|
|
14
|
+
import { applySectionEdit, sectionContent, sectionReadResult, sectionsOf, stripProvenance } from './sections.js';
|
|
15
15
|
import { page, pickFields } from './summarize.js';
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -169,10 +169,18 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
169
169
|
"Get the full content of one of the organization's documents, sections included. " +
|
|
170
170
|
'Use this when you need the security requirements, data handling rules, architecture standards ' +
|
|
171
171
|
'or compliance controls that apply to your code, or the goals, standards and voice the ' +
|
|
172
|
-
'organization has set down. A
|
|
173
|
-
'
|
|
172
|
+
'organization has set down. A policy runs to thousands of tokens: name the sections you need (the ' +
|
|
173
|
+
'ids taiga_get_product_context lists for every policy) and the read carries only those, listing the ' +
|
|
174
|
+
'rest with their sizes and any id the document lacks (unknownSections). A read that finds nothing ' +
|
|
175
|
+
'says which documents are published and whether a draft exists.',
|
|
174
176
|
inputSchema: z.object({
|
|
175
177
|
type: tenantDocumentType,
|
|
178
|
+
sections: z
|
|
179
|
+
.array(z.string().min(1).max(200))
|
|
180
|
+
.min(1)
|
|
181
|
+
.max(50)
|
|
182
|
+
.optional()
|
|
183
|
+
.describe('Only these sections, by id; omit for the whole document'),
|
|
176
184
|
}),
|
|
177
185
|
annotations: READ_ONLY,
|
|
178
186
|
},
|
|
@@ -180,7 +188,8 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
180
188
|
runTool(async () => {
|
|
181
189
|
try {
|
|
182
190
|
const result = await client.getTenantDocument(params.type);
|
|
183
|
-
|
|
191
|
+
const doc = stripProvenance(result.data);
|
|
192
|
+
return params.sections ? sectionReadResult(doc, params.sections, params.type) : toolResult(doc);
|
|
184
193
|
} catch (error) {
|
|
185
194
|
if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
|
|
186
195
|
// Best effort, each part on its own: a failed lookup must not turn
|
|
@@ -273,7 +282,7 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
273
282
|
'Drafts are listed too (isDraft true); an empty list means neither a publication nor a draft exists. ' +
|
|
274
283
|
'Use taiga_get_factory_document to read the full content.',
|
|
275
284
|
inputSchema: z.object({
|
|
276
|
-
factoryId:
|
|
285
|
+
factoryId: idInput('factory', 'taiga_list_factories (query finds one by name) gives it'),
|
|
277
286
|
...pagination,
|
|
278
287
|
}),
|
|
279
288
|
annotations: READ_ONLY,
|
|
@@ -295,7 +304,7 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
295
304
|
'the factory expects every product it owns to follow. A read that finds nothing says whether ' +
|
|
296
305
|
'a draft exists and how to write one.',
|
|
297
306
|
inputSchema: z.object({
|
|
298
|
-
factoryId:
|
|
307
|
+
factoryId: idInput('factory', 'taiga_list_factories (query finds one by name) gives it'),
|
|
299
308
|
type: z.enum(FACTORY_DOCUMENT_TYPES).default('factory_instructions').describe('Document type'),
|
|
300
309
|
}),
|
|
301
310
|
annotations: READ_ONLY,
|
|
@@ -344,7 +353,7 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
344
353
|
saveDraft: (data) => client.saveFactoryDocumentDraft(factoryId, type, { data }),
|
|
345
354
|
publish: () => client.publishFactoryDocument(factoryId, type),
|
|
346
355
|
});
|
|
347
|
-
const factoryId =
|
|
356
|
+
const factoryId = idInput('factory', 'taiga_list_factories (query finds one by name) gives it');
|
|
348
357
|
const factoryDocumentType = z
|
|
349
358
|
.enum(FACTORY_DOCUMENT_TYPES)
|
|
350
359
|
.default('factory_instructions')
|
package/src/tools/errors.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
2
2
|
import { TaigaApiError } from '../client.js';
|
|
3
|
+
import { withNotFoundHint } from './hints.js';
|
|
3
4
|
|
|
4
5
|
/** A successful tool result: one JSON text block the model reads. */
|
|
5
6
|
export function toolResult(value: unknown): CallToolResult {
|
|
@@ -33,8 +34,13 @@ export function networkFailureDetail(error: TypeError): string {
|
|
|
33
34
|
return why ? `${error.message} (${why})` : error.message;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The code a status implies when the API sent none. 400 is VALIDATION_ERROR,
|
|
39
|
+
* the code the API itself sends, so a refusal by this server and one by the
|
|
40
|
+
* API read the same.
|
|
41
|
+
*/
|
|
36
42
|
const CODE_BY_STATUS: Record<number, string> = {
|
|
37
|
-
400: '
|
|
43
|
+
400: 'VALIDATION_ERROR',
|
|
38
44
|
401: 'UNAUTHENTICATED',
|
|
39
45
|
403: 'FORBIDDEN',
|
|
40
46
|
404: 'NOT_FOUND',
|
|
@@ -42,13 +48,6 @@ const CODE_BY_STATUS: Record<number, string> = {
|
|
|
42
48
|
429: 'RATE_LIMITED',
|
|
43
49
|
};
|
|
44
50
|
|
|
45
|
-
/**
|
|
46
|
-
* Run a tool handler and turn a failure into a structured error. An API
|
|
47
|
-
* refusal keeps the API's own code when it sent one and its message whole:
|
|
48
|
-
* on a refused publish that names the section holding template residue,
|
|
49
|
-
* which is what the model needs to fix the draft. A failed connection is
|
|
50
|
-
* structured too, so every failure the model sees has the same shape.
|
|
51
|
-
*/
|
|
52
51
|
/** What the API said, in one phrase: the status, the code it sent (or the one its status implies), its message. */
|
|
53
52
|
export function apiFailureText(error: TaigaApiError): string {
|
|
54
53
|
return `${error.status} ${apiFailureCode(error)}${error.apiMessage ? `: ${error.apiMessage}` : ''}`;
|
|
@@ -64,13 +63,23 @@ export function isMissOrRefusal(error: unknown): error is TaigaApiError {
|
|
|
64
63
|
return error instanceof TaigaApiError && (error.status === 404 || error.status === 403);
|
|
65
64
|
}
|
|
66
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Run a tool handler and turn a failure into a structured error. An API
|
|
68
|
+
* refusal keeps the API's own code when it sent one and its message whole:
|
|
69
|
+
* on a refused publish that names the section holding template residue,
|
|
70
|
+
* which is what the model needs to fix the draft. A failed connection is
|
|
71
|
+
* structured too, so every failure the model sees has the same shape.
|
|
72
|
+
*/
|
|
67
73
|
export async function runTool(handler: () => Promise<CallToolResult>): Promise<CallToolResult> {
|
|
68
74
|
try {
|
|
69
75
|
return await handler();
|
|
70
76
|
} catch (error) {
|
|
71
77
|
if (error instanceof TaigaApiError) {
|
|
72
78
|
const code = error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
|
|
73
|
-
|
|
79
|
+
const said = error.apiMessage ?? `Taiga API answered ${error.status}.`;
|
|
80
|
+
// A miss says where the right IDs are (hints.ts): the API's own word is bare.
|
|
81
|
+
const message = error.status === 404 ? withNotFoundHint(said) : said;
|
|
82
|
+
return toolError(code, message, error.details);
|
|
74
83
|
}
|
|
75
84
|
if (isNetworkFailure(error)) {
|
|
76
85
|
return toolError(
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The next step after the API answers 404. The API's own word is bare
|
|
5
|
+
* ("Backlog item not found", "Repository link not found"), and an agent that
|
|
6
|
+
* hears only that guesses another ID, or a name. Each ID a tool takes names
|
|
7
|
+
* the tool that lists the right ones; the tool's arguments say which apply.
|
|
8
|
+
*/
|
|
9
|
+
const BY_ARGUMENT: Record<string, string> = {
|
|
10
|
+
itemId: "taiga_list_backlog_items lists a product's initiatives with their IDs; a run or plan ID is not one.",
|
|
11
|
+
backlogItemId: "taiga_list_backlog_items lists a product's initiatives with their IDs.",
|
|
12
|
+
buildId: 'taiga_list_builds lists the runs, of one product or of every product the key can see.',
|
|
13
|
+
planId: "taiga_get_plan gives the initiative's active plan and its ID.",
|
|
14
|
+
repositoryId:
|
|
15
|
+
"taiga_list_product_repositories lists the product's repository links; a GitHub repository id is not a link ID.",
|
|
16
|
+
replacesRepositoryId: "taiga_list_product_repositories lists the product's repository links.",
|
|
17
|
+
githubRepositoryId:
|
|
18
|
+
'taiga_list_linkable_repositories lists the repositories the GitHub connection reaches, with their ids.',
|
|
19
|
+
installationId: 'taiga_list_linkable_repositories lists the connected GitHub accounts.',
|
|
20
|
+
jobId: 'A job ID is what the tool that started the job returned (taiga_request_initiatives, taiga_start_import).',
|
|
21
|
+
factoryId: 'taiga_list_factories lists the factories the key can see.',
|
|
22
|
+
findingId: "taiga_list_findings lists a product's findings.",
|
|
23
|
+
violationId: "taiga_get_policy_standing lists a product's policy violations.",
|
|
24
|
+
environmentId: "taiga_get_deployments lists the product's environments with their IDs; a product ID is not one.",
|
|
25
|
+
knowledgeId: 'taiga_list_knowledge lists the knowledge files.',
|
|
26
|
+
productId:
|
|
27
|
+
'taiga_list_products lists the products the key can see; a name is not an ID (taiga_get_product takes a slug).',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A hint chosen from what the API said, for a tool whose 404 can mean two things. */
|
|
31
|
+
type Hint = (said: string) => string | undefined;
|
|
32
|
+
|
|
33
|
+
/** Where an argument means something else on one tool, or the tool's 404 needs its own word. */
|
|
34
|
+
const BY_TOOL: Record<string, Hint> = {
|
|
35
|
+
taiga_resolve_action: () => 'taiga_list_action_required lists the items with their IDs.',
|
|
36
|
+
// "No active plan" is the plan's miss; anything else is the initiative's,
|
|
37
|
+
// and sending an agent that mistyped an ID to taiga_plan_again would start
|
|
38
|
+
// paid planning to fix a typo.
|
|
39
|
+
taiga_get_plan: (said) =>
|
|
40
|
+
/\bplan\b/i.test(said)
|
|
41
|
+
? 'taiga_get_backlog_item says whether one is being generated (planningStatus planning) or failed, and ' +
|
|
42
|
+
'taiga_plan_again asks for a new one.'
|
|
43
|
+
: BY_ARGUMENT.itemId,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The hint for a 404 from one tool call: the tool's own, else one per ID it was given. */
|
|
47
|
+
export function notFoundHint(tool: string, args: unknown): Hint | undefined {
|
|
48
|
+
if (BY_TOOL[tool]) return BY_TOOL[tool];
|
|
49
|
+
if (typeof args !== 'object' || args === null) return undefined;
|
|
50
|
+
const given = Object.keys(args).filter((key) => (args as Record<string, unknown>)[key] != null && BY_ARGUMENT[key]);
|
|
51
|
+
// The product last: a product the key cannot see is answered "Product not
|
|
52
|
+
// found", which already says which ID it was; the others are named first.
|
|
53
|
+
given.sort((a, b) => Number(a === 'productId') - Number(b === 'productId'));
|
|
54
|
+
if (given.length === 0) return undefined;
|
|
55
|
+
const hint = given.map((key) => BY_ARGUMENT[key]).join(' ');
|
|
56
|
+
return () => hint;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The hint of the tool call in progress, read by runTool when the API answers 404. */
|
|
60
|
+
export const notFoundHints = new AsyncLocalStorage<Hint | undefined>();
|
|
61
|
+
|
|
62
|
+
/** The API's 404 message with the hint of the call in progress, if it has one. */
|
|
63
|
+
export function withNotFoundHint(said: string): string {
|
|
64
|
+
const hint = notFoundHints.getStore()?.(said);
|
|
65
|
+
return hint ? `${withStop(said)} ${hint}` : said;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A sentence ends in a stop before the next one is added to it. */
|
|
69
|
+
export function withStop(text: string): string {
|
|
70
|
+
return /[.!?]$/.test(text.trimEnd()) ? text.trimEnd() : `${text.trimEnd()}.`;
|
|
71
|
+
}
|