@taiga-ai/mcp-server 0.17.0-dev.441 → 0.18.0-dev.443

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.
@@ -112,7 +112,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
112
112
  'Action Required resolves. The mark says a human read it, so make it only when the person tells you they ' +
113
113
  'have: reading it yourself is not a review.',
114
114
  inputSchema: z.object({ productId, type: reviewableType }),
115
- annotations: WRITE,
115
+ annotations: OPERATE,
116
116
  },
117
117
  (params) =>
118
118
  runTool(async () => toolResult((await client.markProductDocumentReviewed(params.productId, params.type)).data))
@@ -130,7 +130,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
130
130
  'are regenerated while it is open only: a finished Discovery locks them, and reopening it is done in ' +
131
131
  'the web. The optional Service Blueprint can be generated whenever someone wants one. The result is published as the new version, and ' +
132
132
  'the documents generated from it are marked outdated in turn. Runs an agent in the background: this ' +
133
- 'returns the job; taiga_wait_for_job says when it is done. Ask the person first.',
133
+ 'returns the job; taiga_wait_for_job says when it is done.',
134
134
  inputSchema: z.object({ productId, type: generatedType }),
135
135
  annotations: OPERATE,
136
136
  },
@@ -170,7 +170,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
170
170
  'order they build on each other, one after another (each next one starts when the one before it is ' +
171
171
  'published). Refused (CONFLICT) while a document is already being generated. On an imported product, ' +
172
172
  'it queues every unpublished document. Runs agents in the background: this returns the jobs it started ' +
173
- '(jobIds); taiga_stop_generating_documents stops the run. Ask the person first.',
173
+ '(jobIds); taiga_stop_generating_documents stops the run.',
174
174
  inputSchema: z.object({ productId }),
175
175
  annotations: OPERATE,
176
176
  },
@@ -183,8 +183,7 @@ export function registerProductDocumentWrites(server: McpServer, client: TaigaCl
183
183
  title: 'Stop generating product documents',
184
184
  description:
185
185
  'Stop a Generate remaining run: every document of it still being generated is cancelled and nothing ' +
186
- 'after them starts; published documents stay. An empty list means no run was going. Interrupts agent ' +
187
- 'work; ask the person first.',
186
+ 'after them starts; published documents stay. An empty list means no run was going. Interrupts agent work.',
188
187
  inputSchema: z.object({ productId }),
189
188
  annotations: OPERATE,
190
189
  },
@@ -19,7 +19,7 @@ import {
19
19
  } from './catalogue.js';
20
20
  import { apiFailureCode, apiFailureText, isMissOrRefusal, runTool, toolError, toolResult } from './errors.js';
21
21
  import { parseRemote } from './remote.js';
22
- import { sectionReadResult, stripProvenance } from './sections.js';
22
+ import { sectionReadResult, sectionsInput, stripProvenance } from './sections.js';
23
23
  import { SEARCH_NOTE, SEARCH_PAGE_SIZE, SEARCH_WITH_CURSOR, page, pickFields, searchPages } from './summarize.js';
24
24
 
25
25
  /** What a product row needs to say for the model to pick one and read it in full. */
@@ -441,7 +441,8 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
441
441
  "review and update times), and say which timeline is the product's own (defaultOrigin). " +
442
442
  `Two timelines: ${ORIGIN_MEANING.plan} ${ORIGIN_MEANING.reality} ` +
443
443
  'An empty side comes with a note saying why; a side the API could not list is null, with a note. ' +
444
- 'Use taiga_get_product_document with the type and origin to read one in full.',
444
+ 'Use taiga_get_product_document with the type and origin to read one in full, or with sections: [] ' +
445
+ 'for its outline (section ids and sizes) first.',
445
446
  inputSchema: z.object({ productId }),
446
447
  annotations: READ_ONLY,
447
448
  },
@@ -463,9 +464,9 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
463
464
  'Read the plan to learn what to build: specification (requirements), architecture (design), ' +
464
465
  'threat_model (security risks), technology_decisions. Read reality, when the product has it, to ' +
465
466
  "learn what the code actually does before changing it. Omit origin for the product's own timeline. " +
466
- 'A large document runs to tens of thousands of characters: name the sections you need (the ids ' +
467
- 'taiga_get_product_context lists) and the read carries only those, listing the rest with their sizes ' +
468
- 'and any id the document lacks (unknownSections). ' +
467
+ 'A large document runs to tens of thousands of characters: sections: [] reads its outline (every ' +
468
+ 'section id with its size, no text) on either timeline, then name the sections you need and the read ' +
469
+ 'carries only those, listing the rest with their sizes and any id the document lacks (unknownSections). ' +
469
470
  'A read that finds nothing says which documents and timelines the product does have.',
