@taiga-ai/mcp-server 0.9.0 → 0.10.0-rc.405

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.
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Tool annotations shared by every read tool. Hints for the client, not a
3
- * control: the API enforces what a key may do.
2
+ * Tool annotations shared by the tools. Hints for the client, not a
3
+ * control: the API enforces what a key may do. A client that asks the
4
+ * person before a destructive call reads destructiveHint; the server-side
5
+ * approval gate (PRO-548) is separate work.
4
6
  */
5
7
  export const READ_ONLY = {
6
8
  readOnlyHint: true,
@@ -8,3 +10,23 @@ export const READ_ONLY = {
8
10
  idempotentHint: true,
9
11
  openWorldHint: false,
10
12
  } as const;
13
+
14
+ /** A write that adds or edits a row and starts nothing: a created initiative, a moved one. */
15
+ export const WRITE = {
16
+ readOnlyHint: false,
17
+ destructiveHint: false,
18
+ idempotentHint: false,
19
+ openWorldHint: false,
20
+ } as const;
21
+
22
+ /**
23
+ * A write that starts or stops paid work, or opens the door to it: queueing
24
+ * an initiative, approving a plan, resuming or stopping a run. Each starts
25
+ * an agent that spends tokens and opens pull requests, or halts one mid-way.
26
+ */
27
+ export const OPERATE = {
28
+ readOnlyHint: false,
29
+ destructiveHint: true,
30
+ idempotentHint: false,
31
+ openWorldHint: false,
32
+ } as const;
@@ -1,9 +1,10 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import type { TaigaClient } from '../client.js';
4
- import { READ_ONLY } from './annotations.js';
4
+ import { INITIATIVE_TRANSITION_STATES, PLAN_REJECTION_COMMENT_MAX_CHARS, PR_CLOSING_STATES } from '@taiga/types';
5
+ import { OPERATE, READ_ONLY, WRITE } from './annotations.js';
5
6
  import { pagination, productId } from './inputs.js';
6
- import { runTool, toolResult } from './errors.js';
7
+ import { runTool, toolError, toolResult } from './errors.js';
7
8
  import { page, pickFields } from './summarize.js';
8
9
 
9
10
  /** What a list row needs to say for the model to pick an item to read in full. */
@@ -23,9 +24,48 @@ export const BACKLOG_SUMMARY_FIELDS = [
23
24
  'updatedAt',
24
25
  ] as const;
25
26
 
27
+ /**
28
+ * What an initiative says about itself after a write: where it is on the
29
+ * line, whose turn it is and what the line is waiting for. Not the brief,
30
+ * the criteria or the technical context the caller already has.
31
+ */
32
+ const ITEM_OUTCOME_FIELDS = [
33
+ 'id',
34
+ 'productId',
35
+ 'title',
36
+ 'status',
37
+ 'state',
38
+ 'planningStatus',
39
+ 'needsHumanCause',
40
+ 'needsHumanDetail',
41
+ 'lifecycle',
42
+ ] as const;
43
+
44
+ const itemId = z.string().uuid().describe('Backlog item ID -- get from taiga_list_backlog_items');
45
+
46
+ const outcome = (item: unknown) => toolResult(pickFields([item as Record<string, unknown>], ITEM_OUTCOME_FIELDS)[0]);
47
+
48
+ /**
49
+ * Each tool is registered under the capability its route requires, so a
50
+ * key sees exactly what it may call: reads on backlog:read, the create and
51
+ * the board move on backlog:write, queueing on both backlog:write and
52
+ * products:write (the state route checks both for a drop into queue), the
53
+ * resume on products:write alone, as its route is (the item ID it takes
54
+ * also comes back from a create or a queue), and the plan verdicts on
55
+ * products:write together with backlog:read, because the plan ID they take
56
+ * comes only from the plan read: a key that cannot read plans has nothing
57
+ * to feed them.
58
+ */
26
59
  export function registerBacklogTools(server: McpServer, client: TaigaClient) {
27
- if (!client.hasCapability('backlog:read')) return;
60
+ const may = (capability: string) => client.hasCapability(capability);
61
+ if (may('backlog:read')) registerBacklogReads(server, client);
62
+ if (may('backlog:write')) registerBacklogWrites(server, client);
63
+ if (may('backlog:write') && may('products:write')) registerQueue(server, client);
64
+ if (may('products:write')) registerResume(server, client);
65
+ if (may('backlog:read') && may('products:write')) registerPlanVerdicts(server, client);
66
+ }
28
67
 
68
+ function registerBacklogReads(server: McpServer, client: TaigaClient) {
29
69
  server.registerTool(
30
70
  'taiga_list_backlog_items',
31
71
  {
@@ -79,4 +119,194 @@ export function registerBacklogTools(server: McpServer, client: TaigaClient) {
79
119
  return toolResult(result.data);
80
120
  })
81
121
  );
122
+
123
+ server.registerTool(
124
+ 'taiga_get_plan',
125
+ {
126
+ title: 'Get the active plan',
127
+ description:
128
+ "Get an initiative's active plan with its steps: what each step changes, the files it writes and reads, " +
129
+ 'its layer and dependencies. Read this before taiga_approve_plan or taiga_reject_plan, and to see what ' +
130
+ 'a run will do. NOT_FOUND when the initiative has no active plan yet (planningStatus says whether one ' +
131
+ 'is being generated).',
132
+ inputSchema: { itemId },
133
+ annotations: READ_ONLY,
134
+ },
135
+ (params) =>
136
+ runTool(async () => {
137
+ const result = await client.getActivePlan(params.itemId);
138
+ return toolResult(result.data);
139
+ })
140
+ );
141
+ }
142
+
143
+ function registerBacklogWrites(server: McpServer, client: TaigaClient) {
144
+ server.registerTool(
145
+ 'taiga_create_backlog_item',
146
+ {
147
+ title: 'Create backlog item',
148
+ description:
149
+ 'Create an initiative in the Backlog column of a product: a title and a brief in Markdown (the ' +
150
+ 'problem, what it looks like, what it covers and leaves alone), its category, priority and estimate. ' +
151
+ 'It starts nothing: the planner reads it only once it is queued (taiga_queue_backlog_item) or moved to ' +
152
+ 'Plan (taiga_move_backlog_item). Returns the new item with its ID.',
153
+ inputSchema: {
154
+ productId,
155
+ title: z.string().min(1).max(500).describe('The initiative title, as it appears on the board'),
156
+ description: z
157
+ .string()
158
+ .min(1)
159
+ .max(20_000)
160
+ .describe('The brief in Markdown: the problem, what it looks like, what it covers and leaves alone'),
161
+ category: z
162
+ .enum(['feature', 'bug', 'tech_debt', 'improvement'])
163
+ .optional()
164
+ .describe('What kind of work (default feature)'),
165
+ priority: z.number().int().min(1).optional().describe('1 is the most urgent (default 3)'),
166
+ estimate: z.number().int().min(0).optional().describe('Story points, 0 for unestimated (default 0)'),
167
+ },
168
+ annotations: WRITE,
169
+ },
170
+ (params) =>
171
+ runTool(async () => {
172
+ const { productId: id, ...body } = params;
173
+ return outcome((await client.createBacklogItem(id, body)).data);
174
+ })
175
+ );
176
+
177
+ server.registerTool(
178
+ 'taiga_move_backlog_item',
179
+ {
180
+ title: 'Move backlog item on the board',
181
+ description:
182
+ 'Move an initiative to a column, or order it within one: backlog and todo are the idle columns; ' +
183
+ 'queue puts it in the line (taiga_queue_backlog_item says more); plan starts planning now; done and ' +
184
+ `canceled take it off the line. A move to ${PR_CLOSING_STATES.join(', ')} CLOSES a pull request the ` +
185
+ 'initiative is still waiting on (a move to queue keeps it, and done leaves it to be merged). The ' +
186
+ 'initiative state machine refuses a move that is not allowed (CONFLICT: build cannot be dropped ' +
187
+ 'into, an initiative being planned cannot leave plan). A move into queue or plan starts paid work and ' +
188
+ 'needs products:write on the key; a key without it is refused here. Ask the person before a move that ' +
189
+ `starts work (queue, plan) or closes a pull request (${PR_CLOSING_STATES.join(', ')} from build).`,
190
+ inputSchema: {
191
+ itemId,
192
+ state: z.enum(INITIATIVE_TRANSITION_STATES).describe('The column to move into'),
193
+ afterId: z
194
+ .string()
195
+ .uuid()
196
+ .nullable()
197
+ .optional()
198
+ .describe('The initiative to follow in that column; null for the head; omit to keep rank order'),
199
+ },
200
+ annotations: OPERATE,
201
+ },
202
+ (params) =>
203
+ runTool(async () => {
204
+ const { itemId: id, ...body } = params;
205
+ // What the route checks on top of backlog:write for these two
206
+ // columns; refused here so the model learns which capability, not
207
+ // just that it was forbidden.
208
+ if (LINE_STARTING_STATES.has(body.state) && !client.hasCapability('products:write')) {
209
+ return toolError(
210
+ 'FORBIDDEN',
211
+ `A move into ${body.state} starts agent work and needs products:write, which this key lacks.`
212
+ );
213
+ }
214
+ return outcome((await client.transitionInitiative(id, body)).data);
215
+ })
216
+ );
217
+ }
218
+
219
+ /** The columns a drop into starts paid work, which the state route gates on products:write. */
220
+ const LINE_STARTING_STATES: ReadonlySet<string> = new Set(['queue', 'plan']);
221
+
222
+ function registerQueue(server: McpServer, client: TaigaClient) {
223
+ server.registerTool(
224
+ 'taiga_queue_backlog_item',
225
+ {
226
+ title: 'Queue backlog item',
227
+ description:
228
+ 'Add an initiative to the product queue, the line the coding agent works: queued initiatives are ' +
229
+ 'planned and built one at a time, each advancing when the previous pull request merges. This starts ' +
230
+ 'paid work: a planner run, then (when the plan is approved, or at once if auto-build is on) a coding ' +
231
+ 'run that pushes commits and opens a pull request in the product repository. Ask the person before ' +
232
+ 'calling this. An initiative already in the queue stays where it is unless afterId says where to go; ' +
233
+ 'one being planned or built cannot move (CONFLICT). Returns the initiative with its new state.',
234
+ inputSchema: {
235
+ itemId,
236
+ afterId: z
237
+ .string()
238
+ .uuid()
239
+ .nullable()
240
+ .optional()
241
+ .describe(
242
+ 'The queued initiative to follow; null for the head of the queue; omit to append a new entry or leave a queued one where it is'
243
+ ),
244
+ },
245
+ annotations: OPERATE,
246
+ },
247
+ (params) =>
248
+ runTool(async () => {
249
+ // The move the web makes: one write that answers with the refreshed
250
+ // item, rather than the queue route plus two reads around it.
251
+ const { itemId: id, ...placement } = params;
252
+ return outcome((await client.transitionInitiative(id, { state: 'queue', ...placement })).data);
253
+ })
254
+ );
255
+ }
256
+
257
+ function registerResume(server: McpServer, client: TaigaClient) {
258
+ server.registerTool(
259
+ 'taiga_resume_backlog_item',
260
+ {
261
+ title: 'Resume backlog item',
262
+ description:
263
+ 'Resume an initiative the line stopped on (needsHumanCause is set): a failed or cancelled plan is ' +
264
+ 'planned again, a stopped or failed run resumes from its first incomplete step, a refused build start ' +
265
+ 'is cleared and retried. Does whatever the named cause needs, so prefer it to taiga_resume_build when ' +
266
+ 'the initiative is flagged. Refused (CONFLICT) when nothing is flagged or the cause has no resume. ' +
267
+ 'Starts paid work; ask the person first.',
268
+ inputSchema: { itemId },
269
+ annotations: OPERATE,
270
+ },
271
+ (params) => runTool(async () => outcome((await client.resumeInitiative(params.itemId)).data))
272
+ );
273
+ }
274
+
275
+ function registerPlanVerdicts(server: McpServer, client: TaigaClient) {
276
+ server.registerTool(
277
+ 'taiga_approve_plan',
278
+ {
279
+ title: 'Approve a plan',
280
+ description:
281
+ "Approve an initiative's plan: its build starts now, as the approver, the same start the Start run " +
282
+ 'button makes. Read the plan first with taiga_get_plan and name its ID; an approval of a plan that ' +
283
+ 'was replaced since is refused (CONFLICT), as is one while a new plan is being generated or a build is ' +
284
+ 'already running. Starts a coding run that pushes commits and opens a pull request; ask the person first.',
285
+ inputSchema: { planId: z.string().uuid().describe('Plan ID -- get from taiga_get_plan') },
286
+ annotations: OPERATE,
287
+ },
288
+ (params) => runTool(async () => outcome((await client.approvePlan(params.planId)).data))
289
+ );
290
+
291
+ server.registerTool(
292
+ 'taiga_reject_plan',
293
+ {
294
+ title: 'Reject a plan',
295
+ description:
296
+ "Send an initiative's plan back with direction: a new plan is generated with the comment as reviewer " +
297
+ 'feedback and the rejected plan as its starting point, which supersedes it when the new one lands. ' +
298
+ 'Read the plan first with taiga_get_plan and name its ID. Starts a planner run; ask the person first.',
299
+ inputSchema: {
300
+ planId: z.string().uuid().describe('Plan ID -- get from taiga_get_plan'),
301
+ comment: z
302
+ .string()
303
+ .trim()
304
+ .min(1)
305
+ .max(PLAN_REJECTION_COMMENT_MAX_CHARS)
306
+ .describe('What the next plan should do differently: a paragraph of direction'),
307
+ },
308
+ annotations: OPERATE,
309
+ },
310
+ (params) => runTool(async () => outcome((await client.rejectPlan(params.planId, { comment: params.comment })).data))
311
+ );
82
312
  }
@@ -1,15 +1,17 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import type { TaigaClient } from '../client.js';
4
- import { READ_ONLY } from './annotations.js';
4
+ import { OPERATE, READ_ONLY } from './annotations.js';
5
5
  import { pagination, productId } from './inputs.js';
6
- import { runTool, toolResult } from './errors.js';
6
+ import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
7
+ import { TaigaApiError } from '../client.js';
7
8
  import { page, pickFields } from './summarize.js';
8
9
 
9
10
  /** What a list row needs to say for the model to pick a run to read in full. */
10
11
  export const BUILD_SUMMARY_FIELDS = [
11
12
  'id',
12
13
  'runNumber',
14
+ 'productId',
13
15
  'backlogItemId',
14
16
  'targetTitle',
15
17
  'planId',
@@ -26,11 +28,70 @@ export const BUILD_SUMMARY_FIELDS = [
26
28
  'prState',
27
29
  'prDraft',
28
30
  'prMerged',
31
+ 'actions',
29
32
  'createdAt',
30
33
  'startedAt',
31
34
  'completedAt',
32
35
  ] as const;
33
36
 
37
+ /** What a run says about itself after a stop or a resume: its state and what can be done next. */
38
+ const RUN_OUTCOME_FIELDS = [
39
+ 'id',
40
+ 'runNumber',
41
+ 'backlogItemId',
42
+ 'status',
43
+ 'failedReason',
44
+ 'completedSteps',
45
+ 'totalSteps',
46
+ 'prUrl',
47
+ 'actions',
48
+ ] as const;
49
+
50
+ /** How many products the org-wide list names; past this a row keeps its productId only. */
51
+ const NAMED_PRODUCTS = 100;
52
+
53
+ const buildId = z.string().uuid().describe('Build run ID -- get from taiga_list_builds');
54
+
55
+ /**
56
+ * The runs of every product the key can see, each row naming its product.
57
+ * The route carries product ids only; one products read names them, and a
58
+ * failure of that read leaves the rows unnamed rather than failing the list.
59
+ */
60
+ async function activityRows(
61
+ client: TaigaClient,
62
+ opts: { active?: boolean | undefined; limit?: number | undefined; cursor?: string | undefined }
63
+ ) {
64
+ const [runs, products] = await Promise.all([
65
+ client.listBuildRunActivity(opts),
66
+ // Only the API failing or being unreachable leaves rows unnamed; a bug
67
+ // in this code surfaces.
68
+ client.listProducts({ limit: NAMED_PRODUCTS }).catch((error: unknown) => {
69
+ if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
70
+ throw error;
71
+ }),
72
+ ]);
73
+ const nameById = new Map<string, { name: unknown; slug: unknown }>();
74
+ for (const product of (products?.data ?? []) as Record<string, unknown>[]) {
75
+ nameById.set(String(product.id), { name: product.name, slug: product.slug });
76
+ }
77
+ const rows = pickFields(runs.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS).map((row) => {
78
+ const product = nameById.get(String(row.productId));
79
+ return product ? { ...row, product } : row;
80
+ });
81
+ const unnamed = rows.some((row) => !('product' in row));
82
+ // Why a row stayed unnamed, by what the products read said: it failed, it
83
+ // was cut before that product, or it listed everything the key can see and
84
+ // the run's product is not among them (deleted since, or not visible).
85
+ const note = !unnamed
86
+ ? null
87
+ : !products
88
+ ? 'The products could not be read, so rows carry productId only; taiga_get_product names one.'
89
+ : products.nextCursor !== null
90
+ ? `Only the first ${NAMED_PRODUCTS} products could be named; a row without product carries productId only.`
91
+ : 'A row without product belongs to a product the key cannot list (deleted since, or not visible to it); it carries productId only.';
92
+ return { ...page(rows, runs.nextCursor), ...(note ? { note } : {}) };
93
+ }
94
+
34
95
  export function registerBuildTools(server: McpServer, client: TaigaClient) {
35
96
  if (!client.hasCapability('products:read')) return;
36
97
 
@@ -39,26 +100,54 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
39
100
  {
40
101
  title: 'List build runs',
41
102
  description:
42
- "List the coding agent's runs for a product, newest first: status, branch, pull request and step " +
43
- 'progress. Use this to see what Taiga already built or is building, then read one run with ' +
44
- 'taiga_get_build. Filter by backlog item to follow a single initiative.',
103
+ "List the coding agent's runs, newest first: status, branch, pull request, step progress and the " +
104
+ 'actions a person may take on each (stop is taiga_stop_build; resume and retry_from_failed_step are ' +
105
+ 'both taiga_resume_build, the same route; start_over is only in the web UI for now). With productId, ' +
106
+ 'the runs of that product, optionally of one backlog item; without it, the runs of every product the ' +
107
+ 'key can see, each row naming its product, and active: true narrows to the runs executing right now. ' +
108
+ 'Use this to see what Taiga already built or is building, then read one run with taiga_get_build. ' +
109
+ 'A run of a deleted initiative keeps its title.',
45
110
  inputSchema: {
46
- productId,
111
+ productId: productId.optional(),
47
112
  backlogItemId: z
48
113
  .string()
49
114
  .uuid()
50
115
  .optional()
51
- .describe("Only this backlog item's runs -- get from taiga_list_backlog_items"),
116
+ .describe("Only this backlog item's runs -- get from taiga_list_backlog_items; needs productId"),
117
+ active: z
118
+ .boolean()
119
+ .optional()
120
+ .describe(
121
+ 'Only runs the agent is executing now (cloning through reviewing; a queued pending run is not yet ' +
122
+ 'executing and is left out); for the org-wide list'
123
+ ),
52
124
  ...pagination,
53
125
  },
54
126
  annotations: READ_ONLY,
55
127
  },
56
128
  (params) =>
57
129
  runTool(async () => {
58
- const result = await client.listBuildRuns(params.productId, params);
59
- return toolResult(
60
- page(pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS), result.nextCursor)
61
- );
130
+ if (!params.productId && params.backlogItemId) {
131
+ return toolError(
132
+ 'VALIDATION_FAILED',
133
+ 'backlogItemId narrows the runs of one product: pass its productId as well (the item carries it).'
134
+ );
135
+ }
136
+ // The product route has no such filter; a silently ignored one would
137
+ // hand the model finished runs as running.
138
+ if (params.productId && params.active) {
139
+ return toolError(
140
+ 'VALIDATION_FAILED',
141
+ "active narrows the org-wide list only: omit productId, or read the product's runs and judge each by its status."
142
+ );
143
+ }
144
+ if (params.productId) {
145
+ const result = await client.listBuildRuns(params.productId, params);
146
+ return toolResult(
147
+ page(pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS), result.nextCursor)
148
+ );
149
+ }
150
+ return toolResult(await activityRows(client, params));
62
151
  })
63
152
  );
