@taiga-ai/mcp-server 0.25.0-rc.474 → 0.26.0-dev.479

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.
Files changed (55) hide show
  1. package/README.md +111 -0
  2. package/dist/index.js +292 -115
  3. package/package.json +15 -2
  4. package/scripts/build.mjs +0 -37
  5. package/scripts/prepare-publish.cjs +0 -49
  6. package/src/client.test.ts +0 -424
  7. package/src/client.ts +0 -977
  8. package/src/config.test.ts +0 -24
  9. package/src/config.ts +0 -28
  10. package/src/index.ts +0 -45
  11. package/src/modern.integration.test.ts +0 -153
  12. package/src/server.integration.test.ts +0 -9214
  13. package/src/server.ts +0 -18
  14. package/src/tools/annotations.ts +0 -38
  15. package/src/tools/autonomy.ts +0 -86
  16. package/src/tools/backlogs.ts +0 -802
  17. package/src/tools/builds.ts +0 -668
  18. package/src/tools/catalogue.test.ts +0 -170
  19. package/src/tools/catalogue.ts +0 -223
  20. package/src/tools/context.ts +0 -513
  21. package/src/tools/documents.ts +0 -375
  22. package/src/tools/errors.ts +0 -172
  23. package/src/tools/hints.ts +0 -99
  24. package/src/tools/inbox.ts +0 -546
  25. package/src/tools/index.ts +0 -239
  26. package/src/tools/inputs.ts +0 -47
  27. package/src/tools/instructions.ts +0 -41
  28. package/src/tools/jobs.ts +0 -90
  29. package/src/tools/knowledge.ts +0 -249
  30. package/src/tools/maintenance.ts +0 -547
  31. package/src/tools/markdown.test.ts +0 -249
  32. package/src/tools/markdown.ts +0 -151
  33. package/src/tools/observe.ts +0 -311
  34. package/src/tools/product-documents.ts +0 -184
  35. package/src/tools/products.ts +0 -529
  36. package/src/tools/prompts.ts +0 -265
  37. package/src/tools/queue.ts +0 -250
  38. package/src/tools/readiness.ts +0 -177
  39. package/src/tools/remote.test.ts +0 -44
  40. package/src/tools/remote.ts +0 -107
  41. package/src/tools/repositories.ts +0 -360
  42. package/src/tools/request-prompt.test.ts +0 -32
  43. package/src/tools/request-prompt.ts +0 -28
  44. package/src/tools/resources.ts +0 -217
  45. package/src/tools/sections.test.ts +0 -143
  46. package/src/tools/sections.ts +0 -287
  47. package/src/tools/setup.ts +0 -455
  48. package/src/tools/summarize.test.ts +0 -20
  49. package/src/tools/summarize.ts +0 -96
  50. package/src/tools/wait.ts +0 -490
  51. package/src/version.test.ts +0 -10
  52. package/src/version.ts +0 -14
  53. package/taiga-ai-mcp-server-0.25.0-rc.474.tgz +0 -0
  54. package/tsconfig.json +0 -21
  55. package/vitest.config.ts +0 -9