470
471
  inputSchema: z.object({
471
472
  productId,
@@ -473,12 +474,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
473
474
  .enum(PRODUCT_DOCUMENT_TYPES)
474
475
  .describe('Document type -- taiga_list_product_documents shows which ones the product has'),
475
476
  origin: originInput,
476
- sections: z
477
- .array(z.string().min(1).max(200))
478
- .min(1)
479
- .max(50)
480
- .optional()
481
- .describe('Only these sections, by id; omit for the whole document'),
477
+ sections: sectionsInput,
482
478
  }),
483
479
  annotations: READ_ONLY,
484
480
  },
@@ -54,6 +54,16 @@ const repositoryId = idInput(
54
54
  'taiga_list_product_repositories gives it; a GitHub repository id is not one'
55
55
  );
56
56
 
57
+ /** Round-robin over lists: the first of each, then the second of each, and so on. */
58
+ export function interleave<T>(lists: T[][]): T[] {
59
+ const out: T[] = [];
60
+ const longest = Math.max(0, ...lists.map((list) => list.length));
61
+ for (let index = 0; index < longest; index++) {
62
+ for (const list of lists) if (index < list.length) out.push(list[index]!);
63
+ }
64
+ return out;
65
+ }
66
+
57
67
  export function registerRepositoryTools(server: McpServer, client: TaigaClient) {
58
68
  if (client.hasCapability('products:read')) {
59
69
  server.registerTool(
@@ -68,8 +78,12 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
68
78
  },
69
79
  (params) =>
70
80
  runTool(async () => {
81
+ // The route answers one page of 100 and takes no cursor: past that, the list says it is cut.
71
82
  const result = await client.listProductRepositories(params.productId);
72
- return toolResult(pickFields(result.data as Row[], LINK_FIELDS));
83
+ return toolResult({
84
+ data: pickFields(result.data as Row[], LINK_FIELDS),
85
+ truncated: result.nextCursor !== null,
86
+ });
73
87
  })
74
88
  );
75
89
 
@@ -87,7 +101,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
87
101
  (params) =>
88
102
  runTool(async () => {
89
103
  const result = await client.listRepositoryBranches(params.productId, params.repositoryId);
90
- return toolResult({ branches: result.data, truncated: result.truncated });
104
+ return toolResult({ data: result.data, truncated: result.truncated });
91
105
  })
92
106
  );
93
107
  }
@@ -99,11 +113,21 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
99
113
  title: 'Find a repository to link',
100
114
  description:
101
115
  "The repositories the organization's GitHub connection can reach, to link one to a product " +
102
- '(taiga_link_repository takes its id). Searches by name with query; without installationId it reads ' +
103
- 'every connected GitHub account. No connection means GitHub is not connected yet: that is done once, ' +
104
- 'in the web, by someone who can install the app.',
116
+ '(taiga_link_repository takes its id). Searches by name with query. Name the GitHub account (account: ' +
117
+ 'its login, the owner in owner/repo, or installationId) to read only its repositories; without one it ' +
118
+ 'reads every connected account and takes rows from each in turn, so one account cannot fill the page. No ' +
119
+ 'connection means GitHub is not connected yet: that is done once, in the web, by someone who can ' +
120
+ 'install the app.',
105
121
  inputSchema: z.object({
106
122
  query: z.string().trim().min(1).max(256).optional().describe('Part of the repository name'),
123
+ account: z
124
+ .string()
125
+ .trim()
126
+ .min(1)
127
+ // owner/repo: a 39-character login, the slash and a 100-character name.
128
+ .max(140)
129
+ .optional()
130
+ .describe('The GitHub account login (the owner in owner/repo), case-insensitive'),
107
131
  installationId: idInput('GitHub account', 'this tool lists them').optional(),
108
132
  ...pagination,
109
133
  }),
@@ -119,9 +143,13 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
119
143
  'GitHub is not connected for this organization. It is connected once, in the web; then link.'
120
144
  );
121
145
  }
122
- const chosen = params.installationId
123
- ? installations.filter((i) => i.id === params.installationId)
124
- : installations;
146
+ // owner/repo names the owner too: the part before the slash is the account.
147
+ const login = params.account?.split('/')[0]!.toLowerCase();
148
+ const chosen = installations.filter(
149
+ (i) =>
150
+ (!params.installationId || i.id === params.installationId) &&
151
+ (!login || String(i.accountLogin ?? '').toLowerCase() === login)
152
+ );
125
153
  if (chosen.length === 0) {
126
154
  return toolError('NOT_FOUND', 'No such GitHub connection; these are the connected accounts.', { accounts });
127
155
  }
@@ -131,7 +159,9 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
131
159
  return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
132
160
  }
133
161
  if (chosen.length > 1 && params.cursor !== undefined) {
134
- return toolError('VALIDATION_ERROR', 'A cursor pages one account: name its installationId.', { accounts });
162
+ return toolError('VALIDATION_ERROR', 'A cursor pages one account: name it (account or installationId).', {
163
+ accounts,
164
+ });
135
165
  }
