@taiga-ai/mcp-server 0.7.0-dev.395 → 0.8.0-dev.397

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.
@@ -2,11 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import type { TaigaClient } from '../client.js';
4
4
  import { READ_ONLY } from './annotations.js';
5
+ import { pagination, productId } from './inputs.js';
5
6
  import { runTool, toolResult } from './errors.js';
6
- import { pickFields } from './summarize.js';
7
+ import { page, pickFields } from './summarize.js';
7
8
 
8
9
  /** What a list row needs to say for the model to pick a run to read in full. */
9
- const BUILD_SUMMARY_FIELDS = [
10
+ export const BUILD_SUMMARY_FIELDS = [
10
11
  'id',
11
12
  'runNumber',
12
13
  'backlogItemId',
@@ -42,23 +43,22 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
42
43
  'progress. Use this to see what Taiga already built or is building, then read one run with ' +
43
44
  'taiga_get_build. Filter by backlog item to follow a single initiative.',
44
45
  inputSchema: {
45
- productId: z
46
- .string()
47
- .uuid()
48
- .describe('Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products'),
46
+ productId,
49
47
  backlogItemId: z
50
48
  .string()
51
49
  .uuid()
52
50
  .optional()
53
51
  .describe("Only this backlog item's runs -- get from taiga_list_backlog_items"),
54
- limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
52
+ ...pagination,
55
53
  },
56
54
  annotations: READ_ONLY,
57
55
  },
58
56
  (params) =>
59
57
  runTool(async () => {
60
58
  const result = await client.listBuildRuns(params.productId, params);
61
- return toolResult(pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS));
59
+ return toolResult(
60
+ page(pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS), result.nextCursor)
61
+ );
62
62
  })
63
63
  );
64
64
 
