@taiga-ai/mcp-server 0.20.0-dev.454 → 0.21.0-dev.455

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.
@@ -5,7 +5,7 @@ import type { Product } from '@taiga/types';
5
5
  import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
6
6
  import { OPERATE, OPERATE_SETTING, READ_ONLY, WRITE } from './annotations.js';
7
7
  import { isImportedProduct } from './catalogue.js';
8
- import { runTool, toolError, toolResult } from './errors.js';
8
+ import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
9
9
  import { setupReadiness } from './readiness.js';
10
10
  import { withStop } from './hints.js';
11
11
  import { idInput, productId } from './inputs.js';
@@ -65,6 +65,54 @@ const MISSING_DOCUMENTS_HOW =
65
65
 
66
66
  type ProductRow = WithoutTenant<Product>;
67
67
 
68
+ /**
69
+ * Refuse a name already taken (PRO-2800): the API appends -2 to the slug and
70
+ * creates a second one, so a retried call or two agents at once left
71
+ * duplicates nobody asked for. allowDuplicateName says the person means it.
72
+ */
73
+ const allowDuplicateName = z
74
+ .boolean()
75
+ .optional()
76
+ .describe('Create it even when one with the same name exists (default false: refused, naming the existing one)');
77
+
78
+ /** The same name, as a person would read it: case and surrounding space aside. */
79
+ const sameName = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
80
+
81
+ /** Pages read looking for a taken name; past this the create goes ahead. */
82
+ const NAME_PAGES = 5;
83
+
84
+ /**
85
+ * The first row with this name, across pages. Best effort: a list the API
86
+ * refuses or fails leaves the check out rather than the create.
87
+ */
88
+ async function firstNamed<T extends { id: string; name: string; slug?: string }>(
89
+ name: string,
90
+ read: (cursor: string | undefined) => Promise<{ data: T[]; nextCursor: string | null }>
91
+ ): Promise<T | null> {
92
+ let cursor: string | undefined;
93
+ try {
94
+ for (let pageIndex = 0; pageIndex < NAME_PAGES; pageIndex += 1) {
95
+ const result = await read(cursor);
96
+ const same = result.data.find((row) => sameName(row.name, name));
97
+ if (same) return same;
98
+ if (!result.nextCursor) return null;
99
+ cursor = result.nextCursor;
100
+ }
101
+ } catch (error) {
102
+ if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
103
+ }
104
+ return null;
105
+ }
106
+
107
+ function duplicateName(kind: 'factory' | 'product', existing: { id: string; name: string; slug?: string }) {
108
+ return toolError(
109
+ 'DUPLICATE_NAME',
110
+ `A ${kind} named "${existing.name}" already exists (${existing.id}). Use it, choose another name, or pass ` +
111
+ 'allowDuplicateName: true if the person wants a second one.',
112
+ { existingId: existing.id, ...(existing.slug ? { existingSlug: existing.slug } : {}) }
113
+ );
114
+ }
115
+
68
116
  /** A product row the model can act on: how it started, and while in setup what it still needs. */
69
117
  async function productRow(client: TaigaClient, product: ProductRow) {
70
118
  const { settings: _settings, ...rest } = pickFields([product], PRODUCT_FIELDS)[0]!;
@@ -118,17 +166,23 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
118
166
  {
119
167
  title: 'Create a factory',
120
168
  description:
121
- 'Create a factory: a group of products that share a knowledge base, one instructions document and ' +
122
- 'their members, so products under it work from the same context. Members are added in the web. Then put ' +
123
- 'products in it with taiga_create_product, or move one there with taiga_move_product. Owners and admins ' +
124
- 'create factories.',
125
- inputSchema: z.object({ name: z.string().trim().min(1).max(255).describe('The factory name') }),
169
+ 'Create a factory: a group of products sharing knowledge, one instructions document and members. Owners ' +
170
+ 'and admins only; members are added in the web. Then add products with taiga_create_product or ' +
171
+ 'taiga_move_product.',
172
+ inputSchema: z.object({
173
+ name: z.string().trim().min(1).max(255).describe('The factory name'),
174
+ allowDuplicateName: allowDuplicateName,
175
+ }),
126
176
  annotations: WRITE,
127
177
  },
128
178
  (params) =>
129
- runTool(async () =>
130
- toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0])
131
- )
179
+ runTool(async () => {
180
+ if (!params.allowDuplicateName) {
181
+ const same = await firstNamed(params.name, async (cursor) => client.listFactories({ limit: 100, cursor }));
182
+ if (same) return duplicateName('factory', same);
183
+ }
184
+ return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
185
+ })
132
186
  );