136
166
  // `limit` is the most rows the answer carries, however many accounts
137
167
  // are read: a search answers its ranked matches whatever the page
@@ -158,14 +188,15 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
158
188
  const pages = await Promise.all(
159
189
  chosen.map((i) => client.listInstallationRepositories(String(i.id), { q: params.query, limit: max }))
160
190
  );
161
- const found = pages.flatMap((p) => rows(p.data));
191
+ // In turn, one row from each account, so the first account's page
192
+ // cannot fill the answer and hide the others.
193
+ const perAccount = pages.map((p) => rows(p.data));
194
+ const found = interleave(perAccount);
162
195
  const cut = found.length > max || pages.some((p) => p.nextCursor);
163
196
  return toolResult({
164
197
  accounts,
165
198
  repositories: page(found.slice(0, max), null),
166
- ...(cut
167
- ? { more: 'More than the limit across accounts: name an installationId, or narrow the query.' }
168
- : {}),
199
+ ...(cut ? { more: 'More than the limit across accounts: name an account, or narrow the query.' } : {}),
169
200
  });
170
201
  })
171
202
  );
@@ -182,7 +213,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
182
213
  'product, or with replacesRepositoryId swap the one linked now for it in one step (naming the same ' +
183
214
  'repository there changes nothing; linking one already linked without it is refused). A link Taiga ' +
184
215
  'manages (isManaged) cannot be replaced or unlinked. Once linked, planning reads its code, runs open pull requests in it, ' +