64
153
 
@@ -69,10 +158,9 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
69
158
  description:
70
159
  'Get one run in full with its steps: what each step was meant to change, what it did change ' +
71
160
  '(commit, files), its status and any error. Use this to pick up where the coding agent stopped, ' +
72
- 'or to understand the pull request it opened.',
73
- inputSchema: {
74
- buildId: z.string().uuid().describe('Build run ID -- get from taiga_list_builds'),
75
- },
161
+ 'or to understand the pull request it opened. Its actions list says what it accepts: stop ' +
162
+ '(taiga_stop_build), resume or retry_from_failed_step (both taiga_resume_build), start_over (web UI only for now).',
163
+ inputSchema: { buildId },
76
164
  annotations: READ_ONLY,
77
165
  },
78
166
  (params) =>
@@ -86,4 +174,46 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
86
174
  return toolResult({ ...(run.data as Record<string, unknown>), steps: steps.data });
87
175
  })
88
176
  );
177
+
178
+ if (!client.hasCapability('products:write')) return;
179
+
180
+ server.registerTool(
181
+ 'taiga_stop_build',
182
+ {
183
+ title: 'Stop a build run',
184
+ description:
185
+ 'Stop a run the coding agent is executing: the agent halts, its branch and any draft pull request stay, ' +
186
+ 'and the run can be resumed later with taiga_resume_build. Only a run whose actions include stop ' +
187
+ '(taiga_list_builds shows them); the API refuses the rest with CONFLICT. Ask the person before calling ' +
188
+ 'this: it interrupts work in progress.',
189
+ inputSchema: { buildId },
190
+ annotations: OPERATE,
191
+ },
192
+ (params) =>
193
+ runTool(async () => {
194
+ const result = await client.stopBuildRun(params.buildId);
195
+ return toolResult(pickFields([result.data as Record<string, unknown>], RUN_OUTCOME_FIELDS)[0]);
196
+ })
197
+ );
198
+
199
+ server.registerTool(
200
+ 'taiga_resume_build',
201
+ {
202
+ title: 'Resume a build run',
203
+ description:
204
+ 'Resume a stopped or failed run from its first incomplete step: the agent starts again on the same ' +
205
+ 'branch, which spends tokens and may push commits and open a pull request. This is the route behind ' +
206
+ 'both the resume and the retry_from_failed_step actions: the API accepts a run whose actions include ' +
207
+ 'one of them and refuses the rest with CONFLICT. start_over (a new run) is web-only for now. For an initiative ' +
208
+ 'the line stopped on, ' +
209
+ 'taiga_resume_backlog_item does whatever its cause needs instead. Ask the person before calling this.',
210
+ inputSchema: { buildId },
211
+ annotations: OPERATE,
212
+ },
213
+ (params) =>
214
+ runTool(async () => {
215
+ const result = await client.resumeBuildRun(params.buildId);
216
+ return toolResult(pickFields([result.data as Record<string, unknown>], RUN_OUTCOME_FIELDS)[0]);
217
+ })
218
+ );
89
219
  }
