@taiga-ai/mcp-server 0.4.0 → 0.5.0-rc.389

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.
@@ -13,6 +13,7 @@ import { registerAllTools } from './tools/index.js';
13
13
  const ALL_CAPABILITIES = [
14
14
  'products:read',
15
15
  'factories:read',
16
+ 'factories:documents:write',
16
17
  'tenant:documents:read',
17
18
  'tenant:documents:write',
18
19
  'backlog:read',
@@ -31,10 +32,42 @@ const ALL_TOOL_NAMES = [
31
32
  'taiga_list_factory_documents',
32
33
  'taiga_list_product_documents',
33
34
  'taiga_list_products',
35
+ 'taiga_publish_document',
36
+ 'taiga_publish_factory_document',
34
37
  'taiga_update_document',
35
38
  'taiga_update_factory_document',
36
39
  ];
37
40
 
41
+ const FACTORY_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b';
42
+
43
+ /** A policy as the API returns it: sections carry metadata beside their text. */
44
+ function policyDocument(overrides: Record<string, unknown> = {}) {
45
+ return {
46
+ id: 'doc-1',
47
+ type: 'policy_security',
48
+ name: 'Security Policy',
49
+ version: 3,
50
+ isDraft: false,
51
+ data: {
52
+ version: '1.0.0',
53
+ sections: {
54
+ summary: { source: 'template', priority: 'core', content: 'Old summary' },
55
+ scope: { source: 'template', priority: 'core', content: 'Applies to everything' },
56
+ },
57
+ },
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ /** The JSON body the fetch mock received for the one request matching `method` and `pathPart`. */
63
+ function sentBody(fetchSpy: ReturnType<typeof vi.fn>, method: string, pathPart: string) {
64
+ const call = fetchSpy.mock.calls.find(
65
+ (c: unknown[]) => (c[1] as RequestInit)?.method === method && (c[0] as string).includes(pathPart)
66
+ );
67
+ expect(call, `${method} ${pathPart} was not sent`).toBeDefined();
68
+ return JSON.parse((call![1] as RequestInit).body as string);
69
+ }
70
+
38
71
  function makeFetchRouter(routes: Record<string, unknown>) {
39
72
  return vi.fn().mockImplementation(async (url: string, init: RequestInit) => {
40
73
  const path = new URL(url).pathname;
@@ -50,11 +83,13 @@ function makeFetchRouter(routes: Record<string, unknown>) {
50
83
  text: () => Promise.resolve(JSON.stringify(body)),
51
84
  };
52
85
  }
86
+ // Same envelope as the API's own 404 (shared/middleware/error-handler.ts).
87
+ const notFound = { error: 'Not Found', code: 'NOT_FOUND', message: 'The requested resource does not exist' };
53
88
  return {
54
89
  ok: false,
55
90
  status: 404,
56
- json: () => Promise.resolve({ error: 'Not found' }),
57
- text: () => Promise.resolve('Not found'),
91
+ json: () => Promise.resolve(notFound),
92
+ text: () => Promise.resolve(JSON.stringify(notFound)),
58
93
  };
59
94
  });
60
95
  }
@@ -110,7 +145,7 @@ function toolNames(tools: { name: string }[]) {
110
145
  /** Extract parsed JSON from the first text content block of a tool call result. */
111
146
  function parseToolResult(result: Record<string, unknown>) {
112
147
  const content = result.content as { type: string; text: string }[];
113
- return JSON.parse(content[0].text);
148
+ return JSON.parse(content[0]!.text);
114
149
  }
115
150
 
116
151
  // ---------------------------------------------------------------------------
@@ -170,6 +205,14 @@ describe('MCP server integration', () => {
170
205
  expect(names).not.toContain('taiga_update_document');
171
206
  });
172
207
 
208
+ it('should not expose company document writes to a write-only key', async () => {
209
+ // The edit reads the draft and the published version first, and those
210
+ // routes need tenant:documents:read.
211
+ const { client } = await bootMcpPair({ capabilities: ['tenant:documents:write'] });
212
+
213
+ await expect(client.listTools()).rejects.toThrow(/Method not found/);
214
+ });
215
+
173
216
  it('should expose write tools when documents:write is also granted', async () => {
174
217
  const { client } = await bootMcpPair({
175
218
  capabilities: ['tenant:documents:read', 'tenant:documents:write'],
@@ -181,18 +224,33 @@ describe('MCP server integration', () => {
181
224
  expect(names).toContain('taiga_update_document');
182
225
  });
183
226
 
184
- it('should expose factory document write when both teams:read and documents:write are granted', async () => {
227
+ it('should expose factory document writes when factories:read and factories:documents:write are granted', async () => {
185
228
  const { client } = await bootMcpPair({
186
- capabilities: ['factories:read', 'tenant:documents:write'],
229
+ capabilities: ['factories:read', 'factories:documents:write'],
187
230
  });
188
231
 
189
232
  const { tools } = await client.listTools();
190
233
  const names = toolNames(tools);
191
234
 
192
235
  expect(names).toContain('taiga_update_factory_document');
236
+ expect(names).toContain('taiga_publish_factory_document');
193
237
  expect(names).toContain('taiga_list_factories');
194
238
  });
195
239
 
240
+ it('should not let tenant:documents:write stand in for factories:documents:write', async () => {
241
+ // The API's factory document write routes require factories:documents:write;
242
+ // a tool registered on the company-policy capability would only 403.
243
+ const { client } = await bootMcpPair({
244
+ capabilities: ['factories:read', 'tenant:documents:write'],
245
+ });
246
+
247
+ const { tools } = await client.listTools();
248
+ const names = toolNames(tools);
249
+
250
+ expect(names).not.toContain('taiga_update_factory_document');
251
+ expect(names).not.toContain('taiga_publish_factory_document');
252
+ });
253
+
196
254
  it('should combine capabilities additively', async () => {
197
255
  const { client } = await bootMcpPair({
198
256
  capabilities: ['factories:read', 'tenant:documents:read'],
@@ -210,15 +268,22 @@ describe('MCP server integration', () => {
210
268
  expect(names).not.toContain('taiga_update_document');
211
269
  });
212
270
 
213
- it('should provide a description and input schema for every tool', async () => {
271
+ it('should provide a title, description, annotations and input schema for every tool', async () => {
214
272
  const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
215
273
 
216
274
  const { tools } = await client.listTools();
217
275
 
218
276
  for (const tool of tools) {
277
+ expect(tool.title, `${tool.name} missing title`).toBeTruthy();
219
278
  expect(tool.description, `${tool.name} missing description`).toBeTruthy();
220
279
  expect(tool.inputSchema, `${tool.name} missing inputSchema`).toBeDefined();
221
280
  expect(tool.inputSchema.type).toBe('object');
281
+ expect(tool.annotations, `${tool.name} missing annotations`).toMatchObject({
282
+ readOnlyHint: expect.any(Boolean),
283
+ destructiveHint: expect.any(Boolean),
284
+ idempotentHint: expect.any(Boolean),
285
+ openWorldHint: expect.any(Boolean),
286
+ });
222
287
  }
223
288
  });
224
289
  });
@@ -234,12 +299,15 @@ describe('MCP server integration', () => {
234
299
  'products:read': ['taiga_list_products', 'taiga_list_product_documents', 'taiga_get_product_document'],
235
300
  'factories:read': ['taiga_list_factories', 'taiga_list_factory_documents', 'taiga_get_factory_document'],
236
301
  'tenant:documents:read': ['taiga_list_documents', 'taiga_get_document'],
237
- 'tenant:documents:write': ['taiga_update_document'],
302
+ };
303
+ const PAIRED_CONTRACT: Record<string, string[]> = {
304
+ 'tenant:documents:read tenant:documents:write': ['taiga_update_document', 'taiga_publish_document'],
305
+ 'factories:read factories:documents:write': ['taiga_update_factory_document', 'taiga_publish_factory_document'],
238
306
  };
239
307
 
240
- for (const [capability, expectedTools] of Object.entries(CONTRACT)) {
308
+ for (const [capability, expectedTools] of [...Object.entries(CONTRACT), ...Object.entries(PAIRED_CONTRACT)]) {
241
309
  it(`${capability} should gate: ${expectedTools.join(', ')}`, async () => {
242
- const { client } = await bootMcpPair({ capabilities: [capability] });
310
+ const { client } = await bootMcpPair({ capabilities: capability.split(' ') });
243
311
 
244
312
  const { tools } = await client.listTools();
245
313
  const names = toolNames(tools);
@@ -288,45 +356,349 @@ describe('MCP server integration', () => {
288
356
  expect(parseToolResult(result)).toEqual(docs);
289
357
  });
290
358
 
291
- it('should send PUT when updating a document draft', async () => {
292
- const docType = 'security_policy';
293
- const updated = { type: docType, title: 'Updated Policy' };
359
+ it('should replace one section of the existing draft and keep the rest', async () => {
360
+ const draft = policyDocument({ id: 'draft-1', version: null, isDraft: true });
361
+ const saved = policyDocument({ id: 'draft-1', version: null, isDraft: true });
362
+ (saved.data.sections as Record<string, { content: string }>).summary!.content = 'New summary';
294
363
 
295
364
  const { client, fetchSpy } = await bootMcpPair({
296
365
  capabilities: ALL_CAPABILITIES,
297
- routes: { [`PUT /v1/tenants/current/documents/${docType}/draft`]: { data: updated } },
366
+ routes: {
367
+ 'GET /v1/tenants/current/documents/policy_security/draft': { data: draft },
368
+ 'PUT /v1/tenants/current/documents/policy_security/draft': { data: saved },
369
+ },
370
+ });
371
+
372
+ const result = await client.callTool({
373
+ name: 'taiga_update_document',
374
+ arguments: { type: 'policy_security', sectionId: 'summary', content: 'New summary' },
375
+ });
376
+
377
+ // The whole data payload goes back: the untouched section, the version
378
+ // field, and the edited section's metadata beside its new text.
379
+ expect(sentBody(fetchSpy, 'PUT', '/documents/policy_security/draft')).toEqual({
380
+ data: {
381
+ version: '1.0.0',
382
+ sections: {
383
+ summary: { source: 'template', priority: 'core', content: 'New summary' },
384
+ scope: { source: 'template', priority: 'core', content: 'Applies to everything' },
385
+ },
386
+ },
387
+ });
388
+ expect(result.isError).toBeFalsy();
389
+ expect(parseToolResult(result)).toMatchObject({
390
+ id: 'draft-1',
391
+ isDraft: true,
392
+ sections: ['summary', 'scope'],
393
+ section: { id: 'summary', content: 'New summary' },
394
+ });
395
+ expect(parseToolResult(result)).not.toHaveProperty('data');
396
+ });
397
+
398
+ it('should seed the edit from the published version when no draft exists', async () => {
399
+ const { client, fetchSpy } = await bootMcpPair({
400
+ capabilities: ALL_CAPABILITIES,
401
+ routes: {
402
+ 'GET /v1/tenants/current/documents/policy_security/draft': { data: null },
403
+ 'GET /v1/tenants/current/documents/policy_security': { data: policyDocument() },
404
+ 'PUT /v1/tenants/current/documents/policy_security/draft': { data: policyDocument({ isDraft: true }) },
405
+ },
298
406
  });
299
407
 
300
408
  await client.callTool({
301
409
  name: 'taiga_update_document',
302
- arguments: { type: docType, data: { title: 'Updated' } },
410
+ arguments: { type: 'policy_security', sectionId: 'retention', content: 'Keep logs 90 days' },
411
+ });
412
+
413
+ const body = sentBody(fetchSpy, 'PUT', '/documents/policy_security/draft');
414
+ expect(body.data.sections.summary).toEqual({ source: 'template', priority: 'core', content: 'Old summary' });
415
+ expect(body.data.sections.retention).toEqual({ content: 'Keep logs 90 days' });
416
+ });
417
+
418
+ it('should start from an empty section map when nothing is drafted or published yet', async () => {
419
+ // Organization instructions begin empty and unpublished (PRO-502): the
420
+ // published GET is a 404, and the API seeds the draft on the first save.
421
+ const { client, fetchSpy } = await bootMcpPair({
422
+ capabilities: ALL_CAPABILITIES,
423
+ routes: {
424
+ 'GET /v1/tenants/current/documents/tenant_instructions/draft': { data: null },
425
+ 'PUT /v1/tenants/current/documents/tenant_instructions/draft': {
426
+ data: policyDocument({ type: 'tenant_instructions', isDraft: true }),
427
+ },
428
+ },
429
+ });
430
+
431
+ const result = await client.callTool({
432
+ name: 'taiga_update_document',
433
+ arguments: { type: 'tenant_instructions', sectionId: 'instructions', content: 'Prefer pnpm.' },
303
434
  });
304
435
 
305
- const putCall = fetchSpy.mock.calls.find((c: unknown[]) => (c[1] as RequestInit)?.method === 'PUT');
306
- expect(putCall).toBeDefined();
307
- expect(putCall![0]).toContain(`/v1/tenants/current/documents/${docType}/draft`);
436
+ expect(result.isError).toBeFalsy();
437
+ expect(sentBody(fetchSpy, 'PUT', '/documents/tenant_instructions/draft')).toEqual({
438
+ data: { sections: { instructions: { content: 'Prefer pnpm.' } } },
439
+ });
308
440
  });
309
441
 
310
- it('should send PUT when updating a factory document draft', async () => {
311
- const factoryId = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b';
312
- const docType = 'domain_context';
313
- const updated = { type: docType, name: 'Domain Context' };
442
+ it('should refuse to section-edit a document that is not made of sections', async () => {
443
+ const { client, fetchSpy } = await bootMcpPair({
444
+ capabilities: ALL_CAPABILITIES,
445
+ routes: {
446
+ 'GET /v1/tenants/current/documents/brand_standards/draft': { data: null },
447
+ 'GET /v1/tenants/current/documents/brand_standards': {
448
+ data: policyDocument({ type: 'brand_standards', data: { components: {} } }),
449
+ },
450
+ },
451
+ });
452
+
453
+ const result = await client.callTool({
454
+ name: 'taiga_update_document',
455
+ arguments: { type: 'brand_standards', sectionId: 'summary', content: 'x' },
456
+ });
457
+
458
+ expect(result.isError).toBe(true);
459
+ expect(parseToolResult(result).error).toBe('UNSUPPORTED_DOCUMENT');
460
+ expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[1] as RequestInit)?.method === 'PUT')).toBe(false);
461
+ });
314
462
 
463
+ it('should publish the draft and return the new version without its content', async () => {
315
464
  const { client, fetchSpy } = await bootMcpPair({
316
465
  capabilities: ALL_CAPABILITIES,
317
- routes: { [`PUT /v1/factories/${factoryId}/documents/${docType}/draft`]: { data: updated } },
466
+ routes: {
467
+ 'POST /v1/tenants/current/documents/policy_security/publish': { data: policyDocument({ version: 4 }) },
468
+ },
318
469
  });
319
470
 
320
- await client.callTool({
471
+ const result = await client.callTool({ name: 'taiga_publish_document', arguments: { type: 'policy_security' } });
472
+
473
+ const post = fetchSpy.mock.calls.find((c: unknown[]) => (c[1] as RequestInit)?.method === 'POST');
474
+ expect(post).toBeDefined();
475
+ // Bodiless, and without a JSON content type: Fastify answers 400 to the pair.
476
+ expect((post![1] as RequestInit).body).toBeNull();
477
+ expect((post![1] as RequestInit).headers).not.toHaveProperty('Content-Type');
478
+ expect(result.isError).toBeFalsy();
479
+ expect(parseToolResult(result)).toMatchObject({ version: 4, isDraft: false, sections: ['summary', 'scope'] });
480
+ expect(parseToolResult(result)).not.toHaveProperty('data');
481
+ });
482
+
483
+ it('should pass the API refusal on when there is no draft to publish', async () => {
484
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: {} });
485
+
486
+ const result = await client.callTool({ name: 'taiga_publish_document', arguments: { type: 'policy_security' } });
487
+
488
+ expect(result.isError).toBe(true);
489
+ expect(parseToolResult(result)).toEqual({ error: 'NOT_FOUND', message: 'The requested resource does not exist' });
490
+ });
491
+
492
+ it('should refuse a document type shaped like a path before any request is made', async () => {
493
+ // `type` is interpolated into the route; a traversal-shaped value would
494
+ // otherwise reach a product document's draft through the company tool.
495
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
496
+ const traversal = `../../products/${FACTORY_ID}/documents/specification`;
497
+
498
+ const calls = [
499
+ client.callTool({
500
+ name: 'taiga_update_document',
501
+ arguments: { type: traversal, sectionId: 's', content: 'x' },
502
+ }),
503
+ client.callTool({ name: 'taiga_publish_document', arguments: { type: traversal } }),
504
+ client.callTool({ name: 'taiga_get_document', arguments: { type: traversal } }),
505
+ client.callTool({
506
+ name: 'taiga_update_factory_document',
507
+ arguments: { factoryId: FACTORY_ID, type: traversal, content: 'x' },
508
+ }),
509
+ client.callTool({
510
+ name: 'taiga_publish_factory_document',
511
+ arguments: { factoryId: FACTORY_ID, type: traversal },
512
+ }),
513
+ client.callTool({ name: 'taiga_get_factory_document', arguments: { factoryId: FACTORY_ID, type: traversal } }),
514
+ client.callTool({ name: 'taiga_get_product_document', arguments: { productId: FACTORY_ID, type: traversal } }),
515
+ ];
516
+ for (const result of await Promise.all(calls)) {
517
+ expect(result.isError).toBe(true);
518
+ // The refusal names the type input, not some other failure.
519
+ expect((result.content as { text: string }[])[0]!.text).toMatch(/type/);
520
+ }
521
+ expect(fetchSpy.mock.calls.filter((c: unknown[]) => !(c[0] as string).endsWith('/auth/introspect'))).toEqual([]);
522
+ });
523
+
524
+ it('should mark publishing destructive and the edit not', async () => {
525
+ // Publishing replaces the governing version with no unpublish; a host
526
+ // that confirms destructive tools must ask before it.
527
+ const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
528
+
529
+ const { tools } = await client.listTools();
530
+ const byName = Object.fromEntries(tools.map((t) => [t.name, t.annotations]));
531
+
532
+ expect(byName.taiga_publish_document).toMatchObject({ destructiveHint: true, idempotentHint: false });
533
+ expect(byName.taiga_publish_factory_document).toMatchObject({ destructiveHint: true, idempotentHint: false });
534
+ expect(byName.taiga_update_document).toMatchObject({ destructiveHint: false, idempotentHint: true });
535
+ });
536
+
537
+ it('should hand the model the API message when the publish gate refuses the draft', async () => {
538
+ const refusal = {
539
+ error: 'Bad Request',
540
+ code: 'VALIDATION_ERROR',
541
+ message: 'Draft contains template residue in section "scope": "Edit this table to reflect"',
542
+ };
543
+ const fetchSpy = vi.fn().mockImplementation(async (url: string, init: RequestInit) => {
544
+ const path = new URL(url).pathname;
545
+ if (path === '/v1/auth/introspect') {
546
+ return {
547
+ ok: true,
548
+ status: 200,
549
+ json: async () => introspectionResponse(ALL_CAPABILITIES),
550
+ text: async () => '',
551
+ };
552
+ }
553
+ if (init?.method === 'POST') {
554
+ return { ok: false, status: 400, json: async () => refusal, text: async () => JSON.stringify(refusal) };
555
+ }
556
+ const draft = { data: policyDocument({ isDraft: true }) };
557
+ return { ok: true, status: 200, json: async () => draft, text: async () => JSON.stringify(draft) };
558
+ });
559
+ vi.stubGlobal('fetch', fetchSpy);
560
+ const taigaClient = new TaigaClient({ baseUrl: 'https://api.test.com', apiKey: 'taiga_sk_live_test' });
561
+ await taigaClient.introspect();
562
+ const server = new McpServer({ name: 'taiga-test', version: '0.0.1' });
563
+ registerAllTools(server, taigaClient);
564
+ const client = new Client({ name: 'test-client', version: '0.0.1' });
565
+ const [ct, st] = InMemoryTransport.createLinkedPair();
566
+ await Promise.all([server.connect(st), client.connect(ct)]);
567
+
568
+ const result = await client.callTool({ name: 'taiga_publish_document', arguments: { type: 'policy_security' } });
569
+
570
+ expect(result.isError).toBe(true);
571
+ expect(parseToolResult(result)).toEqual({ error: 'VALIDATION_ERROR', message: refusal.message });
572
+ });
573
+
574
+ it('should name the invalid field when route validation refuses the input', async () => {
575
+ const refusal = {
576
+ error: 'ValidationError',
577
+ code: 'VALIDATION_ERROR',
578
+ message: 'Request validation failed',
579
+ details: [{ instancePath: '/type', message: 'Invalid enum value' }],
580
+ };
581
+ const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
582
+ const path = new URL(url).pathname;
583
+ if (path === '/v1/auth/introspect') {
584
+ return {
585
+ ok: true,
586
+ status: 200,
587
+ json: async () => introspectionResponse(ALL_CAPABILITIES),
588
+ text: async () => '',
589
+ };
590
+ }
591
+ return { ok: false, status: 400, json: async () => refusal, text: async () => JSON.stringify(refusal) };
592
+ });
593
+ vi.stubGlobal('fetch', fetchSpy);
594
+ const taigaClient = new TaigaClient({ baseUrl: 'https://api.test.com', apiKey: 'taiga_sk_live_test' });
595
+ await taigaClient.introspect();
596
+ const server = new McpServer({ name: 'taiga-test', version: '0.0.1' });
597
+ registerAllTools(server, taigaClient);
598
+ const client = new Client({ name: 'test-client', version: '0.0.1' });
599
+ const [ct, st] = InMemoryTransport.createLinkedPair();
600
+ await Promise.all([server.connect(st), client.connect(ct)]);
601
+
602
+ const result = await client.callTool({
603
+ name: 'taiga_update_document',
604
+ arguments: { type: 'policy_nope', sectionId: 'summary', content: 'x' },
605
+ });
606
+
607
+ expect(result.isError).toBe(true);
608
+ expect(parseToolResult(result)).toEqual({
609
+ error: 'VALIDATION_ERROR',
610
+ message: 'Request validation failed: /type Invalid enum value',
611
+ });
612
+ });
613
+
614
+ it('should report an unreachable API as a structured error', async () => {
615
+ const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
616
+ const path = new URL(url).pathname;
617
+ if (path === '/v1/auth/introspect') {
618
+ return {
619
+ ok: true,
620
+ status: 200,
621
+ json: async () => introspectionResponse(ALL_CAPABILITIES),
622
+ text: async () => '',
623
+ };
624
+ }
625
+ throw new TypeError('fetch failed');
626
+ });
627
+ vi.stubGlobal('fetch', fetchSpy);
628
+ const taigaClient = new TaigaClient({ baseUrl: 'https://api.test.com', apiKey: 'taiga_sk_live_test' });
629
+ await taigaClient.introspect();
630
+ const server = new McpServer({ name: 'taiga-test', version: '0.0.1' });
631
+ registerAllTools(server, taigaClient);
632
+ const client = new Client({ name: 'test-client', version: '0.0.1' });
633
+ const [ct, st] = InMemoryTransport.createLinkedPair();
634
+ await Promise.all([server.connect(st), client.connect(ct)]);
635
+
636
+ const result = await client.callTool({ name: 'taiga_list_factories', arguments: {} });
637
+
638
+ expect(result.isError).toBe(true);
639
+ expect(parseToolResult(result)).toMatchObject({ error: 'NETWORK_ERROR' });
640
+ });
641
+
642
+ it('should edit the instructions section of factory instructions by default', async () => {
643
+ const instructions = {
644
+ id: 'fd-1',
645
+ type: 'factory_instructions',
646
+ name: 'Factory Instructions',
647
+ version: 1,
648
+ isDraft: false,
649
+ data: { sections: { instructions: { content: 'Use pnpm.' } } },
650
+ };
651
+
652
+ const { client, fetchSpy } = await bootMcpPair({
653
+ capabilities: ALL_CAPABILITIES,
654
+ routes: {
655
+ [`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions/draft`]: { data: null },
656
+ [`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions`]: { data: instructions },
657
+ [`PUT /v1/factories/${FACTORY_ID}/documents/factory_instructions/draft`]: {
658
+ data: { ...instructions, isDraft: true },
659
+ },
660
+ },
661
+ });
662
+
663
+ const result = await client.callTool({
321
664
  name: 'taiga_update_factory_document',
322
- arguments: { factoryId, type: docType, data: { sections: { overview: 'Updated' } } },
665
+ arguments: { factoryId: FACTORY_ID, type: 'factory_instructions', content: 'Use pnpm. Node 22.' },
666
+ });
667
+
668
+ expect(result.isError).toBeFalsy();
669
+ expect(sentBody(fetchSpy, 'PUT', `/factories/${FACTORY_ID}/documents/factory_instructions/draft`)).toEqual({
670
+ data: { sections: { instructions: { content: 'Use pnpm. Node 22.' } } },
671
+ });
672
+ });
673
+
674
+ it('should publish factory instructions through the factory publish route', async () => {
675
+ const published = {
676
+ id: 'fd-1',
677
+ type: 'factory_instructions',
678
+ name: 'Factory Instructions',
679
+ version: 2,
680
+ isDraft: false,
681
+ data: { sections: {} },
682
+ };
683
+ const { client, fetchSpy } = await bootMcpPair({
684
+ capabilities: ALL_CAPABILITIES,
685
+ routes: {
686
+ [`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions/draft`]: {
687
+ data: { ...published, isDraft: true },
688
+ },
689
+ [`POST /v1/factories/${FACTORY_ID}/documents/factory_instructions/publish`]: { data: published },
690
+ },
691
+ });
692
+
693
+ const result = await client.callTool({
694
+ name: 'taiga_publish_factory_document',
695
+ arguments: { factoryId: FACTORY_ID, type: 'factory_instructions' },
323
696
  });
324
697
 
325
- const putCall = fetchSpy.mock.calls.find(
326
- (c: unknown[]) => (c[1] as RequestInit)?.method === 'PUT' && (c[0] as string).includes('/factories/')
327
- );
328
- expect(putCall).toBeDefined();
329
- expect(putCall![0]).toContain(`/v1/factories/${factoryId}/documents/${docType}/draft`);
698
+ expect(result.isError).toBeFalsy();
699
+ const post = fetchSpy.mock.calls.find((c: unknown[]) => (c[1] as RequestInit)?.method === 'POST');
700
+ expect(post![0]).toContain(`/v1/factories/${FACTORY_ID}/documents/factory_instructions/publish`);
701
+ expect(parseToolResult(result)).toMatchObject({ version: 2, isDraft: false });
330
702
  });
331
703
 
332
704
  it('should return factories through MCP protocol', async () => {
@@ -349,7 +721,7 @@ describe('MCP server integration', () => {
349
721
  // -------------------------------------------------------------------------
350
722
 
351
723
  describe('error handling', () => {
352
- it('should surface API errors as MCP error content', async () => {
724
+ it('should surface API errors as structured MCP error content', async () => {
353
725
  // Introspect succeeds, but all other endpoints fail
354
726
  const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
355
727
  const path = new URL(url).pathname;
@@ -382,8 +754,9 @@ describe('MCP server integration', () => {
382
754
  // Act
383
755
  const result = await mcpClient.callTool({ name: 'taiga_list_products', arguments: {} });
384
756
 
385
- // Assert -- the MCP SDK marks tool errors with isError
757
+ // Assert -- a stable code the model can branch on, and the API's status
386
758
  expect(result.isError).toBe(true);
759
+ expect(parseToolResult(result)).toEqual({ error: 'API_ERROR', message: 'Taiga API answered 500.' });
387
760
  });
388
761
 
389
762
  it('should reject tool calls with invalid parameters', async () => {
@@ -392,10 +765,10 @@ describe('MCP server integration', () => {
392
765
  routes: {},
393
766
  });
394
767
 
395
- // taiga_get_product requires a UUID, not a plain string
768
+ // productId must be a UUID, not a plain string
396
769
  const result = await client.callTool({
397
- name: 'taiga_get_product',
398
- arguments: { id: 'not-a-uuid' },
770
+ name: 'taiga_get_product_document',
771
+ arguments: { productId: 'not-a-uuid', type: 'specification' },
399
772
  });
400
773
 
401
774
  expect(result.isError).toBe(true);