185
- 'sweeps scan it, and its deployment history is imported in the background. Ask the person first.',
216
+ 'sweeps scan it, and its deployment history is imported in the background.',
186
217
  inputSchema: z.object({
187
218
  productId,
188
219
  githubRepositoryId: idInput('GitHub repository', 'taiga_list_linkable_repositories gives it'),
@@ -208,7 +239,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
208
239
  "Choose the branch of a product's repository that Taiga branches from and opens its pull requests " +
209
240
  "against (taiga_list_repository_branches lists them), or null to follow the repository's default " +
210
241
  'again. A protected branch is not refused, but Taiga may not be able to merge into it: the answer says ' +
211
- 'when it is protected. Optionally a label for the link. Ask the person first.',
242
+ 'when it is protected. Optionally a label for the link.',
212
243
  inputSchema: z.object({
213
244
  productId,
214
245
  repositoryId,
@@ -250,7 +281,7 @@ export function registerRepositoryTools(server: McpServer, client: TaigaClient)
250
281
  'Unlink a repository from a product. Without one, the product can still have documents and initiatives ' +
251
282
  'but no changes: nothing is planned against code, built or swept. The repository itself is untouched, ' +
252
283
  'and a repository Taiga created for the product cannot be unlinked. ' +
253
- 'To swap it for another, use taiga_link_repository with replacesRepositoryId instead. Ask the person first.',
284
+ 'To swap it for another, use taiga_link_repository with replacesRepositoryId instead.',
254
285
  inputSchema: z.object({ productId, repositoryId }),
255
286
  annotations: OPERATE,
256
287
  },
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { CallToolResult } from '@modelcontextprotocol/server';
15
+ import { z } from 'zod';
15
16
  import { toolError, toolResult } from './errors.js';
16
17
 
17
18
  /** A section as stored: an object with `content` and metadata, or (older rows) a bare string. */
@@ -129,20 +130,31 @@ export function budgetSections(doc: Record<string, unknown>, budget: number): Se
129
130
  return cutTo(doc, sections, Object.fromEntries(kept));
130
131
  }
131
132
 
133
+ /**
134
+ * The `sections` input of a document read: some sections by id, none for the
135
+ * outline (every id with its size and no text), or omitted for the whole.
136
+ */
137
+ export const sectionsInput = z
138
+ .array(z.string().min(1).max(200))
139
+ .max(50)
140
+ .optional()
141
+ .describe(
142
+ 'Only these sections, by id; [] for the outline (every section id and its size, no text); omit for the whole document'
143
+ );
144
+
132
145
  /**
133
146
  * The answer to a read that names sections: the ones the document has, the
134
147
  * rest listed with their sizes, and the asked-for ids it lacks under
135
- * unknownSections. One wrong id does not throw away the right ones (the
136
- * model retries only the miss); none right is a miss that lists the real ids.
148
+ * unknownSections. One shape whatever matched (PRO-2778): a wrong id does not
149
+ * throw away the right ones, and an answer with none right still lists every
150
+ * real id with its size, so the retry needs nothing else. No ids is the
151
+ * outline.
137
152
  */
138
153
  export function sectionReadResult(doc: Record<string, unknown>, ids: readonly string[], label: string): CallToolResult {
139
154
  const cut = pickSections(doc, new Set(ids));
140
155
  if (!cut) return toolError('VALIDATION_ERROR', `The ${label} is not made of sections; read it without sections.`);
141
156
  const all = Object.keys(sectionsOf(doc.data as Record<string, unknown>) ?? {});
142
157
  const unknownSections = ids.filter((id) => !all.includes(id));
143
- if (unknownSections.length === ids.length) {
144
- return toolError('NOT_FOUND', `The ${label} has no section ${ids.join(', ')}; its sections are ${all.join(', ')}.`);
145
- }
146
158
  return toolResult({
147
159
  ...cut.doc,
148
160
  omittedSections: cut.omittedSections,
@@ -154,7 +154,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
154
154
  title: 'Rename a factory',
155
155
  description:
156
156
  'Rename a factory. Its products, members, knowledge and instructions stay as they are, but its slug ' +
157
- 'follows the new name, so links to the factory change. Ask the person first.',
157
+ 'follows the new name, so links to the factory change.',
158
158
  inputSchema: z.object({ factoryId, name: z.string().trim().min(1).max(255).describe('The new name') }),
159
159
  annotations: OPERATE_SETTING,
160
160
  },
@@ -194,7 +194,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
194
194
  "product (taiga_preview_product_move says who); the product's own knowledge files are re-filed onto " +
195
195
  'the new factory, and factory knowledge and instructions stay with the factory that owns them. Refused ' +
196
196
  '(CONFLICT) while a plan or build is in flight: stopLine: true stops the line first and then moves, which ' +
197
- 'interrupts that work. Ask the person first.',
197
+ 'interrupts that work.',
198
198
  inputSchema: z.object({
199
199
  productId,
200
200
  factoryId: factoryId.describe('The factory to move it to'),
@@ -230,8 +230,8 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
230
230
  title: 'Create a product',
231
231
  description:
232
232
  'Create a product in a factory, the way New product does. It starts one of two ways, chosen now and not ' +
233
- 'later: scratch, where Discovery is a conversation that writes the specification (held in the web), or ' +
234
- 'import (projectType brownfield), where Taiga reads an existing repository and drafts the documents from ' +
233
+ 'later: scratch (afterwards projectType greenfield), where Discovery is a conversation that writes the ' +
234
+ 'specification (held in the web), or import (projectType brownfield), where Taiga reads an existing repository and drafts the documents from ' +
235
235
  'the code: link the repository (taiga_link_repository; linking is for owners and admins, so a member ' +
236
236
  'asks one), then taiga_start_import. Also what Taiga builds for it: its ' +
237
237
  'infrastructure code and its CI/CD pipelines, each following the organization unless decided here; when ' +
@@ -275,7 +275,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
275
275
  'returns one to the organization default), the sentence on how a half Taiga does not build is done today, ' +
276
276
  "and whether it inherits the organization's design standards (null follows the default for how the " +
277
277
  'product started). Later plans read the new answer; work already planned is not redone. Autonomy is ' +
278
- 'taiga_set_product_autonomy. Ask the person first.',
278
+ 'taiga_set_product_autonomy.',
279
279
  inputSchema: z.object({
280
280
  productId,
281
281
  name: z.string().trim().min(1).max(255).optional().describe('A new name'),
@@ -312,7 +312,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
312
312
  "the chosen branch, or the repository's default): all of them, or the ones named (with " +
313
313
  'what they are derived from). For a document the import misread, refresh it rather than importing ' +
314
314
  'again; unchanged sections are not republished. Runs an agent in the background and returns the job; ' +
315
- 'taiga_wait_for_job says when it is done. Ask the person first.',
315
+ 'taiga_wait_for_job says when it is done.',
316
316
  inputSchema: z.object({
317
317
  productId,
318
318
  docTypes: z
@@ -367,7 +367,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
367
367
  title: 'Reopen Discovery',
368
368
  description:
369
369
  "Reopen a finished product's Discovery so its documents can be amended; finish it again afterwards. " +
370
- 'Nothing built from them is undone. Ask the person first.',
370
+ 'Nothing built from them is undone.',
371
371
  inputSchema: z.object({ productId }),
372
372
  annotations: OPERATE,
373
373
  },
@@ -390,7 +390,7 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
390
390
  "assessed against the organization's policies, and the first initiatives are planned when Discovery is " +
391
391
  'finished. Refused for a product started from scratch (the choice is made at creation), without a ' +
392
392
  'linked repository, or while an import runs. Runs agents in the background and returns the job; ' +
393
- 'taiga_wait_for_job says when it is done (it takes a while). Ask the person first.',
393
+ 'taiga_wait_for_job says when it is done (it takes a while).',
394
394
  inputSchema: z.object({ productId }),
395
395
  annotations: OPERATE,
396
396
  },