@@ -28,6 +28,8 @@ const ALL_CAPABILITIES = [
28
28
  'tenant:documents:read',
29
29
  'tenant:documents:write',
30
30
  'backlog:read',
31
+ 'backlog:write',
32
+ 'products:write',
31
33
  ];
32
34
 
33
35
  async function connected() {
@@ -43,7 +43,16 @@ export const SERVER_INSTRUCTIONS = [
43
43
  '',
44
44
  '**Backlog and runs** (taiga_list_backlog_items / taiga_get_backlog_item / taiga_list_builds / taiga_get_build):',
45
45
  "The product's initiatives with their acceptance criteria, and what Taiga's coding agent already built",
46
- 'for them: runs, steps, commits and pull requests.',
46
+ 'for them: runs, steps, commits and pull requests. taiga_list_builds without a product lists the runs of',
47
+ 'every product the key can see, and active: true the ones executing now.',
48
+ '',
49
+ '**Operating the line** (taiga_create_backlog_item / taiga_queue_backlog_item / taiga_move_backlog_item /',
50
+ 'taiga_resume_backlog_item / taiga_get_plan / taiga_approve_plan / taiga_reject_plan / taiga_stop_build /',
51
+ 'taiga_resume_build): create an initiative, put it in the queue the coding agent works, move it on the',
52
+ 'board, read and approve or reject its plan, stop or resume a run. Queueing, approving and resuming start',
53
+ 'paid agent work that pushes commits and opens pull requests; stopping interrupts it. Tell the person what',
54
+ 'you are about to start or stop and wait for their yes before calling these; a client that prompts on',
55
+ 'destructive tools does that for you. A refusal (CONFLICT) carries the API reason: say it as it is.',
47
56
  '',
48
57
  '**Sections.** Policies and instructions are maps of sections. Each section carries its text and two',
49
58
  'provenance fields you can read past: `source` says where the text originally came from (template:',
@@ -208,15 +208,16 @@ export async function resolveProductRef(
208
208
  client: TaigaClient,
209
209
  ref: { productId?: string | undefined; slug?: string | undefined; remoteUrl?: string | undefined },
210
210
  /** The names the calling tool accepts, for the message when none or several were given. */
211
- accepted: readonly string[]
211
+ accepted: readonly string[],
212
+ /** How the caller's arguments are passed, for the message when none was given (a slash command's usage). */
213
+ usage?: string
212
214
  ): Promise<{ product: Record<string, unknown> } | { refusal: { code: string; message: string } }> {
213
215
  const refusal = (code: string, message: string) => ({ refusal: { code, message } });
214
216
  const given = [ref.productId, ref.slug, ref.remoteUrl].filter((v) => v !== undefined).length;
215
217
  if (given !== 1) {
216
- return refusal(
217
- 'VALIDATION_FAILED',
218
- `Name the product exactly once: ${accepted.join(' or ')}. Two names could mean two products.`
219
- );
218
+ const how =
219
+ given === 0 ? (usage ?? 'taiga_list_products shows what exists.') : 'Two names could mean two products.';
220
+ return refusal('VALIDATION_FAILED', `Name the product exactly once: ${accepted.join(' or ')}. ${how}`);
220
221
  }
221
222
  if (ref.productId) {
222
223
  try {
@@ -75,15 +75,23 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
75
75
  'specification and architecture, the policies that apply, the open backlog and the latest runs. ' +
76
76
  'Name the product by the git remote URL of the checkout, its slug or its ID.',
77
77
  // The shared schemas: a malformed ID or slug is refused here, before
78
- // any request, as it is on the tools.
78
+ // any request, as it is on the tools. The slug first: a client that
79
+ // takes the arguments positionally (/server:prompt <arg>) fills the
80
+ // first one, and the slug is the name a person knows from the URL.
79
81
  argsSchema: {
80
- remoteUrl: remoteUrl.optional(),
81
82
  slug: slug.optional(),
82
83
  productId: idInput.optional(),
84
+ remoteUrl: remoteUrl.optional(),
83
85
  },
84
86
  },
85
87
  async (args) => {
86
- const resolved = await resolveProductRef(client, args, ['remoteUrl', 'slug', 'productId']);
88
+ const resolved = await resolveProductRef(
89
+ client,
90
+ args,
91
+ ['slug', 'productId', 'remoteUrl'],
92
+ 'Pass the slug as the first argument (/taiga_start_session <slug>, the slug from the Taiga URL); ' +
93
+ 'taiga_list_products lists the slugs.'
94
+ );
87
95
  if ('refusal' in resolved) refuse(resolved.refusal);
88
96
  const result = await productContext(client, resolved.product, { fullDocuments: true });
89
97
  const bundle = (result.content[0] as { text?: string } | undefined)?.text ?? '{}';
Binary file