133
187
 
134
188
  server.registerTool(
@@ -136,8 +190,8 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
136
190
  {
137
191
  title: 'Rename a factory',
138
192
  description:
139
- 'Rename a factory. Its products, members, knowledge and instructions stay as they are, but its slug ' +
140
- 'follows the new name, so links to the factory change.',
193
+ 'Rename a factory. Its slug follows the new name, so links to it change; products, members, knowledge ' +
194
+ 'and instructions stay as they are.',
141
195
  inputSchema: z.object({ factoryId, name: z.string().trim().min(1).max(255).describe('The new name') }),
142
196
  annotations: OPERATE_SETTING,
143
197
  },
@@ -154,10 +208,9 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
154
208
  {
155
209
  title: 'Preview moving a product to another factory',
156
210
  description:
157
- 'Who gains and who loses access if a product moves to another factory: people reach a product through ' +
158
- "its factory, so the source factory's members lose it and the target's gain it. People on both, the " +
159
- "product's own members, and owners and admins keep it, and are named with why. Read this, and show it " +
160
- 'to the person, before taiga_move_product.',
211
+ "Show who gains and who loses access if a product moves to another factory: the source factory's members " +
212
+ "lose it, the target's gain it; people in both, the product's own members, and owners and admins keep " +
213
+ 'it, named with why. Show it to the person before taiga_move_product.',
161
214
  inputSchema: z.object({ productId, factoryId: factoryId.describe('The factory to move it to') }),
162
215
  annotations: READ_ONLY,
163
216
  },
@@ -170,11 +223,10 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
170
223
  {
171
224
  title: 'Move a product to another factory',
172
225
  description:
173
- 'Move a product to another factory. Access follows the factory, so this changes who can reach the ' +
174
- "product (taiga_preview_product_move says who); the product's own knowledge files are re-filed onto " +
175
- 'the new factory, and factory knowledge and instructions stay with the factory that owns them. Refused ' +
176
- '(WORK_IN_FLIGHT) while a plan or build is in flight. Then ask the person: stopLine: true stops the ' +
177
- 'line first and then moves, which interrupts that work.',
226
+ 'Move a product to another factory. Access follows the factory, so this changes who can reach it ' +
227
+ "(taiga_preview_product_move says who). The product's own knowledge moves with it; factory knowledge and " +
228
+ 'instructions stay. Refused (WORK_IN_FLIGHT) while a plan or run is in flight; then ask the person: ' +
229
+ 'stopLine: true stops the line first, interrupting that work.',
178
230
  inputSchema: z.object({
179
231
  productId,
180
232
  factoryId: factoryId.describe('The factory to move it to'),
@@ -227,21 +279,19 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
227
279
  {
228
280
  title: 'Create a product',
229
281
  description:
230
- 'Create a product in a factory, the way New product does. It starts one of two ways, chosen now and not ' +
231
- 'later: scratch (afterwards projectType greenfield), where Discovery is a conversation that writes the ' +
232
- 'specification (held in the web), or import (projectType brownfield), where Taiga reads an existing repository and drafts the documents from ' +
233
- 'the code: link the repository (taiga_link_repository; linking is for owners and admins, so a member ' +
234
- 'asks one), then taiga_start_import. Also what Taiga builds for it: its ' +
235
- 'infrastructure code and its CI/CD pipelines, each following the organization unless decided here; when ' +
236
- 'one is off, deploymentNotes says in a sentence how that half is done today. Material the product should ' +
237
- 'be built from is worth adding before Discovery starts (taiga_add_knowledge with the productId): what is ' +
238
- 'added first shapes the specification.',
282
+ 'Create a product in a factory, as New product does, and return it. Choose now how it starts; it cannot ' +
283
+ 'change later: scratch (projectType greenfield), where Discovery is a conversation in the web that ' +
284
+ 'writes the specification, or import (brownfield), where Taiga drafts the documents from a repository: ' +
285
+ 'taiga_link_repository (owners and admins only), then taiga_start_import. Infrastructure code and CI/CD ' +
286
+ 'pipelines follow the organization unless set here; when one is off, deploymentNotes says how that half ' +
287
+ 'is done today. Add source material first with taiga_add_knowledge (productId): Discovery writes from it.',
239
288
  inputSchema: z.object({
240
289
  factoryId,
241
290
  name: z.string().trim().min(1).max(255).describe('The product name'),
242
291
  start: z.enum(['scratch', 'import']).describe('scratch: describe it in Discovery; import: from a repository'),
243
292
  ...buildScope,
244
293
  deploymentNotes: deploymentNotes.optional(),
294
+ allowDuplicateName: allowDuplicateName,
245
295
  }),
246
296
  annotations: WRITE,
247
297
  },
@@ -249,6 +299,10 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
249
299
  runTool(async () => {
250
300
  const contradiction = notesWithNothingToDescribe(params);
251
301
  if (contradiction) return contradiction;
302
+ if (!params.allowDuplicateName) {
303
+ const same = await firstNamed(params.name, async (cursor) => client.listProducts({ limit: 100, cursor }));
304
+ if (same) return duplicateName('product', same);
305
+ }
252
306
  const product = (
253
307
  await client.createProduct({
254
308
  name: params.name,
@@ -268,11 +322,10 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
268
322
  {
269
323
  title: "Change a product's settings",
270
324
  description:
271
- "Change a product's name or what Taiga builds for it. A new name changes the slug, so links to the product change. What Taiga " +
272
- 'builds for it: its infrastructure code and CI/CD pipelines (null ' +
273
- 'returns one to the organization default), the sentence on how a half Taiga does not build is done today, ' +
274
- "and whether it inherits the organization's design standards (null follows the default for how the " +
275
- 'product started). Later plans read the new answer; work already planned is not redone. Autonomy is ' +
325
+ "Change a product's name or what Taiga builds for it. A new name changes the slug, so links change. " +
326
+ 'Infrastructure code and CI/CD pipelines: null returns one to the organization default; deploymentNotes ' +
327
+ 'says how a half Taiga does not build is done; emitDesignStandards: null follows the default for how the ' +
328
+ 'product started. Later plans read the change; planned work is not redone. Autonomy is ' +
276
329
  'taiga_set_product_autonomy.',
277
330
  inputSchema: z.object({
278
331
  productId,
@@ -306,11 +359,9 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
306
359
  {
307
360
  title: "Refresh a product's documents from its code",
308
361
  description:
309
- "Derive the reality documents again from the code, on the branch Taiga works on (the link's effectiveBranch: " +
310
- "the chosen branch, or the repository's default): all of them, or the ones named (with " +
311
- 'what they are derived from). For a document the import misread, refresh it rather than importing ' +
312
- 'again; unchanged sections are not republished. Runs an agent in the background and returns the job; ' +
313
- 'taiga_wait_for_job says when it is done.',
362
+ 'Derive the reality documents again from the code on the branch Taiga works on (effectiveBranch): all, ' +
363
+ 'or the ones named. For a document the import misread, refresh it instead of importing again; unchanged ' +
364
+ 'sections are not republished. Returns the job; taiga_wait_for_job says when it is done.',
314
365
  inputSchema: z.object({
315
366
  productId,
316
367
  docTypes: z
@@ -331,14 +382,11 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
331
382
  {
332
383
  title: 'Finish Discovery',
333
384
  description:
334
- "Finish a product's Discovery. Refused (CONFLICT) with details.reason: documents_incomplete names the " +
335
- 'required documents still unpublished (details.missingDocuments) and how they are made; not_in_setup ' +
336
- 'means it is already ' +
337
- 'finished; a required check left unstamped (stamped in the web) is refused with its details too. It ' +
338
- 'locks the document set ' +
339
- 'and opens the rest of the product; for an ' +
340
- 'imported product it is also when the first initiatives are planned. A locked document changes only ' +
341
- 'after taiga_reopen_discovery. Ask the person first: it is their decision that the documents are right.',
385
+ "Finish a product's Discovery: it locks the documents and opens the rest of the product; for an imported " +
386
+ 'product the first initiatives are planned. Refused (CONFLICT) with details.reason: documents_incomplete ' +
387
+ '(details.missingDocuments lists them and how they are made), not_in_setup (already finished), or a ' +
388
+ 'required check not yet stamped (stamped in the web). A locked document changes only after ' +
389
+ 'taiga_reopen_discovery. The person decides the documents are right: ask first.',
342
390
  inputSchema: z.object({ productId }),
343
391
  annotations: OPERATE,
344
392
  },
@@ -380,13 +428,11 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
380
428
  {
381
429
  title: 'Import a codebase into a product',
382
430
  description:
383
- 'Start the import of a product created to import a codebase: Taiga reads its linked repository and ' +
384
- 'derives the full document set from the code, each document citing the files it came from. Importing ' +
385
- 'is one decision; the rest runs by itself. The scanners then sweep the repository, the documents are ' +
386
- "assessed against the organization's policies, and the first initiatives are planned when Discovery is " +
387
- 'finished. Refused for a product started from scratch (the choice is made at creation), without a ' +
388
- 'linked repository, or while an import runs. Runs agents in the background and returns the job; ' +
389
- 'taiga_wait_for_job says when it is done (it takes a while).',
431
+ 'Start importing a product created for import, and return the job. Taiga reads the linked repository and ' +
432
+ 'derives the full document set from the code, citing the files. Then the scanners sweep it, the ' +
433
+ 'documents are assessed against policies, and the first initiatives are planned when Discovery finishes. ' +
434
+ 'Refused for a scratch product, without a linked repository, or while an import runs. taiga_wait_for_job ' +
435
+ 'says when it is done (it takes a while).',
390
436
  inputSchema: z.object({ productId }),
391
437
  annotations: OPERATE,
392
438
  },
@@ -61,7 +61,9 @@ export async function searchPages<T>(
61
61
  fetchPage: (cursor: string | undefined) => Promise<{ data: T[]; nextCursor: string | null }>,
62
62
  query: string,
63
63
  text: (row: T) => unknown[],
64
- limit: number
64
+ limit: number,
65
+ /** A further condition a row must meet, besides the query. */
66
+ keep: (row: T) => boolean = () => true
65
67
  ): Promise<{ rows: T[]; more: boolean }> {
66
68
  const needle = query.toLowerCase();
67
69
  const rows: T[] = [];
@@ -70,6 +72,7 @@ export async function searchPages<T>(
70
72
  const result = await fetchPage(cursor);
71
73
  const page = result.data;
72
74
  for (const [at, row] of page.entries()) {
75
+ if (!keep(row)) continue;
73
76
  if (!text(row).some((value) => typeof value === 'string' && value.toLowerCase().includes(needle))) continue;
74
77
  rows.push(row);
75
78
  // Full: stop reading. \`more\` then means "there may be more", not
package/src/tools/wait.ts CHANGED
@@ -360,17 +360,12 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
360
360
  {
361
361
  title: 'Wait for an initiative',
362
362
  description:
363
- 'Wait for an initiative to reach a point (planned, building, a pull request, done), instead of reading it again and again: changed (anything ' +
364
- 'about where it is or its latest run), planned (its plan landed), your_turn (it waits on a person: a ' +
365
- 'plan to review, a pull request, a flag), pr_open (a pull request open now; a merged or closed one does ' +
366
- 'not count), run_settled (its latest run stopped moving) or done (finished; a canceled one stops the wait). ' +
367
- 'Returns as soon as that holds (reached: true), early when it cannot hold without a person (a flag, a ' +
368
- 'failed plan, a plan waiting for its verdict, a pull request waiting for a review, a cancelled ' +
369
- 'initiative, or a landed plan whose review gate cannot be read; stoppedBecause says which), or at the ' +
370
- 'timeout with reached: false: ' +
371
- 'call again to keep waiting. Conditions are states, not events: an initiative whose latest run is already ' +
372
- 'settled answers run_settled at once; item is null when not even the first read answered in time. ' +
373
- 'Planning takes minutes and a build up to an hour.',
363
+ 'Wait until an initiative reaches a condition instead of polling: changed, planned, your_turn (waits on ' +
364
+ 'a person), pr_open (open now; merged or closed does not count), run_settled (latest run stopped moving) ' +
365
+ 'or done. Returns reached: true when it ' +
366
+ 'holds; early with stoppedBecause when only a person can move it (a flag, failed plan, plan or pull ' +
367
+ 'request awaiting review, a cancel); or reached: false at the timeout: call again. A condition already ' +
368
+ 'true answers at once. Planning takes minutes, a run up to an hour.',
374
369
  inputSchema: z.object({
375
370
  initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it'),
376
371
  until: z.enum(ITEM_CONDITIONS).describe('What to wait for'),
@@ -425,13 +420,10 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
425
420
  {
426
421
  title: 'Wait for a run',
427
422
  description:
428
- 'Wait for a run to reach a point (a step, a pull request, settled): changed (status, steps or pull request), step_done ' +
429
- '(one more step finished), pr_open (open now, not merged or closed) or settled (it stopped moving: in ' +
430
- 'review, completed, failed, stopped). ' +
431
- 'Returns as soon as that holds (reached: true), early when the run settled without it (stoppedBecause; ' +
432
- 'changed waits on, since a person resuming the run is a change), or at the timeout with reached: false: ' +
433
- 'call again to keep waiting. Conditions are states: a run already settled answers settled at once; run ' +
434
- 'is null when not even the first read answered in time.',
423
+ 'Wait until a run reaches a condition: changed, step_done (one more step finished), pr_open (open now) ' +
424
+ 'or settled (in review, completed, failed, stopped). Returns reached: true when it holds; early with ' +
425
+ 'stoppedBecause if the run settled without it; or reached: false at the timeout: call again. A settled ' +
426
+ 'run answers settled at once; run is null if the first read timed out.',
435
427
  inputSchema: z.object({
436
428
  runId: idInput('run', 'taiga_list_runs gives it'),
437
429
  until: z.enum(BUILD_CONDITIONS).describe('What to wait for'),
@@ -468,10 +460,9 @@ export function registerWaitTools(server: McpServer, client: TaigaClient, option
468
460
  {
469
461
  title: 'Wait for an agent job',
470
462
  description:
471
- 'Wait until an agent job finishes (completed, failed or cancelled): an initiative request, a document, ' +
472
- 'a refresh. Returns the job as taiga_get_job reads it, with what came of an initiative request, as soon ' +
473
- 'as it settles (reached: true), or at the timeout with reached: false: call again to keep waiting. Most ' +
474
- 'jobs take a few minutes.',
463
+ 'Wait until an agent job finishes (completed, failed or cancelled). Returns the job as taiga_get_job ' +
464
+ "reads it, with an initiative request's outcome, and reached: true; or reached: false at the timeout: " +
465
+ 'call again to keep waiting. Most jobs take a few minutes.',
475
466
  inputSchema: z.object({
476
467
  jobId: idInput('job', 'the tool that started the job returned it'),
477
468
  timeoutSeconds,