@taiga-ai/mcp-server 0.4.0-rc.345 → 0.5.0-dev.388
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.
- package/dist/index.js +393 -153
- package/package.json +2 -2
- package/src/client.test.ts +78 -16
- package/src/client.ts +124 -21
- package/src/config.test.ts +24 -0
- package/src/config.ts +25 -0
- package/src/index.ts +7 -9
- package/src/server.integration.test.ts +407 -34
- package/src/tools/documents.ts +245 -97
- package/src/tools/errors.ts +48 -0
- package/src/tools/inputs.test.ts +19 -0
- package/src/tools/inputs.ts +13 -0
- package/src/tools/products.ts +87 -73
- package/src/tools/sections.test.ts +58 -0
- package/src/tools/sections.ts +38 -0
- package/taiga-ai-mcp-server-0.5.0-dev.388.tgz +0 -0
- package/tsconfig.json +8 -5
- package/taiga-ai-mcp-server-0.4.0-rc.345.tgz +0 -0
|
@@ -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(
|
|
57
|
-
text: () => Promise.resolve(
|
|
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]
|
|
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
|
|
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', '
|
|
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
|
-
|
|
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:
|
|
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
|
|
292
|
-
const
|
|
293
|
-
const
|
|
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: {
|
|
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:
|
|
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
|
-
|
|
306
|
-
expect(
|
|
307
|
-
|
|
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
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
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: {
|
|
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:
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
);
|
|
328
|
-
expect(
|
|
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
|
|
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
|
-
//
|
|
768
|
+
// productId must be a UUID, not a plain string
|
|
396
769
|
const result = await client.callTool({
|
|
397
|
-
name: '
|
|
398
|
-
arguments: {
|
|
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);
|