@@ -1,360 +0,0 @@
1
- import type { McpServer } from '@modelcontextprotocol/server';
2
- import { z } from 'zod';
3
- import type { GithubRepository } from '@taiga/types';
4
- import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
5
- import { OPERATE, OPERATE_SETTING, READ_ONLY } from './annotations.js';
6
- import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
7
- import { setupReadiness } from './readiness.js';
8
- import { idInput, pagination, productId } from './inputs.js';
9
- import { SEARCH_WITH_CURSOR, page, pickFields } from './summarize.js';
10
-
11
- /**
12
- * A product's repository (docs.tai.ga, Administration: Integrations and
13
- * environments, Source control). A linked repository is the prerequisite for
14
- * most of what Taiga does: planning reads the code, runs work on a branch of
15
- * it, sweeps scan it and the as-built documents are derived from it. GitHub is
16
- * connected once for the organization, in the web; linking one of the
17
- * repositories it can reach to a product is a separate act, per product.
18
- */
19
-
20
- const LINK_FIELDS = [
21
- 'id',
22
- 'provider',
23
- 'repositoryName',
24
- 'repositoryUrl',
25
- 'repositoryDefaultBranch',
26
- 'baseBranch',
27
- 'effectiveBranch',
28
- 'label',
29
- 'buildable',
30
- 'isManaged',
31
- ] as const;
32
-
33
- const INSTALLATION_FIELDS = [
34
- 'id',
35
- 'accountLogin',
36
- 'accountType',
37
- 'repositorySelection',
38
- 'suspendedAt',
39
- 'lastSyncError',
40
- ] as const;
41
-
42
- const GITHUB_REPOSITORY_FIELDS = [
43
- 'id',
44
- 'fullName',
45
- 'private',
46
- 'archived',
47
- 'defaultBranch',
48
- 'description',
49
- 'language',
50
- // A product already links it (PRO-2800); absent from an API that predates the field.
51
- 'linked',
52
- // Which product holds it, to a key that sees every product (PRO-2822).
53
- 'linkedProduct',
54
- ] as const;
55
-
56
- const repositoryId = idInput(
57
- 'repository link',
58
- 'taiga_list_product_repositories gives it; a linkable repository id is not one'
59
- );
60
-
61
- /** Round-robin over lists: the first of each, then the second of each, and so on. */
62
- export function interleave<T>(lists: T[][]): T[] {
63
- const out: T[] = [];
64
- const longest = Math.max(0, ...lists.map((list) => list.length));
65
- for (let index = 0; index < longest; index++) {
66
- for (const list of lists) if (index < list.length) out.push(list[index]!);
67
- }
68
- return out;
69
- }
70
-
71
- /**
72
- * A repository linked elsewhere names the product that holds it (PRO-2800): the API says "another product", and a
73
- * reviewer spent a call finding which. One read, the products the key can see; null leaves the API's refusal.
74
- */
75
- async function holderOf(client: TaigaClient, error: unknown, productId: string) {
76
- if (!(error instanceof TaigaApiError) || error.status !== 409) return null;
77
- // An API that names the holder (PRO-2807) says it itself; an older one, or a key that cannot see every product,
78
- // says "another product", and this lists the ones the key can see.
79
- if (error.details?.['holder']) return null;
80
- const name = /^(\S+\/\S+) is already linked to another product\./.exec(error.apiMessage ?? '')?.[1];
81
- if (!name) return null;
82
- try {
83
- // The refusal names the repository only, not its host, and the same name can live on github.com and on an
84
- // enterprise host: these are the products with a repository of that name, which is as exact as it can say.
85
- const holders = (await client.listProducts({ repository: name, limit: 20 })).data.filter((p) => p.id !== productId);
86
- const said =
87
- holders.length > 0
88
- ? `Products this key can see with a repository named ${name}: ${holders.map((p) => `${p.name} (${p.id})`).join(', ')}.`
89
- : 'It is linked to a product this key cannot see.';
90
- return toolError(
91
- 'CONFLICT',
92
- `${name} is already linked to another product. ${said} A repository belongs to one product; unlink it there ` +
93
- 'first (taiga_unlink_repository) if this product should have it.',
94
- { holders: holders.map((p) => ({ productId: p.id, name: p.name })) }
95
- );
96
- } catch (inner) {
97
- if (inner instanceof TaigaApiError || isNetworkFailure(inner)) return null;
98
- throw inner;
99
- }
100
- }
101
-
102
- /**
103
- * A product in setup says what it still needs after a repository write, as create_product and get_product do
104
- * (PRO-2800): a reviewer had to read the product again to learn that the import came next. Best effort: the write
105
- * is done, so a failed read leaves readiness out rather than failing it.
106
- */
107
- async function readinessAfter(client: TaigaClient, productId: string): Promise<{ readiness?: unknown }> {
108
- try {
109
- const readiness = await setupReadiness(
110
- client,
111
- (await client.getProduct(productId)).data as unknown as Record<string, unknown>
112
- );
113
- return readiness ? { readiness } : {};
114
- } catch (error) {
115
- if (error instanceof TaigaApiError || isNetworkFailure(error)) return {};
116
- throw error;
117
- }
118
- }
119
-
120
- export function registerRepositoryTools(server: McpServer, client: TaigaClient) {
121
- if (client.hasCapability('products:read')) {
122
- server.registerTool(
123
- 'taiga_list_product_repositories',
124
- {
125
- title: "List a product's repositories",
126
- description:
127
- 'List the repositories linked to a product, each with effectiveBranch (the chosen branch, or the ' +
128
- "repository's default) and buildable (whether Taiga can plan and build in it).",
129
- inputSchema: z.object({ productId }),
130
- annotations: READ_ONLY,
131
- },
132
- (params) =>
133
- runTool(async () => {
134
- // The route answers one page of 100 and takes no cursor: past that, the list says it is cut.
135
- const result = await client.listProductRepositories(params.productId);
136
- return toolResult({
137
- data: pickFields(result.data, LINK_FIELDS),
138
- truncated: result.nextCursor !== null,
139
- });
140
- })
141
- );
142
-
143
- server.registerTool(
144
- 'taiga_list_repository_branches',
145
- {
146
- title: "List a repository's branches",
147
- description:
148
- "List the branches of a product's linked repository, each with whether it is protected, to choose the " +
149
- 'one Taiga works on (taiga_set_repository_branch). truncated: true means there are more than listed.',
150
- inputSchema: z.object({ productId, repositoryId }),
151
- annotations: READ_ONLY,
152
- },
153
- (params) =>
154
- runTool(async () => {
155
- const result = await client.listRepositoryBranches(params.productId, params.repositoryId);
156
- return toolResult({ data: result.data, truncated: result.truncated });
157
- })
158
- );
159
- }
160
-
161
- if (client.hasCapability('vcs:read')) {
162
- server.registerTool(
163
- 'taiga_list_linkable_repositories',
164
- {
165
- title: 'Find a repository to link',
166
- description:
167
- "List the repositories the organization's GitHub connection can reach, to link one with " +
168
- "taiga_link_repository (it takes the row's id as linkableRepositoryId). query searches by name; account (login, owner, or " +
169
- 'installationId) reads one account, else rows come from every account in turn. linked: true means a ' +
170
- 'product already links it, and linkedProduct names it to a key that sees every product. No connection means GitHub is not ' +
171
- 'connected yet; someone who can install the app does that once, in the web.',
172
- inputSchema: z.object({
173
- query: z.string().trim().min(1).max(256).optional().describe('Part of the repository name'),
174
- account: z
175
- .string()
176
- .trim()
177
- .min(1)
178
- // owner/repo: a 39-character login, the slash and a 100-character name.
179
- .max(140)
180
- .optional()
181
- .describe('The GitHub account login (the owner in owner/repo), case-insensitive'),
182
- installationId: idInput('GitHub account', 'this tool lists them').optional(),
183
- ...pagination,
184
- }),
185
- annotations: READ_ONLY,
186
- },
187
- (params) =>
188
- runTool(async () => {
189
- const installations = (await client.listGithubInstallations()).data;
190
- const accounts = pickFields(installations, INSTALLATION_FIELDS);
191
- if (installations.length === 0) {
192
- return toolError(
193
- 'NOT_CONNECTED',
194
- 'GitHub is not connected for this organization. It is connected once, in the web; then link.'
195
- );
196
- }
197
- // owner/repo names the owner too: the part before the slash is the account.
198
- const login = params.account?.split('/')[0]!.toLowerCase();
199
- const chosen = installations.filter(
200
- (i) =>
201
- (!params.installationId || i.id === params.installationId) &&
202
- (!login || String(i.accountLogin ?? '').toLowerCase() === login)
203
- );
204
- if (chosen.length === 0) {
205
- return toolError('NOT_FOUND', 'No such GitHub connection; these are the connected accounts.', { accounts });
206
- }
207
- // A cursor belongs to one account's pages, and a search answers one
208
- // ranked page: either way a cursor would quietly start over.
209
- if (params.cursor !== undefined && params.query !== undefined) {
210
- return toolError('VALIDATION_ERROR', SEARCH_WITH_CURSOR);
211
- }
212
- if (chosen.length > 1 && params.cursor !== undefined) {
213
- return toolError('VALIDATION_ERROR', 'A cursor pages one account: name it (account or installationId).', {
214
- accounts,
215
- });
216
- }
217
- // `limit` is the most rows the answer carries, however many accounts
218
- // are read: a search answers its ranked matches whatever the page
219
- // size, and several accounts add up.
220
- const max = params.limit ?? 25;
221
- const rows = (data: WithoutTenant<GithubRepository>[]) => pickFields(data, GITHUB_REPOSITORY_FIELDS);
222
- if (chosen.length === 1) {
223
- const result = await client.listInstallationRepositories(String(chosen[0]!.id), {
224
- q: params.query,
225
- limit: max,
226
- cursor: params.cursor,
227
- });
228
- const found = rows(result.data);
229
- return toolResult({
230
- // The account asked for, not every connected one (PRO-2800).
231
- accounts: params.account || params.installationId ? pickFields(chosen, INSTALLATION_FIELDS) : accounts,
232
- repositories: page(found.slice(0, max), result.nextCursor),
233
- ...(found.length > max
234
- ? { more: 'More matched than the limit; narrow the query or raise the limit.' }
235
- : {}),
236
- });
237
- }
238
- // Several accounts are read once each, as the web's picker reads them;
239
- // a cursor belongs to one account, so the page has none.
240
- const pages = await Promise.all(
241
- chosen.map((i) => client.listInstallationRepositories(String(i.id), { q: params.query, limit: max }))
242
- );
243
- // In turn, one row from each account, so the first account's page
244
- // cannot fill the answer and hide the others.
245
- const perAccount = pages.map((p) => rows(p.data));
246
- const found = interleave(perAccount);
247
- const cut = found.length > max || pages.some((p) => p.nextCursor);
248
- return toolResult({
249
- accounts,
250
- repositories: page(found.slice(0, max), null),
251
- ...(cut ? { more: 'More than the limit across accounts: name an account, or narrow the query.' } : {}),
252
- });
253
- })
254
- );
255
- }
256
-
257
- if (!client.hasCapability('vcs:manage')) return;
258
-
259
- server.registerTool(
260
- 'taiga_link_repository',
261
- {
262
- title: 'Link a repository to a product',
263
- description:
264
- 'Link a GitHub repository to a product (taiga_list_linkable_repositories gives its id). ' +
265
- 'replacesRepositoryId swaps out the one linked now in one step (the same one changes nothing); linking ' +
266
- 'while one is linked without it is refused. A managed link (isManaged) cannot be replaced or unlinked. ' +
267
- 'Once linked, planning reads its code, runs open pull requests in it, sweeps scan it, and its deployment ' +
268
- 'history is imported.',
269
- inputSchema: z.object({
270
- productId,
271
- // Named for the list it comes from: it is Taiga's record of a repository the connection reaches, and three
272
- // reviews read githubRepositoryId as GitHub's numeric id (PRO-2800).
273
- linkableRepositoryId: idInput(
274
- 'linkable repository',
275
- "taiga_list_linkable_repositories gives it (a row's id; not GitHub's numeric id, not a link ID)"
276
- ),
277
- replacesRepositoryId: repositoryId.optional().describe('The link this one replaces'),
278
- }),
279
- annotations: OPERATE,
280
- },
281
- (params) =>
282
- runTool(async () => {
283
- let linked;
284
- try {
285
- linked = await client.linkRepository(params.productId, {
286
- githubRepositoryId: params.linkableRepositoryId,
287
- ...(params.replacesRepositoryId ? { replacesRepositoryId: params.replacesRepositoryId } : {}),
288
- });
289
- } catch (error) {
290
- const holder = await holderOf(client, error, params.productId);
291
- if (holder) return holder;
292
- throw error;
293
- }
294
- return toolResult({
295
- ...pickFields([linked.data], LINK_FIELDS)[0],
296
- ...(await readinessAfter(client, params.productId)),
297
- });
298
- })
299
- );
300
-
301
- server.registerTool(
302
- 'taiga_set_repository_branch',
303
- {
304
- title: 'Choose the branch Taiga works on',
305
- description:
306
- 'Choose the branch Taiga branches from and opens pull requests against (taiga_list_repository_branches ' +
307
- "lists them), or null for the repository's default. A protected branch is accepted, but Taiga may not be " +
308
- 'able to merge into it; the result says when it is protected. Optionally sets a label for the link.',
309
- inputSchema: z.object({
310
- productId,
311
- repositoryId,
312
- baseBranch: z
313
- .string()
314
- .trim()
315
- .min(1)
316
- .max(255)
317
- .nullable()
318
- .optional()
319
- .describe('The branch; null for the default'),
320
- label: z.string().trim().min(1).max(100).nullable().optional().describe('A label for the link; null clears it'),
321
- }),
322
- annotations: OPERATE_SETTING,
323
- },
324
- (params) =>
325
- runTool(async () => {
326
- if (params.baseBranch === undefined && params.label === undefined) {
327
- return toolError('VALIDATION_ERROR', 'Set baseBranch, label or both.');
328
- }
329
- const updated = (
330
- await client.updateRepository(params.productId, params.repositoryId, {
331
- ...(params.baseBranch !== undefined ? { baseBranch: params.baseBranch } : {}),
332
- ...(params.label !== undefined ? { label: params.label } : {}),
333
- })
334
- ).data;
335
- return toolResult({
336
- ...pickFields([updated], LINK_FIELDS)[0],
337
- baseBranchProtection: updated.baseBranchProtection ?? null,
338
- ...(await readinessAfter(client, params.productId)),
339
- });
340
- })
341
- );
342
-
343
- server.registerTool(
344
- 'taiga_unlink_repository',
345
- {
346
- title: 'Unlink a repository from a product',
347
- description:
348
- 'Unlink a repository from a product. The product keeps its documents and initiatives, but nothing is ' +
349
- 'planned against code, built or swept. The repository itself is untouched; one Taiga created for the ' +
350
- 'product cannot be unlinked. To swap it, use taiga_link_repository with replacesRepositoryId.',
351
- inputSchema: z.object({ productId, repositoryId }),
352
- annotations: OPERATE,
353
- },
354
- (params) =>
355
- runTool(async () => {
356
- await client.unlinkRepository(params.productId, params.repositoryId);
357
- return toolResult({ repositoryId: params.repositoryId, unlinked: true });
358
- })
359
- );
360
- }
@@ -1,32 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { requestPrompt, stripInlineImages } from './request-prompt.js';
3
-
4
- const IMAGE = '![shot](/v1/custom-documents/0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5c/image)';
5
-
6
- describe('requestPrompt', () => {
7
- it('joins the trimmed title and description with a blank line', () => {
8
- expect(requestPrompt(' Add login ', ' Users log in. ')).toBe('Add login\n\nUsers log in.');
9
- });
10
-
11
- it('is undefined when both are empty, which asks the agent for a suggestion', () => {
12
- expect(requestPrompt(' ', '\n\n')).toBeUndefined();
13
- expect(requestPrompt(undefined, undefined)).toBeUndefined();
14
- });
15
-
16
- it('drops inline images and collapses the blank lines they and the author left', () => {
17
- expect(requestPrompt('T', `Before\n\n${IMAGE}\n\n\n\nAfter`)).toBe('T\n\nBefore\n\nAfter');
18
- });
19
- });
20
-
21
- describe('stripInlineImages', () => {
22
- it('keeps an image that is not a Taiga inline image, and a link to one that is not an image', () => {
23
- const external = '![logo](https://example.com/logo.png)';
24
- const download = '[file](/v1/custom-documents/0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5c/image)';
25
- expect(stripInlineImages(`${external} ${download}`)).toBe(`${external} ${download}`);
26
- });
27
-
28
- it('does not take /imagefoo or /image/download for an inline image', () => {
29
- const near = '![x](/v1/custom-documents/0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5c/image/download)';
30
- expect(stripInlineImages(near)).toBe(near);
31
- });
32
- });
@@ -1,28 +0,0 @@
1
- /**
2
- * The planner's prompt for an initiative request, built as the web builds it
3
- * (apps/web/src/features/loops/delivery/components/planning/generateBacklogJob.ts):
4
- * title and description trimmed, the description's inline-image markdown
5
- * removed and the blank lines it leaves collapsed, joined by a blank line.
6
- * The planner reads `userPrompt` and nothing else of the request, so the same
7
- * words must make the same prompt from either surface.
8
- *
9
- * The image pattern is the web's (inline-image-doc-ids.ts), which keeps it in
10
- * lockstep with the API's `extractInlineImageDocIds`: the `![...](...)` form,
11
- * an exact UUID, and a terminator after `/image`.
12
- */
13
- const INLINE_IMAGE_RE =
14
- /!\[(?:[^\]\\]|\\.)*\]\([^)]*?\/custom-documents\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\/image(?=[)?#\s])[^)]*\)/g;
15
-
16
- /** The description without its inline images, as the web strips it. */
17
- export function stripInlineImages(markdown: string): string {
18
- return markdown
19
- .replace(INLINE_IMAGE_RE, '')
20
- .replace(/\n{3,}/g, '\n\n')
21
- .trim();
22
- }
23
-
24
- /** The prompt for a title and a description; undefined when both are empty, which asks for a suggestion. */
25
- export function requestPrompt(title: string | undefined, description: string | undefined): string | undefined {
26
- const body = stripInlineImages((description ?? '').trim());
27
- return [(title ?? '').trim(), body].filter(Boolean).join('\n\n') || undefined;
28
- }
@@ -1,217 +0,0 @@
1
- import {
2
- type McpServer,
3
- ProtocolError,
4
- ProtocolErrorCode,
5
- type ReadResourceResult,
6
- ResourceTemplate,
7
- } from '@modelcontextprotocol/server';
8
- import { TaigaApiError, type TaigaClient } from '../client.js';
9
- import { apiFailureText, isMissOrRefusal } from './errors.js';
10
- import {
11
- FACTORY_DOCUMENT_TYPES,
12
- PRODUCT_DOCUMENT_ORIGINS,
13
- PRODUCT_DOCUMENT_TYPES,
14
- TENANT_DOCUMENT_TYPES,
15
- defaultOriginFor,
16
- productDocumentName,
17
- type ProductDocumentOrigin,
18
- type ProductDocumentType,
19
- } from './catalogue.js';
20
- import { productId as productIdInput } from './inputs.js';
21
- import { mimeTypeOf, renderDocument } from './markdown.js';
22
- import { stripProvenance } from './sections.js';
23
-
24
- /**
25
- * The knowledge base as resources, so an IDE can attach a document with `@`
26
- * the way it attaches a file. Every resource reads through the same routes
27
- * and capability gates as the tool that reads it, and returns the same
28
- * stripped content, rendered as Markdown where the document is a section
29
- * map. `resources/list` names what is published; the templates say how to
30
- * address the rest.
31
- *
32
- * A resource has no `isError` result: a refusal or a miss is a JSON-RPC
33
- * error, InvalidParams (the caller named something that does not exist)
34
- * with a message that says what does.
35
- */
36
-
37
- /** One text resource, rendered. */
38
- function contents(uri: string, doc: Record<string, unknown>): ReadResourceResult {
39
- const rendered = renderDocument(stripProvenance(doc));
40
- return { contents: [{ uri, mimeType: rendered.mimeType, text: rendered.text }] };
41
- }
42
-
43
- function invalid(message: string): never {
44
- throw new ProtocolError(ProtocolErrorCode.InvalidParams, message);
45
- }
46
-
47
- function requireOneOf<T extends string>(what: string, value: string, allowed: readonly T[]): T {
48
- if (!(allowed as readonly string[]).includes(value))
49
- invalid(`${what} "${value}" is not one of: ${allowed.join(', ')}`);
50
- return value as T;
51
- }
52
-
53
- function requireId(what: string, value: string): string {
54
- if (!productIdInput.safeParse(value).success) invalid(`${what} "${value}" is not an ID`);
55
- return value;
56
- }
57
-
58
- /** The read, with a miss or a refusal turned into guidance; anything else is the API failing and propagates. */
59
- async function served<T>(read: Promise<T>, miss: string): Promise<T> {
60
- try {
61
- return await read;
62
- } catch (error) {
63
- if (!isMissOrRefusal(error)) throw error;
64
- return invalid(`${miss} (${apiFailureText(error)})`);
65
- }
66
- }
67
-
68
- export function registerResources(server: McpServer, client: TaigaClient) {
69
- if (client.hasCapability('tenant:documents:read')) {
70
- const published = async () =>
71
- (await client.listTenantDocuments({ limit: 100 })).data.filter((row) => row.isDraft === false);
72
-
73
- server.registerResource(
74
- 'organization-document',
75
- new ResourceTemplate('taiga://documents/{type}', {
76
- // What is published, so the client can offer it without guessing.
77
- // A tenant list the API would not serve must not empty every other
78
- // resource from the client's picker: this one lists nothing then.
79
- list: async () => ({
80
- resources: (await published().catch(() => [])).map((row) => ({
81
- uri: `taiga://documents/${String(row.type)}`,
82
- name: String(row.name ?? row.type),
83
- description: typeof row.description === 'string' ? row.description : undefined,
84
- mimeType: mimeTypeOf(row),
85
- })),
86
- }),
87
- }),
88
- {
89
- title: 'Organization document',
90
- description:
91
- 'A published organization document: a policy, the business goals, the design or brand standards, the ' +
92
- 'tone of voice or the standing instructions. Sections rendered as headings.',
93
- },
94
- async (uri, variables) => {
95
- const type = requireOneOf('Document type', String(variables['type']), TENANT_DOCUMENT_TYPES);
96
- try {
97
- return contents(uri.href, (await client.getTenantDocument(type)).data);
98
- } catch (error) {
99
- if (!isMissOrRefusal(error)) throw error;
100
- // The follow-up list is best effort, but a failed one is said to
101
- // have failed, not read as "nothing is published".
102
- const rest = await published().then(
103
- (rows) => {
104
- const names = rows.map((row) => String(row.type));
105
- return names.length > 0 ? `Published documents: ${names.join(', ')}.` : 'Nothing is published yet.';
106
- },
107
- (reason: unknown) =>
108
- `The published list could not be read (${reason instanceof TaigaApiError ? apiFailureText(reason) : 'the request failed'}).`
109
- );
110
- return invalid(`No published ${type} the key can read (${apiFailureText(error)}). ${rest}`);
111
- }
112
- }
113
- );
114
- }
115
-
116
- if (client.hasCapability('factories:read')) {
117
- server.registerResource(
118
- 'factory-instructions',
119
- new ResourceTemplate('taiga://factories/{factoryId}/instructions', { list: undefined }),
120
- {
121
- title: 'Factory instructions',
122
- description:
123
- "A factory's published standing guidance; taiga_list_factories gives the factory ID. Markdown for a " +
124
- 'section map, JSON otherwise (the read says which).',
125
- },
126
- async (uri, variables) => {
127
- const factoryId = requireId('Factory ID', String(variables['factoryId']));
128
- const doc = await served(
129
- client.getFactoryDocument(factoryId, FACTORY_DOCUMENT_TYPES[0]),
130
- 'This factory has not published instructions, or the key cannot see the factory'
131
- );
132
- return contents(uri.href, doc.data);
133
- }
134
- );
135
- }
136
-
137
- if (client.hasCapability('products:read')) {
138
- const documentType = (value: string): ProductDocumentType => {
139
- if (value.includes('?')) invalid(`Name the timeline as ?origin=plan or ?origin=reality, not "${value}"`);
140
- return requireOneOf('Document type', value, PRODUCT_DOCUMENT_TYPES);
141
- };
142
- const read = async (uri: URL, productId: string, type: ProductDocumentType, origin: ProductDocumentOrigin) => {
143
- const result = await served(
144
- client.getProductDocument(productId, type, origin),
145
- `No published ${origin} ${type} for this product, or the key cannot see it; taiga_get_product_document without type shows what exists`
146
- );
147
- return contents(uri.href, { name: productDocumentName(type), ...result.data });
148
- };
149
-
150
- // The timeline template first: the SDK tries templates in registration
151
- // order, and a URI with ?origin= would otherwise bind the query into {type}.
152
- server.registerResource(
153
- 'product-document-on-timeline',
154
- new ResourceTemplate('taiga://products/{productId}/documents/{type}{?origin}', { list: undefined }),
155
- {
156
- title: 'Product document on a timeline',
157
- description:
158
- 'A product document from the named timeline: origin=plan (discovery) or origin=reality (derived from the code). ' +
159
- 'Markdown for a section map, JSON otherwise (the read says which).',
160
- },
161
- async (uri, variables) => {
162
- const productId = requireId('Product ID', String(variables['productId']));
163
- const type = documentType(String(variables['type']));
164
- const origin = requireOneOf('Origin', String(variables['origin']), PRODUCT_DOCUMENT_ORIGINS);
165
- return read(uri, productId, type, origin);
166
- }
167
- );
168
-
169
- server.registerResource(
170
- 'product-document',
171
- new ResourceTemplate('taiga://products/{productId}/documents/{type}', { list: undefined }),
172
- {
173
- title: 'Product document',
174
- description:
175
- "A product document from the product's own timeline (reality for an imported product, plan otherwise; " +
176
- 'the documents a person writes are always on the plan side). taiga_get_product_document without type shows what ' +
177
- 'exists; add ?origin=plan or ?origin=reality to read the other side. Markdown for a section map, JSON ' +
178
- 'otherwise (the read says which).',
179
- },
180
- async (uri, variables) => {
181
- const productId = requireId('Product ID', String(variables['productId']));
182
- const type = documentType(String(variables['type']));
183
- const product = (
184
- await served(
185
- client.getProduct(productId),
186
- 'No product with that ID the key can see; taiga_list_products shows them'
187
- )
188
- ).data;
189
- return read(uri, productId, type, defaultOriginFor(product, type));
190
- }
191
- );
192
- }
193
-
194
- if (client.hasCapability('backlog:read')) {
195
- server.registerResource(
196
- 'backlog-item',
197
- new ResourceTemplate('taiga://initiatives/{initiativeId}', { list: undefined }),
198
- {
199
- title: 'Initiative',
200
- description:
201
- 'An initiative in full: description, acceptance criteria, technical context. taiga_list_initiatives ' +
202
- 'gives the item ID.',
203
- mimeType: 'application/json',
204
- },
205
- async (uri, variables) => {
206
- const initiativeId = requireId('Initiative ID', String(variables['initiativeId']));
207
- const item = (
208
- await served(
209
- client.getBacklogItem(initiativeId),
210
- 'No initiative with that ID the key can see; taiga_list_initiatives shows what exists'
211
- )
212
- ).data;
213
- return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(item, null, 2) }] };
214
- }
215
- );
216
- }
217
- }