@@ -0,0 +1,280 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
+ import { isSettledOrArchived } from '@taiga/types';
4
+ import { z } from 'zod';
5
+ import { TaigaApiError, type TaigaClient } from '../client.js';
6
+ import { READ_ONLY } from './annotations.js';
7
+ import { BACKLOG_SUMMARY_FIELDS } from './backlogs.js';
8
+ import { BUILD_SUMMARY_FIELDS } from './builds.js';
9
+ import { POLICY_TYPES, defaultOriginFor, isImportedProduct, type ProductSettingsView } from './catalogue.js';
10
+ import { isNetworkFailure, networkFailureDetail, runTool, toolResult } from './errors.js';
11
+ import { productId, remoteUrl, slug } from './inputs.js';
12
+ import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
13
+ import { instructionsText, sectionsOf, stripProvenance } from './sections.js';
14
+ import { pickFields } from './summarize.js';
15
+
16
+ /**
17
+ * One call at the start of a coding session: everything the model would
18
+ * otherwise collect in eight, fanned out here. Each part stands on its own
19
+ * (Promise.allSettled): a part the API refuses or fails is named under
20
+ * `unavailable` with what the API said, a part the key's capabilities do not
21
+ * cover is named under `notGranted`, and neither is ever shown as empty. A
22
+ * failure that is not the API's (a bug in this code) is not downgraded; it
23
+ * fails the call so it surfaces.
24
+ */
25
+
26
+ /** The two documents a coding session starts from; the rest are a read away. */
27
+ const CONTEXT_DOCUMENT_TYPES = ['specification', 'architecture'] as const;
28
+
29
+ const REPOSITORY_SUMMARY_FIELDS = [
30
+ 'id',
31
+ 'provider',
32
+ 'repositoryName',
33
+ 'repositoryUrl',
34
+ 'effectiveBranch',
35
+ 'isManaged',
36
+ ] as const;
37
+
38
+ /** How many of the latest runs to carry; taiga_list_builds pages the rest. */
39
+ const BUILDS_IN_CONTEXT = 10;
40
+
41
+ /** Pages of 100 the backlog part follows before it stops and says so. */
42
+ const BACKLOG_PAGE_LIMIT = 10;
43
+
44
+ const PARTS = {
45
+ factory: 'factories:read',
46
+ documents: 'products:read',
47
+ policies: 'tenant:documents:read',
48
+ backlog: 'backlog:read',
49
+ repositories: 'products:read',
50
+ builds: 'products:read',
51
+ } as const;
52
+ type Part = keyof typeof PARTS;
53
+
54
+ /** What the API said when a part failed, in the words the bundle reports. */
55
+ function unavailableReason(reason: unknown): string {
56
+ if (reason instanceof TaigaApiError) {
57
+ return `${reason.status} ${reason.code ?? ''}`.trim() + (reason.apiMessage ? `: ${reason.apiMessage}` : '');
58
+ }
59
+ return `the API could not be reached: ${networkFailureDetail(reason as TypeError)}`;
60
+ }
61
+
62
+ /** Whether a failed part is the API's doing; anything else is ours and propagates. */
63
+ function isApiFailure(reason: unknown): boolean {
64
+ return reason instanceof TaigaApiError || isNetworkFailure(reason);
65
+ }
66
+
67
+ function isNotFound(reason: unknown): boolean {
68
+ return reason instanceof TaigaApiError && reason.status === 404;
69
+ }
70
+
71
+ /** The factory's standing guidance in full; null, with a note, when none is published or it is not section-shaped. */
72
+ async function factoryPart(client: TaigaClient, factoryId: string) {
73
+ const factory = (await client.getFactory(factoryId)).data as Record<string, unknown>;
74
+ const base = { id: factory.id, name: factory.name, slug: factory.slug };
75
+ try {
76
+ const doc = (await client.getFactoryDocument(factoryId, 'factory_instructions')).data;
77
+ const sections = sectionsOf(doc.data);
78
+ if (!sections) {
79
+ return {
80
+ ...base,
81
+ instructions: null,
82
+ instructionsVersion: doc.version,
83
+ note: 'The published instructions are not made of sections; read them with taiga_get_factory_document.',
84
+ };
85
+ }
86
+ // Every section, as the API injects them into the agents; a factory
87
+ // whose instructions grew a second section is not cut to the first.
88
+ return { ...base, instructions: instructionsText(sections), instructionsVersion: doc.version };
89
+ } catch (error) {
90
+ if (!isNotFound(error)) throw error;
91
+ return {
92
+ ...base,
93
+ instructions: null,
94
+ instructionsVersion: null,
95
+ note: 'This factory has not published instructions yet.',
96
+ };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The specification and architecture from the product's own timeline: in full
102
+ * by default, or as section ids when the caller wants a small bundle. Null,
103
+ * with a note, where unpublished.
104
+ */
105
+ async function documentsPart(client: TaigaClient, product: Record<string, unknown>, full: boolean) {
106
+ const productId = String(product.id);
107
+ const origin = defaultOriginFor(product as ProductSettingsView);
108
+ const reads = await Promise.allSettled(
109
+ CONTEXT_DOCUMENT_TYPES.map((type) => client.getProductDocument(productId, type, origin))
110
+ );
111
+ const out: Record<string, unknown> = { origin };
112
+ const notes: string[] = [];
113
+ CONTEXT_DOCUMENT_TYPES.forEach((type, index) => {
114
+ const read = reads[index]!;
115
+ if (read.status === 'fulfilled') {
116
+ const doc = stripProvenance(read.value.data as Record<string, unknown>);
117
+ if (full) out[type] = doc;
118
+ else {
119
+ const sections = sectionsOf((doc.data as Record<string, unknown>) ?? {});
120
+ if (sections) out[type] = { version: doc.version, sections: Object.keys(sections) };
121
+ else {
122
+ out[type] = { version: doc.version, sections: null };
123
+ notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
124
+ }
125
+ }
126
+ } else if (isNotFound(read.reason)) {
127
+ out[type] = null;
128
+ notes.push(`No published ${origin} ${type}; taiga_list_product_documents shows what the product has.`);
129
+ } else throw read.reason;
130
+ });
131
+ return { ...out, notes };
132
+ }
133
+
134
+ /** Every published policy as its section ids, so the model knows which one to read with taiga_get_document. */
135
+ async function policiesPart(client: TaigaClient) {
136
+ const rows = (await client.listTenantDocuments({ limit: 100 })).data as Record<string, unknown>[];
137
+ return rows
138
+ .filter((row) => (POLICY_TYPES as readonly string[]).includes(String(row.type)) && row.isDraft === false)
139
+ .map((row) => ({
140
+ type: row.type,
141
+ name: row.name,
142
+ version: row.version,
143
+ sections: Object.keys(sectionsOf((row.data as Record<string, unknown>) ?? {}) ?? {}),
144
+ }));
145
+ }
146
+
147
+ /** The statuses an item is open in; the API's filter takes one at a time. */
148
+ const OPEN_BACKLOG_STATUSES = ['backlog', 'in_progress'] as const;
149
+
150
+ /**
151
+ * The items still open: one status-filtered read per open status (the API
152
+ * leaves archived items out), each paged in full up to a cap the result
153
+ * reports. The list is oldest first and the open items are the newest, so a
154
+ * single page would miss exactly the ones that matter. Rows carrying a
155
+ * retired status (readable for a rolling deploy) match neither filter;
156
+ * the settled check below keeps the shape honest for whatever comes back.
157
+ */
158
+ async function backlogPart(client: TaigaClient, productId: string) {
159
+ // Keyed by id: an item that moves from backlog to in_progress between the
160
+ // two reads would otherwise appear twice.
161
+ const open = new Map<string, Record<string, unknown>>();
162
+ let truncated = false;
163
+ for (const status of OPEN_BACKLOG_STATUSES) {
164
+ let cursor: string | undefined;
165
+ for (let pageIndex = 0; ; pageIndex += 1) {
166
+ if (pageIndex === BACKLOG_PAGE_LIMIT) {
167
+ truncated = true;
168
+ break;
169
+ }
170
+ const result = await client.listBacklogItems(productId, { status, limit: 100, cursor });
171
+ for (const row of result.data as Record<string, unknown>[]) {
172
+ if (
173
+ !isSettledOrArchived({ status: String(row.status), archivedAt: (row.archivedAt as string | null) ?? null })
174
+ ) {
175
+ open.set(String(row.id), row);
176
+ }
177
+ }
178
+ if (!result.nextCursor) break;
179
+ cursor = result.nextCursor;
180
+ }
181
+ }
182
+ return { items: pickFields([...open.values()], BACKLOG_SUMMARY_FIELDS), truncated };
183
+ }
184
+
185
+ /**
186
+ * The linked repositories. The route serves one page of 100 and takes no
187
+ * cursor, so past that the bundle says the list is cut rather than complete.
188
+ */
189
+ async function repositoriesPart(client: TaigaClient, productId: string) {
190
+ const result = await client.listProductRepositories(productId);
191
+ return {
192
+ items: pickFields(result.data as Record<string, unknown>[], REPOSITORY_SUMMARY_FIELDS),
193
+ truncated: result.nextCursor !== null,
194
+ };
195
+ }
196
+
197
+ async function buildsPart(client: TaigaClient, productId: string) {
198
+ const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
199
+ return pickFields(rows, BUILD_SUMMARY_FIELDS);
200
+ }
201
+
202
+ export async function productContext(
203
+ client: TaigaClient,
204
+ product: Record<string, unknown>,
205
+ options: { fullDocuments: boolean }
206
+ ): Promise<CallToolResult> {
207
+ const productId = String(product.id);
208
+ const granted = (part: Part) => client.hasCapability(PARTS[part]);
209
+ const notGranted = (Object.keys(PARTS) as Part[]).filter((part) => !granted(part));
210
+
211
+ const fetchers: Record<Part, () => Promise<unknown>> = {
212
+ factory: () => factoryPart(client, String(product.factoryId)),
213
+ documents: () => documentsPart(client, product, options.fullDocuments),
214
+ policies: () => policiesPart(client),
215
+ backlog: () => backlogPart(client, productId),
216
+ repositories: () => repositoriesPart(client, productId),
217
+ builds: () => buildsPart(client, productId),
218
+ };
219
+ const parts = (Object.keys(fetchers) as Part[]).filter(granted);
220
+ const settled = await Promise.allSettled(parts.map((part) => fetchers[part]()));
221
+
222
+ const bundle: Record<string, unknown> = {
223
+ product: {
224
+ ...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
225
+ projectType: isImportedProduct(product as ProductSettingsView) ? 'brownfield' : 'greenfield',
226
+ defaultOrigin: defaultOriginFor(product as ProductSettingsView),
227
+ },
228
+ };
229
+ const unavailable: Record<string, string> = {};
230
+ parts.forEach((part, index) => {
231
+ const result = settled[index]!;
232
+ if (result.status === 'fulfilled') bundle[part] = result.value;
233
+ else if (isApiFailure(result.reason)) unavailable[part] = unavailableReason(result.reason);
234
+ else throw result.reason;
235
+ });
236
+ return toolResult({
237
+ ...bundle,
238
+ notGranted: notGranted.map((part) => `${part} (needs ${PARTS[part]})`),
239
+ unavailable,
240
+ readNext:
241
+ 'Read a policy in full with taiga_get_document, another product document with taiga_get_product_document, ' +
242
+ 'a backlog item with taiga_get_backlog_item and a run with taiga_get_build.',
243
+ });
244
+ }
245
+
246
+ export function registerContextTools(server: McpServer, client: TaigaClient) {
247
+ if (!client.hasCapability('products:read')) return;
248
+
249
+ server.registerTool(
250
+ 'taiga_get_product_context',
251
+ {
252
+ title: 'Get product context',
253
+ description:
254
+ 'Everything a coding session starts from, in one call: the product and which document timeline is its ' +
255
+ 'own, its factory with the instructions in full, the specification and architecture from that timeline, ' +
256
+ 'every published policy as its section ids, the open backlog items, the linked repositories and the ' +
257
+ 'latest runs. Name the product by ID, slug or the git remote URL of the checkout. A part the API refused ' +
258
+ 'or failed is named under unavailable with what it said; a part the key cannot read is named under ' +
259
+ 'notGranted; neither is shown as empty. The two documents come in full, which on a large product is ' +
260
+ 'several thousand tokens; pass fullDocuments: false for their section ids only. Call this first, then ' +
261
+ 'read what you need in full.',
262
+ inputSchema: {
263
+ productId: productId.optional(),
264
+ slug: slug.optional(),
265
+ remoteUrl: remoteUrl.optional(),
266
+ fullDocuments: z
267
+ .boolean()
268
+ .default(true)
269
+ .describe('Carry the specification and architecture in full (default) or as section ids only'),
270
+ },
271
+ annotations: READ_ONLY,
272
+ },
273
+ (params) =>
274
+ runTool(async () => {
275
+ const resolved = await resolveProductRef(client, params, ['productId', 'slug', 'remoteUrl']);
276
+ if ('error' in resolved) return resolved.error;
277
+ return productContext(client, resolved.product, { fullDocuments: params.fullDocuments });
278
+ })
279
+ );
280
+ }
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
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
6
  import {
6
7
  FACTORY_DOCUMENT_TYPES,
7
8
  OTHER_TENANT_DOCUMENT_TYPES,
@@ -11,7 +12,7 @@ import {
11
12
  } from './catalogue.js';
12
13
  import { runTool, toolError, toolResult } from './errors.js';
13
14
  import { applySectionEdit, sectionContent, sectionsOf, stripProvenance } from './sections.js';
14
- import { pickFields } from './summarize.js';
15
+ import { page, pickFields } from './summarize.js';
15
16
 
16
17
  /**
17
18
  * What a document row needs to say for the model to pick one and read it in
@@ -136,14 +137,14 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
136
137
  `documents (${named(OTHER_TENANT_DOCUMENT_TYPES)}) carry goals, standards, voice and standing ` +
137
138
  'instructions. Use taiga_get_document with the type to read one in full.',
138
139
  inputSchema: {
139
- limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
140
+ ...pagination,
140
141
  },
141
142
  annotations: READ_ONLY,
142
143
  },
143
144
  (params) =>
144
145
  runTool(async () => {
145
146
  const result = await client.listTenantDocuments(params);
146
- return toolResult(summaries(result.data));
147
+ return toolResult(page(summaries(result.data), result.nextCursor));
147
148
  })
148
149
  );
149
150
 
@@ -260,14 +261,14 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
260
261
  'Use taiga_get_factory_document to read the full content.',
261
262
  inputSchema: {
262
263
  factoryId: z.string().uuid().describe('Factory ID -- get from taiga_list_factories'),
263
- limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
264
+ ...pagination,
264
265
  },
265
266
  annotations: READ_ONLY,
266
267
  },
267
268
  (params) =>
268
269
  runTool(async () => {
269
- const result = await client.listFactoryDocuments(params.factoryId, { limit: params.limit });
270
- return toolResult(summaries(result.data));
270
+ const result = await client.listFactoryDocuments(params.factoryId, params);
271
+ return toolResult(page(summaries(result.data), result.nextCursor));
271
272
  })
272
273
  );
273
274
 
@@ -15,6 +15,21 @@ export function toolError(error: string, message: string): CallToolResult {
15
15
  return { content: [{ type: 'text', text: JSON.stringify({ error, message }) }], isError: true };
16
16
  }
17
17
 
18
+ /**
19
+ * fetch rejects with a TypeError carrying a `cause` when the API cannot be
20
+ * reached; a TypeError from our own code carries none and is a bug to surface.
21
+ */
22
+ export function isNetworkFailure(error: unknown): error is TypeError {
23
+ return error instanceof TypeError && 'cause' in error;
24
+ }
25
+
26
+ /** What went wrong on the wire: undici's message is the constant "fetch failed", the cause says why. */
27
+ export function networkFailureDetail(error: TypeError): string {
28
+ const cause = error.cause as { code?: unknown; message?: unknown } | undefined;
29
+ const why = typeof cause?.code === 'string' ? cause.code : typeof cause?.message === 'string' ? cause.message : null;
30
+ return why ? `${error.message} (${why})` : error.message;
31
+ }
32
+
18
33
  const CODE_BY_STATUS: Record<number, string> = {
19
34
  400: 'VALIDATION_FAILED',
20
35
  401: 'UNAUTHENTICATED',
@@ -39,9 +54,11 @@ export async function runTool(handler: () => Promise<CallToolResult>): Promise<C
39
54
  const code = error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
40
55
  return toolError(code, error.apiMessage ?? `Taiga API answered ${error.status}.`);
41
56
  }
42
- // fetch rejects with a TypeError when the API cannot be reached.
43
- if (error instanceof TypeError) {
44
- return toolError('NETWORK_ERROR', `Could not reach the Taiga API: ${error.message}. Check TAIGA_API_URL.`);
57
+ if (isNetworkFailure(error)) {
58
+ return toolError(
59
+ 'NETWORK_ERROR',
60
+ `Could not reach the Taiga API: ${networkFailureDetail(error)}. Check TAIGA_API_URL.`
61
+ );
45
62
  }
46
63
  throw error;
47
64
  }
@@ -4,6 +4,7 @@ import { registerProductTools } from './products.js';
4
4
  import { registerDocumentTools } from './documents.js';
5
5
  import { registerBacklogTools } from './backlogs.js';
6
6
  import { registerBuildTools } from './builds.js';
7
+ import { registerContextTools } from './context.js';
7
8
 
8
9
  /**
9
10
  * Register all MCP tools based on the API key's effective capabilities.
@@ -15,4 +16,5 @@ export function registerAllTools(server: McpServer, client: TaigaClient): void {
15
16
  registerDocumentTools(server, client);
16
17
  registerBacklogTools(server, client);
17
18
  registerBuildTools(server, client);
19
+ registerContextTools(server, client);
18
20
  }
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+
3
+ /** The inputs several tools share, so a product or a page is named the same way everywhere. */
4
+
5
+ export const productId = z
6
+ .string()
7
+ .uuid()
8
+ .describe('Product ID -- taiga_get_product_context, taiga_resolve_product or taiga_list_products give it');
9
+
10
+ /** The page inputs every list tool takes; `nextCursor` of one page is the `cursor` of the next. */
11
+ export const pagination = {
12
+ limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
13
+ cursor: z
14
+ .string()
15
+ .max(256)
16
+ .optional()
17
+ .describe('The nextCursor of the previous page, to read the next one; omit for the first page'),
18
+ };
19
+
20
+ export const slug = z
21
+ .string()
22
+ .max(63)
23
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'A slug is lowercase words joined by hyphens')
24
+ .describe('Product slug, as it appears in Taiga URLs');
25
+
26
+ export const remoteUrl = z
27
+ .string()
28
+ .min(1)
29
+ .max(2048)
30
+ .describe(
31
+ 'The git remote URL of a checkout (git remote get-url origin): https://, git@host:owner/repo.git or ssh://'
32
+ );
@@ -53,8 +53,12 @@ export const SERVER_INSTRUCTIONS = [
53
53
  '`priority: core` marks the sections every organization keeps. Neither changes what the section',
54
54
  'requires of you.',
55
55
  '',
56
- 'Workflow: inside a git checkout, start with taiga_resolve_product on the remote URL to find the product.',
57
- 'Otherwise use taiga_list_factories and taiga_list_products. Then read the factory instructions,',
56
+ 'Workflow: start with taiga_get_product_context, naming the product by its git remote URL inside a checkout',
57
+ '(git remote get-url origin), else by slug or ID from taiga_list_products. It returns the product, the',
58
+ 'factory instructions, the specification and architecture, every policy as its section ids, the open backlog',
59
+ 'items, the repositories and the latest runs in one call, and names any part it could not read. Then read',
60
+ 'what you need in full: a policy with taiga_get_document, a backlog item with taiga_get_backlog_item.',
61
+ 'Without it, use taiga_list_factories and taiga_list_products, then read the factory instructions,',
58
62
  "the product's specification and architecture, and the backlog item you are working on. A document",
59
63
  'read that finds nothing tells you what does exist and which tool reads or writes it.',
60
64
  '',