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

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.
@@ -2114,6 +2114,449 @@ describe('MCP server integration', () => {
2114
2114
  });
2115
2115
  });
2116
2116
 
2117
+ // -------------------------------------------------------------------------
2118
+ // Resources and prompts (PRO-2576): the knowledge base attachable with @,
2119
+ // and a session's opening message.
2120
+ // -------------------------------------------------------------------------
2121
+
2122
+ describe('resources', () => {
2123
+ const textOf = (contents: unknown[]) => (contents[0] as { text: string }).text;
2124
+ const product = { id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_ID, settings: {} };
2125
+ const imported = { ...product, settings: { projectType: 'brownfield' } };
2126
+ const architecture = {
2127
+ id: 'doc-arch',
2128
+ type: 'architecture',
2129
+ version: 2,
2130
+ origin: 'reality',
2131
+ derivedFrom: { sources: [] },
2132
+ data: { sections: { overview: { content: 'As built', derivedFrom: ['x'] } } },
2133
+ };
2134
+
2135
+ it('should list the published organization documents and the templates for the rest', async () => {
2136
+ const { client } = await bootMcpPair({
2137
+ capabilities: ALL_CAPABILITIES,
2138
+ routes: {
2139
+ 'GET /v1/tenants/current/documents': {
2140
+ data: [policyDocument(), policyDocument({ type: 'tone_of_voice', isDraft: true, version: null })],
2141
+ nextCursor: null,
2142
+ },
2143
+ },
2144
+ });
2145
+
2146
+ const { resources } = await client.listResources();
2147
+ const { resourceTemplates } = await client.listResourceTemplates();
2148
+
2149
+ expect(resources).toHaveLength(1);
2150
+ expect(resources[0]).toMatchObject({
2151
+ uri: 'taiga://documents/policy_security',
2152
+ name: 'Security Policy',
2153
+ mimeType: 'text/markdown',
2154
+ });
2155
+ expect(resourceTemplates.map((t) => t.uriTemplate).sort()).toEqual([
2156
+ 'taiga://backlog/{itemId}',
2157
+ 'taiga://documents/{type}',
2158
+ 'taiga://factories/{factoryId}/instructions',
2159
+ 'taiga://products/{productId}/documents/{type}',
2160
+ 'taiga://products/{productId}/documents/{type}{?origin}',
2161
+ ]);
2162
+ });
2163
+
2164
+ it('should expose only the templates the key can read', async () => {
2165
+ const { client } = await bootMcpPair({ capabilities: ['backlog:read'] });
2166
+
2167
+ const { resourceTemplates } = await client.listResourceTemplates();
2168
+
2169
+ expect(resourceTemplates.map((t) => t.uriTemplate)).toEqual(['taiga://backlog/{itemId}']);
2170
+ });
2171
+
2172
+ it('should read a policy as Markdown with its sections as headings and no provenance', async () => {
2173
+ const { client } = await bootMcpPair({
2174
+ capabilities: ['tenant:documents:read'],
2175
+ routes: { 'GET /v1/tenants/current/documents/policy_security': { data: policyDocument() } },
2176
+ });
2177
+
2178
+ const { contents } = await client.readResource({ uri: 'taiga://documents/policy_security' });
2179
+
2180
+ expect(contents).toHaveLength(1);
2181
+ expect(contents[0]).toMatchObject({ uri: 'taiga://documents/policy_security', mimeType: 'text/markdown' });
2182
+ const text = textOf(contents);
2183
+ expect(text).toContain('# Security Policy');
2184
+ expect(text).toContain('## summary');
2185
+ expect(text).toContain('Old summary');
2186
+ expect(text).not.toContain('derivedFrom');
2187
+ });
2188
+
2189
+ it("should read a product document from the product's own timeline, or the named one", async () => {
2190
+ const { client, fetchSpy } = await bootMcpPair({
2191
+ capabilities: ['products:read'],
2192
+ routes: {
2193
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: imported },
2194
+ [`GET /v1/products/${PRODUCT_ID}/documents/architecture?origin=reality`]: { data: architecture },
2195
+ [`GET /v1/products/${PRODUCT_ID}/documents/architecture?origin=plan`]: {
2196
+ data: { ...architecture, origin: 'plan' },
2197
+ },
2198
+ },
2199
+ });
2200
+
2201
+ const own = await client.readResource({ uri: `taiga://products/${PRODUCT_ID}/documents/architecture` });
2202
+ const named = await client.readResource({
2203
+ uri: `taiga://products/${PRODUCT_ID}/documents/architecture?origin=plan`,
2204
+ });
2205
+
2206
+ expect(textOf(own.contents)).toContain('origin: reality');
2207
+ expect(textOf(own.contents)).toContain('# Architecture');
2208
+ expect(textOf(named.contents)).toContain('origin: plan');
2209
+ const origins = fetchSpy.mock.calls
2210
+ .map((c: unknown[]) => new URL(c[0] as string))
2211
+ .filter((u: URL) => u.pathname.endsWith('/documents/architecture'))
2212
+ .map((u: URL) => u.searchParams.get('origin'));
2213
+ expect(origins).toEqual(['reality', 'plan']);
2214
+ });
2215
+
2216
+ it('should read factory instructions as Markdown and a backlog item as JSON', async () => {
2217
+ const item = { id: ITEM_ID, title: 'Add login', acceptanceCriteria: [{ criterion: 'x', met: false }] };
2218
+ const { client } = await bootMcpPair({
2219
+ capabilities: ['factories:read', 'backlog:read'],
2220
+ routes: {
2221
+ [`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions`]: {
2222
+ data: {
2223
+ type: 'factory_instructions',
2224
+ name: 'Factory Instructions',
2225
+ version: 1,
2226
+ data: { sections: { instructions: { content: 'Use pnpm.' } } },
2227
+ },
2228
+ },
2229
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: item },
2230
+ },
2231
+ });
2232
+
2233
+ const instructions = await client.readResource({ uri: `taiga://factories/${FACTORY_ID}/instructions` });
2234
+ const backlog = await client.readResource({ uri: `taiga://backlog/${ITEM_ID}` });
2235
+
2236
+ expect(textOf(instructions.contents)).toContain('## instructions\n\nUse pnpm.');
2237
+ expect(backlog.contents[0]!.mimeType).toBe('application/json');
2238
+ expect(JSON.parse(textOf(backlog.contents))).toEqual(item);
2239
+ });
2240
+
2241
+ it('should refuse a type outside the catalogue, an id that is not one, and a URI nothing serves', async () => {
2242
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
2243
+ const before = fetchSpy.mock.calls.length;
2244
+
2245
+ await expect(client.readResource({ uri: 'taiga://documents/policy_ip' })).rejects.toThrow(/not one of/);
2246
+ await expect(client.readResource({ uri: 'taiga://backlog/not-an-id' })).rejects.toThrow(/not an ID/);
2247
+ await expect(client.readResource({ uri: 'taiga://nothing/here' })).rejects.toThrow(/not found/);
2248
+ await expect(
2249
+ client.readResource({ uri: `taiga://products/${PRODUCT_ID}/documents/architecture?origin=` })
2250
+ ).rejects.toThrow(/\?origin=plan or \?origin=reality/);
2251
+ expect(fetchSpy.mock.calls.length).toBe(before);
2252
+ });
2253
+
2254
+ it('should say what is published when a document is not, as an invalid-params error', async () => {
2255
+ const { client } = await bootMcpPair({
2256
+ capabilities: ['tenant:documents:read'],
2257
+ routes: { 'GET /v1/tenants/current/documents': { data: [policyDocument()], nextCursor: null } },
2258
+ });
2259
+
2260
+ const failure = await client.readResource({ uri: 'taiga://documents/tone_of_voice' }).catch((e: unknown) => e);
2261
+
2262
+ expect(failure).toMatchObject({ code: -32602 });
2263
+ expect(String((failure as Error).message)).toBe(
2264
+ 'MCP error -32602: No published tone_of_voice the key can read (404 NOT_FOUND: The requested resource does not exist). ' +
2265
+ 'Published documents: policy_security.'
2266
+ );
2267
+ });
2268
+
2269
+ it('should turn a refusal of the product into the same guidance, on either template', async () => {
2270
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
2271
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
2272
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
2273
+ if (!new URL(url).pathname.includes('/products/')) return router(url, init);
2274
+ const body = { error: 'Forbidden', code: 'FORBIDDEN', message: 'Not a member of this product' };
2275
+ return { ok: false, status: 403, json: async () => body, text: async () => JSON.stringify(body) };
2276
+ });
2277
+
2278
+ const own = await client
2279
+ .readResource({ uri: `taiga://products/${PRODUCT_ID}/documents/architecture` })
2280
+ .catch((e: unknown) => e);
2281
+ const named = await client
2282
+ .readResource({ uri: `taiga://products/${PRODUCT_ID}/documents/architecture?origin=plan` })
2283
+ .catch((e: unknown) => e);
2284
+
2285
+ expect(own).toMatchObject({ code: -32602 });
2286
+ expect(String((own as Error).message)).toContain('No product with that ID the key can see');
2287
+ expect(String((own as Error).message)).toContain('403 FORBIDDEN: Not a member of this product');
2288
+ expect(named).toMatchObject({ code: -32602 });
2289
+ expect(String((named as Error).message)).toContain(
2290
+ 'taiga_list_product_documents shows what exists (403 FORBIDDEN'
2291
+ );
2292
+ });
2293
+
2294
+ it('should say the published list failed rather than that nothing is published', async () => {
2295
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ['tenant:documents:read'] });
2296
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
2297
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
2298
+ if (new URL(url).pathname !== '/v1/tenants/current/documents') return router(url, init);
2299
+ const body = { error: 'Too Many Requests', code: 'RATE_LIMITED', message: 'Slow down' };
2300
+ return { ok: false, status: 429, json: async () => body, text: async () => JSON.stringify(body) };
2301
+ });
2302
+
2303
+ const failure = await client.readResource({ uri: 'taiga://documents/tone_of_voice' }).catch((e: unknown) => e);
2304
+
2305
+ expect(String((failure as Error).message)).toContain(
2306
+ 'The published list could not be read (429 RATE_LIMITED: Slow down).'
2307
+ );
2308
+ expect(String((failure as Error).message)).not.toContain('Nothing is published');
2309
+ });
2310
+
2311
+ it('should list the mime type a read will answer with', async () => {
2312
+ const { client } = await bootMcpPair({
2313
+ capabilities: ['tenant:documents:read'],
2314
+ routes: {
2315
+ 'GET /v1/tenants/current/documents': {
2316
+ data: [
2317
+ policyDocument(),
2318
+ policyDocument({ type: 'design_standards', name: 'Design', data: { tokens: {} } }),
2319
+ ],
2320
+ nextCursor: null,
2321
+ },
2322
+ },
2323
+ });
2324
+
2325
+ const { resources } = await client.listResources();
2326
+
2327
+ expect(resources.map((r) => r.mimeType)).toEqual(['text/markdown', 'application/json']);
2328
+ });
2329
+ });
2330
+
2331
+ describe('prompts', () => {
2332
+ const product = { id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_ID, settings: {} };
2333
+ const item = {
2334
+ id: ITEM_ID,
2335
+ productId: PRODUCT_ID,
2336
+ title: 'Add login',
2337
+ acceptanceCriteria: [{ criterion: 'Users can log in', met: false }],
2338
+ };
2339
+ const routes = {
2340
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: product },
2341
+ [`GET /v1/factories/${FACTORY_ID}`]: { data: { id: FACTORY_ID, name: 'Platform', slug: 'platform' } },
2342
+ [`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions`]: {
2343
+ data: {
2344
+ type: 'factory_instructions',
2345
+ version: 1,
2346
+ data: { sections: { instructions: { content: 'Use pnpm.' } } },
2347
+ },
2348
+ },
2349
+ [`GET /v1/products/${PRODUCT_ID}/documents/architecture?origin=plan`]: {
2350
+ data: {
2351
+ type: 'architecture',
2352
+ version: 1,
2353
+ origin: 'plan',
2354
+ data: { sections: { overview: { content: 'Hexagonal.' } } },
2355
+ },
2356
+ },
2357
+ 'GET /v1/tenants/current/documents': { data: [policyDocument()], nextCursor: null },
2358
+ 'GET /v1/tenants/current/documents/policy_security': { data: policyDocument() },
2359
+ [`GET /v1/backlog/${ITEM_ID}`]: { data: item },
2360
+ [`GET /v1/products/${PRODUCT_ID}/backlog?status=backlog&limit=100`]: { data: [], nextCursor: null },
2361
+ [`GET /v1/products/${PRODUCT_ID}/backlog?status=in_progress&limit=100`]: { data: [item], nextCursor: null },
2362
+ [`GET /v1/products/${PRODUCT_ID}/repositories`]: { data: [], nextCursor: null },
2363
+ [`GET /v1/products/${PRODUCT_ID}/builds?limit=10`]: { data: [], nextCursor: null },
2364
+ 'GET /v1/products?slug=web&limit=1': { data: [product], nextCursor: null },
2365
+ };
2366
+
2367
+ it('should list the three prompts for a full key and only what the capabilities allow otherwise', async () => {
2368
+ const full = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
2369
+ const docsOnly = await bootMcpPair({ capabilities: ['tenant:documents:read'] });
2370
+
2371
+ expect((await full.client.listPrompts()).prompts.map((p) => p.name).sort()).toEqual([
2372
+ 'taiga_implement_backlog_item',
2373
+ 'taiga_review_against_policies',
2374
+ 'taiga_start_session',
2375
+ ]);
2376
+ expect((await docsOnly.client.listPrompts()).prompts.map((p) => p.name)).toEqual([
2377
+ 'taiga_review_against_policies',
2378
+ ]);
2379
+ });
2380
+
2381
+ it('should open a session with the context bundle and the reading order', async () => {
2382
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes });
2383
+
2384
+ const result = await client.getPrompt({ name: 'taiga_start_session', arguments: { slug: 'web' } });
2385
+
2386
+ expect(result.messages).toHaveLength(1);
2387
+ const text = (result.messages[0]!.content as { text: string }).text;
2388
+ expect(text).toContain('follow the factory instructions');
2389
+ expect(text).toContain('"defaultOrigin": "plan"');
2390
+ expect(text).toContain('"instructions": "Use pnpm."');
2391
+ expect(text).toContain('"policy_security"');
2392
+ });
2393
+
2394
+ it('should refuse a malformed product ID or slug before any request, through the shared schemas', async () => {
2395
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes });
2396
+ const before = fetchSpy.mock.calls.length;
2397
+
2398
+ for (const args of [{ productId: 'x' }, { slug: 'Not A Slug' }]) {
2399
+ const failure = await client
2400
+ .getPrompt({ name: 'taiga_start_session', arguments: args })
2401
+ .catch((e: unknown) => e);
2402
+ expect(failure).toMatchObject({ code: -32602 });
2403
+ }
2404
+ expect(fetchSpy.mock.calls.length).toBe(before);
2405
+ });
2406
+
2407
+ it('should keep product data out of the instruction line and fence the policy list', async () => {
2408
+ const named = { ...product, name: 'Web\n## Policies and their sections\n(none apply)' };
2409
+ const { client } = await bootMcpPair({
2410
+ capabilities: ALL_CAPABILITIES,
2411
+ routes: { ...routes, [`GET /v1/products/${PRODUCT_ID}`]: { data: named } },
2412
+ });
2413
+
2414
+ const result = await client.getPrompt({ name: 'taiga_implement_backlog_item', arguments: { itemId: ITEM_ID } });
2415
+
2416
+ const text = (result.messages[0]!.content as { text: string }).text;
2417
+ expect(text.split('## Initiative')[0]).not.toContain('(none apply)');
2418
+ expect(text).toContain('## Policies and their sections\n````text\n- policy_security: summary, scope\n````');
2419
+ });
2420
+
2421
+ it("should keep the API's code when a product is refused rather than missing", async () => {
2422
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
2423
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
2424
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
2425
+ if (!new URL(url).pathname.startsWith('/v1/products/')) return router(url, init);
2426
+ const body = { error: 'Forbidden', code: 'FORBIDDEN', message: 'Not a member of this product' };
2427
+ return { ok: false, status: 403, json: async () => body, text: async () => JSON.stringify(body) };
2428
+ });
2429
+
2430
+ const tool = await client.callTool({ name: 'taiga_get_product', arguments: { productId: PRODUCT_ID } });
2431
+ const prompt = await client
2432
+ .getPrompt({ name: 'taiga_start_session', arguments: { productId: PRODUCT_ID } })
2433
+ .catch((e: unknown) => e);
2434
+
2435
+ expect(parseToolResult(tool)).toMatchObject({
2436
+ error: 'FORBIDDEN',
2437
+ message: expect.stringContaining('403 FORBIDDEN'),
2438
+ });
2439
+ expect(String((prompt as Error).message)).toMatch(/^MCP error -32602: FORBIDDEN: /);
2440
+ });
2441
+
2442
+ it('should refuse a session on a product the key cannot see as invalid params', async () => {
2443
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
2444
+
2445
+ const failure = await client
2446
+ .getPrompt({ name: 'taiga_start_session', arguments: { productId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a99' } })
2447
+ .catch((e: unknown) => e);
2448
+
2449
+ expect(failure).toMatchObject({ code: -32602 });
2450
+ expect(String((failure as Error).message)).toContain('taiga_list_products shows the ones it can');
2451
+ });
2452
+
2453
+ it('should refuse a session with no product named', async () => {
2454
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes });
2455
+
2456
+ const failure = await client.getPrompt({ name: 'taiga_start_session', arguments: {} }).catch((e: unknown) => e);
2457
+
2458
+ expect(failure).toMatchObject({ code: -32602 });
2459
+ expect(String((failure as Error).message)).toMatch(
2460
+ /^MCP error -32602: VALIDATION_FAILED: Name the product exactly once/
2461
+ );
2462
+ });
2463
+
2464
+ it('should refuse an item that is not an ID, or that the key cannot see, as invalid params', async () => {
2465
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes });
2466
+ const before = fetchSpy.mock.calls.length;
2467
+
2468
+ const notAnId = await client
2469
+ .getPrompt({ name: 'taiga_implement_backlog_item', arguments: { itemId: 'PRO-123' } })
2470
+ .catch((e: unknown) => e);
2471
+ expect(notAnId).toMatchObject({ code: -32602 });
2472
+ expect(fetchSpy.mock.calls.length).toBe(before);
2473
+
2474
+ const unseen = await client
2475
+ .getPrompt({
2476
+ name: 'taiga_implement_backlog_item',
2477
+ arguments: { itemId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a99' },
2478
+ })
2479
+ .catch((e: unknown) => e);
2480
+ expect(unseen).toMatchObject({ code: -32602 });
2481
+ expect(String((unseen as Error).message)).toContain('taiga_list_backlog_items shows what exists');
2482
+ });
2483
+
2484
+ it('should assemble an initiative with its criteria, the architecture, the instructions and the policy sections', async () => {
2485
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes });
2486
+
2487
+ const result = await client.getPrompt({ name: 'taiga_implement_backlog_item', arguments: { itemId: ITEM_ID } });
2488
+
2489
+ const text = (result.messages[0]!.content as { text: string }).text;
2490
+ expect(text).toContain('Users can log in');
2491
+ expect(text).toContain('## Factory instructions\n````markdown\nUse pnpm.\n````');
2492
+ expect(text).toContain('## overview\n\nHexagonal.');
2493
+ expect(text).toContain('````text\n- policy_security: summary, scope\n````');
2494
+ });
2495
+
2496
+ it('should say which parts a narrower key could not read rather than drop them', async () => {
2497
+ const { client } = await bootMcpPair({ capabilities: ['products:read', 'backlog:read'], routes });
2498
+
2499
+ const result = await client.getPrompt({ name: 'taiga_implement_backlog_item', arguments: { itemId: ITEM_ID } });
2500
+
2501
+ const text = (result.messages[0]!.content as { text: string }).text;
2502
+ expect(text).toContain('(The factory instructions need factories:read, which this key lacks.)');
2503
+ expect(text).toContain('(The policies need tenant:documents:read, which this key lacks.)');
2504
+ });
2505
+
2506
+ it('should name a part the API refused rather than fail the whole prompt', async () => {
2507
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes });
2508
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
2509
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
2510
+ if (!new URL(url).pathname.endsWith('/documents/factory_instructions')) return router(url, init);
2511
+ const body = { error: 'Forbidden', code: 'FORBIDDEN', message: 'Not a member of this factory' };
2512
+ return { ok: false, status: 403, json: async () => body, text: async () => JSON.stringify(body) };
2513
+ });
2514
+
2515
+ const result = await client.getPrompt({ name: 'taiga_implement_backlog_item', arguments: { itemId: ITEM_ID } });
2516
+
2517
+ const text = (result.messages[0]!.content as { text: string }).text;
2518
+ expect(text).toContain(
2519
+ '(The factory instructions could not be read: 403 FORBIDDEN: Not a member of this factory.)'
2520
+ );
2521
+ expect(text).toContain('Hexagonal.');
2522
+ });
2523
+
2524
+ it('should hand over one policy or the code-review three for a review', async () => {
2525
+ const { client, fetchSpy } = await bootMcpPair({
2526
+ capabilities: ['tenant:documents:read'],
2527
+ routes: {
2528
+ ...routes,
2529
+ 'GET /v1/tenants/current/documents/policy_sdlc': {
2530
+ data: policyDocument({ type: 'policy_sdlc', name: 'SDLC' }),
2531
+ },
2532
+ },
2533
+ });
2534
+
2535
+ const one = await client.getPrompt({
2536
+ name: 'taiga_review_against_policies',
2537
+ arguments: { policyType: 'policy_security' },
2538
+ });
2539
+ const three = await client.getPrompt({ name: 'taiga_review_against_policies', arguments: {} });
2540
+
2541
+ expect((one.messages[0]!.content as { text: string }).text).toContain('# Security Policy');
2542
+ const text = (three.messages[0]!.content as { text: string }).text;
2543
+ expect(text).toContain('# SDLC');
2544
+ expect(text).toContain('(policy_architecture is not published.)');
2545
+ const read = fetchSpy.mock.calls
2546
+ .map((c: unknown[]) => new URL(c[0] as string).pathname)
2547
+ .filter((p: string) => p.startsWith('/v1/tenants/current/documents/'));
2548
+ expect(read).toEqual([
2549
+ '/v1/tenants/current/documents/policy_security',
2550
+ '/v1/tenants/current/documents/policy_security',
2551
+ '/v1/tenants/current/documents/policy_sdlc',
2552
+ '/v1/tenants/current/documents/policy_architecture',
2553
+ ]);
2554
+ await expect(
2555
+ client.getPrompt({ name: 'taiga_review_against_policies', arguments: { policyType: 'policy_ip' } })
2556
+ ).rejects.toThrow(/not one of/);
2557
+ });
2558
+ });
2559
+
2117
2560
  describe('error handling', () => {
2118
2561
  it('should surface API errors as structured MCP error content', async () => {
2119
2562
  // Introspect succeeds, but all other endpoints fail
@@ -30,7 +30,7 @@ const ALL_CAPABILITIES = [
30
30
  'backlog:read',
31
31
  ];
32
32
 
33
- async function allTools() {
33
+ async function connected() {
34
34
  vi.stubGlobal(
35
35
  'fetch',
36
36
  vi.fn().mockResolvedValue({
@@ -49,7 +49,22 @@ async function allTools() {
49
49
  const client = new Client({ name: 'c', version: '0' });
50
50
  const [ct, st] = InMemoryTransport.createLinkedPair();
51
51
  await Promise.all([server.connect(st), client.connect(ct)]);
52
- return (await client.listTools()).tools;
52
+ return client;
53
+ }
54
+
55
+ async function allTools() {
56
+ return (await (await connected()).listTools()).tools;
57
+ }
58
+
59
+ /** Every description a client sees: tools, resource templates, prompts and their arguments. */
60
+ async function everyDescription(): Promise<string> {
61
+ const client = await connected();
62
+ const [tools, templates, prompts] = await Promise.all([
63
+ client.listTools(),
64
+ client.listResourceTemplates(),
65
+ client.listPrompts(),
66
+ ]);
67
+ return JSON.stringify([tools.tools, templates.resourceTemplates, prompts.prompts]);
53
68
  }
54
69
 
55
70
  /** Every `policy_*` token in a text. */
@@ -58,15 +73,26 @@ const policyTokens = (text: string) => [...new Set(text.match(/\bpolicy_[a-z_]+\
58
73
  describe('tool descriptions against the catalogue', () => {
59
74
  beforeEach(() => vi.restoreAllMocks());
60
75
 
61
- it('names no policy type the catalogue lacks, in any description or schema', async () => {
62
- const tools = await allTools();
63
- const text = JSON.stringify(tools) + SERVER_INSTRUCTIONS;
76
+ it('names no policy type the catalogue lacks, in any description, schema, template or prompt', async () => {
77
+ const text = (await everyDescription()) + SERVER_INSTRUCTIONS;
64
78
 
65
79
  for (const token of policyTokens(text)) {
66
80
  expect(TENANT_DOCUMENT_TYPES as readonly string[], token).toContain(token);
67
81
  }
68
82
  });
69
83
 
84
+ it('names no tool that is not registered, in any description, template, prompt or the instructions', async () => {
85
+ const client = await connected();
86
+ const registered = new Set((await client.listTools()).tools.map((t) => t.name));
87
+ const text = (await everyDescription()) + SERVER_INSTRUCTIONS;
88
+
89
+ for (const name of new Set(text.match(/\btaiga_[a-z_]+\b/g) ?? [])) {
90
+ // Prompts carry the same prefix; they are registered too.
91
+ const prompts = new Set((await client.listPrompts()).prompts.map((p) => p.name));
92
+ expect(registered.has(name) || prompts.has(name), name).toBe(true);
93
+ }
94
+ });
95
+
70
96
  it('names every policy where the company documents are explained', async () => {
71
97
  const tools = await allTools();
72
98
  const list = tools.find((t) => t.name === 'taiga_list_documents')!;
@@ -30,7 +30,7 @@ export {
30
30
  PRODUCT_STATUSES,
31
31
  TENANT_DOCUMENT_TYPES,
32
32
  };
33
- export type { ProductDocumentOrigin };
33
+ export type { ProductDocumentOrigin, ProductDocumentType };
34
34
 
35
35
  /**
36
36
  * Display names for every product document type. The reviewable nine come
@@ -7,7 +7,7 @@ import { READ_ONLY } from './annotations.js';
7
7
  import { BACKLOG_SUMMARY_FIELDS } from './backlogs.js';
8
8
  import { BUILD_SUMMARY_FIELDS } from './builds.js';
9
9
  import { POLICY_TYPES, defaultOriginFor, isImportedProduct, type ProductSettingsView } from './catalogue.js';
10
- import { isNetworkFailure, networkFailureDetail, runTool, toolResult } from './errors.js';
10
+ import { apiFailureText, isNetworkFailure, networkFailureDetail, runTool, toolError, toolResult } from './errors.js';
11
11
  import { productId, remoteUrl, slug } from './inputs.js';
12
12
  import { PRODUCT_SUMMARY_FIELDS, resolveProductRef } from './products.js';
13
13
  import { instructionsText, sectionsOf, stripProvenance } from './sections.js';
@@ -53,9 +53,7 @@ type Part = keyof typeof PARTS;
53
53
 
54
54
  /** What the API said when a part failed, in the words the bundle reports. */
55
55
  function unavailableReason(reason: unknown): string {
56
- if (reason instanceof TaigaApiError) {
57
- return `${reason.status} ${reason.code ?? ''}`.trim() + (reason.apiMessage ? `: ${reason.apiMessage}` : '');
58
- }
56
+ if (reason instanceof TaigaApiError) return apiFailureText(reason);
59
57
  return `the API could not be reached: ${networkFailureDetail(reason as TypeError)}`;
60
58
  }
61
59
 
@@ -273,7 +271,7 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
273
271
  (params) =>
274
272
  runTool(async () => {
275
273
  const resolved = await resolveProductRef(client, params, ['productId', 'slug', 'remoteUrl']);
276
- if ('error' in resolved) return resolved.error;
274
+ if ('refusal' in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
277
275
  return productContext(client, resolved.product, { fullDocuments: params.fullDocuments });
278
276
  })
279
277
  );
@@ -46,6 +46,21 @@ const CODE_BY_STATUS: Record<number, string> = {
46
46
  * which is what the model needs to fix the draft. A failed connection is
47
47
  * structured too, so every failure the model sees has the same shape.
48
48
  */
49
+ /** What the API said, in one phrase: the status, the code it sent (or the one its status implies), its message. */
50
+ export function apiFailureText(error: TaigaApiError): string {
51
+ return `${error.status} ${apiFailureCode(error)}${error.apiMessage ? `: ${error.apiMessage}` : ''}`;
52
+ }
53
+
54
+ /** The code the API sent, or the one its status implies: the code a model branches on. */
55
+ export function apiFailureCode(error: TaigaApiError): string {
56
+ return error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
57
+ }
58
+
59
+ /** Whether a failure is the API's refusal or absence of a thing, as opposed to the API failing. */
60
+ export function isMissOrRefusal(error: unknown): error is TaigaApiError {
61
+ return error instanceof TaigaApiError && (error.status === 404 || error.status === 403);
62
+ }
63
+
49
64
  export async function runTool(handler: () => Promise<CallToolResult>): Promise<CallToolResult> {
50
65
  try {
51
66
  return await handler();
@@ -63,3 +78,18 @@ export async function runTool(handler: () => Promise<CallToolResult>): Promise<C
63
78
  throw error;
64
79
  }
65
80
  }
81
+
82
+ /**
83
+ * A refusal a resource or prompt handler throws. The protocol layer sends
84
+ * `code` and `message` as they are; McpError would prefix the message with
85
+ * "MCP error -32602:" and the client would prefix it again.
86
+ */
87
+ export class RequestError extends Error {
88
+ constructor(
89
+ readonly code: number,
90
+ message: string
91
+ ) {
92
+ super(message);
93
+ this.name = 'RequestError';
94
+ }
95
+ }
@@ -5,9 +5,11 @@ import { registerDocumentTools } from './documents.js';
5
5
  import { registerBacklogTools } from './backlogs.js';
6
6
  import { registerBuildTools } from './builds.js';
7
7
  import { registerContextTools } from './context.js';
8
+ import { registerPrompts } from './prompts.js';
9
+ import { registerResources } from './resources.js';
8
10
 
9
11
  /**
10
- * Register all MCP tools based on the API key's effective capabilities.
12
+ * Register all MCP tools, resources and prompts based on the API key's effective capabilities.
11
13
  * Only tools matching the key's scopes are registered -- the AI assistant
12
14
  * never sees tools it can't use.
13
15
  */
@@ -17,4 +19,6 @@ export function registerAllTools(server: McpServer, client: TaigaClient): void {
17
19
  registerBacklogTools(server, client);
18
20
  registerBuildTools(server, client);
19
21
  registerContextTools(server, client);
22
+ registerResources(server, client);
23
+ registerPrompts(server, client);
20
24
  }
@@ -62,6 +62,10 @@ export const SERVER_INSTRUCTIONS = [
62
62
  "the product's specification and architecture, and the backlog item you are working on. A document",
63
63
  'read that finds nothing tells you what does exist and which tool reads or writes it.',
64
64
  '',
65
+ 'The same documents are resources (taiga://documents/{type}, taiga://products/{productId}/documents/{type},',
66
+ 'taiga://factories/{factoryId}/instructions, taiga://backlog/{itemId}) for clients that attach them, and three',
67
+ 'prompts open a session: taiga_start_session, taiga_implement_backlog_item, taiga_review_against_policies.',
68
+ '',
65
69
  'When you notice a policy or factory instructions are outdated, fix the section with',
66
70
  'taiga_update_document or taiga_update_factory_document. That saves a draft nobody follows yet;',
67
71
  'publish it with taiga_publish_document or taiga_publish_factory_document only when the person asks.',