@taiga-ai/mcp-server 0.14.0-dev.428 → 0.16.0-dev.430
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 +560 -158
- package/package.json +6 -2
- package/scripts/build.mjs +2 -2
- package/src/client.ts +130 -2
- package/src/index.ts +11 -21
- package/src/modern.integration.test.ts +153 -0
- package/src/server.integration.test.ts +612 -44
- package/src/server.ts +18 -0
- package/src/tools/autonomy.ts +5 -5
- package/src/tools/backlogs.ts +20 -20
- package/src/tools/builds.ts +6 -6
- package/src/tools/catalogue.test.ts +5 -3
- package/src/tools/context.ts +3 -4
- package/src/tools/documents.ts +16 -16
- package/src/tools/errors.ts +7 -19
- package/src/tools/inbox.ts +6 -6
- package/src/tools/index.ts +11 -8
- package/src/tools/instructions.ts +8 -0
- package/src/tools/jobs.ts +2 -2
- package/src/tools/knowledge.ts +7 -7
- package/src/tools/maintenance.ts +13 -13
- package/src/tools/observe.ts +12 -8
- package/src/tools/product-documents.ts +8 -8
- package/src/tools/products.ts +12 -12
- package/src/tools/prompts.ts +11 -17
- package/src/tools/queue.ts +4 -4
- package/src/tools/repositories.ts +266 -0
- package/src/tools/resources.ts +9 -5
- package/src/tools/setup.ts +342 -0
- package/src/tools/wait.ts +21 -21
- package/taiga-ai-mcp-server-0.16.0-dev.430.tgz +0 -0
- package/taiga-ai-mcp-server-0.14.0-dev.428.tgz +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { Client } from '@modelcontextprotocol/
|
|
3
|
-
import {
|
|
4
|
-
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
2
|
+
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
|
|
3
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
5
4
|
import { TaigaClient } from './client.js';
|
|
6
|
-
import {
|
|
5
|
+
import { createServer } from './server.js';
|
|
6
|
+
import type { WaitOptions } from './tools/wait.js';
|
|
7
7
|
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
// Helpers -- thin wrappers, not abstraction layers
|
|
@@ -22,6 +22,9 @@ const ALL_CAPABILITIES = [
|
|
|
22
22
|
'customDocuments:read',
|
|
23
23
|
'customDocuments:write',
|
|
24
24
|
'customDocuments:delete',
|
|
25
|
+
'factories:write',
|
|
26
|
+
'vcs:read',
|
|
27
|
+
'vcs:manage',
|
|
25
28
|
];
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -31,8 +34,11 @@ const ALL_CAPABILITIES = [
|
|
|
31
34
|
const ALL_TOOL_NAMES = [
|
|
32
35
|
'taiga_add_knowledge',
|
|
33
36
|
'taiga_approve_plan',
|
|
37
|
+
'taiga_create_factory',
|
|
38
|
+
'taiga_create_product',
|
|
34
39
|
'taiga_define_environment',
|
|
35
40
|
'taiga_delete_knowledge',
|
|
41
|
+
'taiga_finish_discovery',
|
|
36
42
|
'taiga_fix_finding',
|
|
37
43
|
'taiga_fix_policy_violation',
|
|
38
44
|
'taiga_generate_product_documents',
|
|
@@ -54,6 +60,7 @@ const ALL_TOOL_NAMES = [
|
|
|
54
60
|
'taiga_get_product_document',
|
|
55
61
|
'taiga_get_queue',
|
|
56
62
|
'taiga_ignore_finding',
|
|
63
|
+
'taiga_link_repository',
|
|
57
64
|
'taiga_list_action_required',
|
|
58
65
|
'taiga_list_backlog_items',
|
|
59
66
|
'taiga_list_builds',
|
|
@@ -62,19 +69,27 @@ const ALL_TOOL_NAMES = [
|
|
|
62
69
|
'taiga_list_factory_documents',
|
|
63
70
|
'taiga_list_findings',
|
|
64
71
|
'taiga_list_knowledge',
|
|
72
|
+
'taiga_list_linkable_repositories',
|
|
65
73
|
'taiga_list_product_documents',
|
|
74
|
+
'taiga_list_product_repositories',
|
|
66
75
|
'taiga_list_products',
|
|
76
|
+
'taiga_list_repository_branches',
|
|
67
77
|
'taiga_list_setup_tasks',
|
|
68
78
|
'taiga_mark_product_document_reviewed',
|
|
69
79
|
'taiga_move_backlog_item',
|
|
80
|
+
'taiga_move_product',
|
|
70
81
|
'taiga_pause_line',
|
|
71
82
|
'taiga_plan_again',
|
|
83
|
+
'taiga_preview_product_move',
|
|
72
84
|
'taiga_publish_document',
|
|
73
85
|
'taiga_publish_factory_document',
|
|
74
86
|
'taiga_publish_product_document',
|
|
75
87
|
'taiga_queue_backlog_item',
|
|
88
|
+
'taiga_refresh_reality_documents',
|
|
76
89
|
'taiga_regenerate_product_document',
|
|
77
90
|
'taiga_reject_plan',
|
|
91
|
+
'taiga_rename_factory',
|
|
92
|
+
'taiga_reopen_discovery',
|
|
78
93
|
'taiga_request_initiatives',
|
|
79
94
|
'taiga_resolve_action',
|
|
80
95
|
'taiga_resolve_product',
|
|
@@ -83,11 +98,15 @@ const ALL_TOOL_NAMES = [
|
|
|
83
98
|
'taiga_set_auto_build',
|
|
84
99
|
'taiga_set_auto_merge',
|
|
85
100
|
'taiga_set_product_autonomy',
|
|
101
|
+
'taiga_set_repository_branch',
|
|
102
|
+
'taiga_start_import',
|
|
86
103
|
'taiga_stop_build',
|
|
87
104
|
'taiga_stop_generating_documents',
|
|
105
|
+
'taiga_unlink_repository',
|
|
88
106
|
'taiga_update_document',
|
|
89
107
|
'taiga_update_environment',
|
|
90
108
|
'taiga_update_factory_document',
|
|
109
|
+
'taiga_update_product',
|
|
91
110
|
'taiga_update_product_document',
|
|
92
111
|
'taiga_wait_for_backlog_item',
|
|
93
112
|
'taiga_wait_for_build',
|
|
@@ -139,14 +158,18 @@ function sentBody(fetchSpy: ReturnType<typeof vi.fn>, method: string, pathPart:
|
|
|
139
158
|
/** What the API answers when it refuses: the status and the error envelope its error handler sends. */
|
|
140
159
|
class Refusal {
|
|
141
160
|
readonly status: number;
|
|
142
|
-
readonly body: { error: string; code: string; message: string };
|
|
161
|
+
readonly body: { error: string; code: string; message: string; details?: Record<string, unknown> };
|
|
143
162
|
|
|
144
|
-
constructor(
|
|
163
|
+
constructor(
|
|
164
|
+
status: number,
|
|
165
|
+
body: { error: string; code: string; message: string; details?: Record<string, unknown> }
|
|
166
|
+
) {
|
|
145
167
|
this.status = status;
|
|
146
168
|
this.body = body;
|
|
147
169
|
}
|
|
148
170
|
}
|
|
149
|
-
const refusal = (status: number, code: string, message: string
|
|
171
|
+
const refusal = (status: number, code: string, message: string, details?: Record<string, unknown>) =>
|
|
172
|
+
new Refusal(status, { error: code, code, message, ...(details ? { details } : {}) });
|
|
150
173
|
|
|
151
174
|
function makeFetchRouter(routes: Record<string, unknown>) {
|
|
152
175
|
return vi.fn().mockImplementation(async (url: string, init: RequestInit) => {
|
|
@@ -218,17 +241,25 @@ async function bootMcpPair(opts: { capabilities: string[]; routes?: Record<strin
|
|
|
218
241
|
});
|
|
219
242
|
await taigaClient.introspect();
|
|
220
243
|
|
|
221
|
-
const server = new McpServer({ name: 'taiga-test', version: '0.0.1' });
|
|
222
244
|
// Waits poll every few milliseconds here, not every few seconds.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const client = new Client({ name: 'test-client', version: '0.0.1' });
|
|
226
|
-
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
227
|
-
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
|
245
|
+
const client = await connectServer(taigaClient, { pollIntervalMs: 5 });
|
|
228
246
|
|
|
229
247
|
return { client, fetchSpy };
|
|
230
248
|
}
|
|
231
249
|
|
|
250
|
+
/**
|
|
251
|
+
* The server as the entry point serves it: createServer behind serveStdio,
|
|
252
|
+
* reached by a client opening with the 2025 initialize handshake, as Claude
|
|
253
|
+
* Code and Cursor do (modern.integration.test.ts covers 2026-07-28).
|
|
254
|
+
*/
|
|
255
|
+
async function connectServer(taigaClient: TaigaClient, wait?: WaitOptions) {
|
|
256
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
257
|
+
serveStdio(() => createServer(taigaClient, wait ? { wait } : {}), { transport: serverTransport });
|
|
258
|
+
const client = new Client({ name: 'test-client', version: '0.0.1' });
|
|
259
|
+
await client.connect(clientTransport);
|
|
260
|
+
return client;
|
|
261
|
+
}
|
|
262
|
+
|
|
232
263
|
/** Extract sorted tool names from a listTools response. */
|
|
233
264
|
function toolNames(tools: { name: string }[]) {
|
|
234
265
|
return tools.map((t) => t.name).sort();
|
|
@@ -264,13 +295,20 @@ describe('MCP server integration', () => {
|
|
|
264
295
|
expect(toolNames(tools)).toEqual(ALL_TOOL_NAMES);
|
|
265
296
|
});
|
|
266
297
|
|
|
267
|
-
it('should
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
// is
|
|
298
|
+
it('should not advertise tools when the API key has no capabilities', async () => {
|
|
299
|
+
// With no tool registered the server leaves the tools capability out, as
|
|
300
|
+
// v1 did. A v2 client then answers listTools with an empty list itself,
|
|
301
|
+
// without asking, so the capability is what is asserted.
|
|
271
302
|
const { client } = await bootMcpPair({ capabilities: [] });
|
|
272
303
|
|
|
273
|
-
|
|
304
|
+
expect(client.getServerCapabilities()?.tools).toBeUndefined();
|
|
305
|
+
expect((await client.listTools()).tools).toEqual([]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should give the server instructions to a client on the 2025 handshake', async () => {
|
|
309
|
+
const { client } = await bootMcpPair({ capabilities: ['products:read'] });
|
|
310
|
+
|
|
311
|
+
expect(client.getInstructions()).toContain('Taiga');
|
|
274
312
|
});
|
|
275
313
|
|
|
276
314
|
it('should expose only factory tools for a teams:read-only key', async () => {
|
|
@@ -302,7 +340,7 @@ describe('MCP server integration', () => {
|
|
|
302
340
|
// routes need tenant:documents:read.
|
|
303
341
|
const { client } = await bootMcpPair({ capabilities: ['tenant:documents:write'] });
|
|
304
342
|
|
|
305
|
-
|
|
343
|
+
expect(client.getServerCapabilities()?.tools).toBeUndefined();
|
|
306
344
|
});
|
|
307
345
|
|
|
308
346
|
it('should expose write tools when documents:write is also granted', async () => {
|
|
@@ -410,7 +448,17 @@ describe('MCP server integration', () => {
|
|
|
410
448
|
'taiga_get_deployments',
|
|
411
449
|
'taiga_get_delivery_metrics',
|
|
412
450
|
'taiga_get_monitoring',
|
|
451
|
+
'taiga_list_product_repositories',
|
|
452
|
+
'taiga_list_repository_branches',
|
|
453
|
+
],
|
|
454
|
+
'vcs:read': ['taiga_list_linkable_repositories'],
|
|
455
|
+
'factories:write': [
|
|
456
|
+
'taiga_create_factory',
|
|
457
|
+
'taiga_rename_factory',
|
|
458
|
+
'taiga_preview_product_move',
|
|
459
|
+
'taiga_move_product',
|
|
413
460
|
],
|
|
461
|
+
'vcs:manage': ['taiga_link_repository', 'taiga_set_repository_branch', 'taiga_unlink_repository'],
|
|
414
462
|
'backlog:read': [
|
|
415
463
|
'taiga_list_backlog_items',
|
|
416
464
|
'taiga_get_backlog_item',
|
|
@@ -435,10 +483,16 @@ describe('MCP server integration', () => {
|
|
|
435
483
|
'taiga_mark_product_document_reviewed',
|
|
436
484
|
'taiga_generate_product_documents',
|
|
437
485
|
'taiga_stop_generating_documents',
|
|
486
|
+
'taiga_create_product',
|
|
487
|
+
'taiga_update_product',
|
|
488
|
+
'taiga_refresh_reality_documents',
|
|
489
|
+
'taiga_finish_discovery',
|
|
490
|
+
'taiga_reopen_discovery',
|
|
438
491
|
],
|
|
439
492
|
'backlog:read products:write': ['taiga_approve_plan', 'taiga_reject_plan'],
|
|
440
493
|
'backlog:read backlog:write products:write': ['taiga_plan_again'],
|
|
441
494
|
'products:read products:write': [
|
|
495
|
+
'taiga_start_import',
|
|
442
496
|
'taiga_stop_build',
|
|
443
497
|
'taiga_pause_line',
|
|
444
498
|
'taiga_ignore_finding',
|
|
@@ -724,11 +778,7 @@ describe('MCP server integration', () => {
|
|
|
724
778
|
vi.stubGlobal('fetch', fetchSpy);
|
|
725
779
|
const taigaClient = new TaigaClient({ baseUrl: 'https://api.test.com', apiKey: 'taiga_sk_live_test' });
|
|
726
780
|
await taigaClient.introspect();
|
|
727
|
-
const
|
|
728
|
-
registerAllTools(server, taigaClient);
|
|
729
|
-
const client = new Client({ name: 'test-client', version: '0.0.1' });
|
|
730
|
-
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
731
|
-
await Promise.all([server.connect(st), client.connect(ct)]);
|
|
781
|
+
const client = await connectServer(taigaClient);
|
|
732
782
|
|
|
733
783
|
const result = await client.callTool({ name: 'taiga_publish_document', arguments: { type: 'policy_security' } });
|
|
734
784
|
|
|
@@ -758,11 +808,7 @@ describe('MCP server integration', () => {
|
|
|
758
808
|
vi.stubGlobal('fetch', fetchSpy);
|
|
759
809
|
const taigaClient = new TaigaClient({ baseUrl: 'https://api.test.com', apiKey: 'taiga_sk_live_test' });
|
|
760
810
|
await taigaClient.introspect();
|
|
761
|
-
const
|
|
762
|
-
registerAllTools(server, taigaClient);
|
|
763
|
-
const client = new Client({ name: 'test-client', version: '0.0.1' });
|
|
764
|
-
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
765
|
-
await Promise.all([server.connect(st), client.connect(ct)]);
|
|
811
|
+
const client = await connectServer(taigaClient);
|
|
766
812
|
|
|
767
813
|
const result = await client.callTool({
|
|
768
814
|
name: 'taiga_update_document',
|
|
@@ -793,11 +839,7 @@ describe('MCP server integration', () => {
|
|
|
793
839
|
vi.stubGlobal('fetch', fetchSpy);
|
|
794
840
|
const taigaClient = new TaigaClient({ baseUrl: 'https://api.test.com', apiKey: 'taiga_sk_live_test' });
|
|
795
841
|
await taigaClient.introspect();
|
|
796
|
-
const
|
|
797
|
-
registerAllTools(server, taigaClient);
|
|
798
|
-
const client = new Client({ name: 'test-client', version: '0.0.1' });
|
|
799
|
-
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
800
|
-
await Promise.all([server.connect(st), client.connect(ct)]);
|
|
842
|
+
const client = await connectServer(taigaClient);
|
|
801
843
|
|
|
802
844
|
const result = await client.callTool({ name: 'taiga_list_factories', arguments: {} });
|
|
803
845
|
|
|
@@ -2437,7 +2479,7 @@ describe('MCP server integration', () => {
|
|
|
2437
2479
|
|
|
2438
2480
|
expect(failure).toMatchObject({ code: -32602 });
|
|
2439
2481
|
expect(String((failure as Error).message)).toBe(
|
|
2440
|
-
'
|
|
2482
|
+
'No published tone_of_voice the key can read (404 NOT_FOUND: The requested resource does not exist). ' +
|
|
2441
2483
|
'Published documents: policy_security.'
|
|
2442
2484
|
);
|
|
2443
2485
|
});
|
|
@@ -2612,7 +2654,8 @@ describe('MCP server integration', () => {
|
|
|
2612
2654
|
error: 'FORBIDDEN',
|
|
2613
2655
|
message: expect.stringContaining('403 FORBIDDEN'),
|
|
2614
2656
|
});
|
|
2615
|
-
expect(
|
|
2657
|
+
expect(prompt).toMatchObject({ code: -32602 });
|
|
2658
|
+
expect(String((prompt as Error).message)).toMatch(/^FORBIDDEN: /);
|
|
2616
2659
|
});
|
|
2617
2660
|
|
|
2618
2661
|
it('should refuse a session on a product the key cannot see as invalid params', async () => {
|
|
@@ -2634,7 +2677,7 @@ describe('MCP server integration', () => {
|
|
|
2634
2677
|
expect(failure).toMatchObject({ code: -32602 });
|
|
2635
2678
|
// A slash command with no argument lands here; the message says how to pass one.
|
|
2636
2679
|
expect(String((failure as Error).message)).toMatch(
|
|
2637
|
-
/^
|
|
2680
|
+
/^VALIDATION_FAILED: Name the product exactly once: slug or productId or remoteUrl\. Pass the slug as the first argument \(\/taiga_start_session <slug>/
|
|
2638
2681
|
);
|
|
2639
2682
|
});
|
|
2640
2683
|
|
|
@@ -3082,14 +3125,19 @@ describe('MCP server integration', () => {
|
|
|
3082
3125
|
// A request, the resume and the review gate take a product or an item ID a request or a queue hands
|
|
3083
3126
|
// back, so they need no backlog read; the verdicts and Plan again act on what only a read shows.
|
|
3084
3127
|
expect(toolNames((await operatorOnly.client.listTools()).tools)).toEqual([
|
|
3128
|
+
'taiga_create_product',
|
|
3129
|
+
'taiga_finish_discovery',
|
|
3085
3130
|
'taiga_generate_product_documents',
|
|
3086
3131
|
'taiga_mark_product_document_reviewed',
|
|
3087
3132
|
'taiga_publish_product_document',
|
|
3133
|
+
'taiga_refresh_reality_documents',
|
|
3134
|
+
'taiga_reopen_discovery',
|
|
3088
3135
|
'taiga_request_initiatives',
|
|
3089
3136
|
'taiga_resume_backlog_item',
|
|
3090
3137
|
'taiga_set_auto_build',
|
|
3091
3138
|
'taiga_set_product_autonomy',
|
|
3092
3139
|
'taiga_stop_generating_documents',
|
|
3140
|
+
'taiga_update_product',
|
|
3093
3141
|
]);
|
|
3094
3142
|
});
|
|
3095
3143
|
|
|
@@ -4202,7 +4250,6 @@ describe('MCP server integration', () => {
|
|
|
4202
4250
|
|
|
4203
4251
|
const pending = client.callTool(
|
|
4204
4252
|
{ name: 'taiga_wait_for_backlog_item', arguments: { itemId: ITEM_ID, until: 'planned', timeoutSeconds: 60 } },
|
|
4205
|
-
undefined,
|
|
4206
4253
|
{ signal: controller.signal }
|
|
4207
4254
|
);
|
|
4208
4255
|
await vi.waitFor(() => expect(hung).toBeDefined());
|
|
@@ -4427,7 +4474,6 @@ describe('MCP server integration', () => {
|
|
|
4427
4474
|
|
|
4428
4475
|
const result = await client.callTool(
|
|
4429
4476
|
{ name: 'taiga_wait_for_backlog_item', arguments: { itemId: ITEM_ID, until: 'changed' } },
|
|
4430
|
-
undefined,
|
|
4431
4477
|
{ onprogress }
|
|
4432
4478
|
);
|
|
4433
4479
|
|
|
@@ -5522,6 +5568,532 @@ describe('MCP server integration', () => {
|
|
|
5522
5568
|
});
|
|
5523
5569
|
});
|
|
5524
5570
|
|
|
5571
|
+
describe('setting a product up', () => {
|
|
5572
|
+
const FACTORY_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c01';
|
|
5573
|
+
const OTHER_FACTORY_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c02';
|
|
5574
|
+
const JOB_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c03';
|
|
5575
|
+
const LINK_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c04';
|
|
5576
|
+
const GH_REPO_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c05';
|
|
5577
|
+
const INSTALL_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c06';
|
|
5578
|
+
const INSTALL_ID_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c07';
|
|
5579
|
+
const product = (settings: Record<string, unknown> = {}) => ({
|
|
5580
|
+
id: PRODUCT_ID,
|
|
5581
|
+
name: 'Billing',
|
|
5582
|
+
slug: 'billing',
|
|
5583
|
+
status: 'draft',
|
|
5584
|
+
factoryId: FACTORY_ID,
|
|
5585
|
+
ownerId: 'u',
|
|
5586
|
+
ownsInfrastructure: null,
|
|
5587
|
+
ownsDeployment: false,
|
|
5588
|
+
deploymentNotes: 'Platform team owns the pipelines',
|
|
5589
|
+
emitDesignStandards: null,
|
|
5590
|
+
autoBuild: true,
|
|
5591
|
+
settings,
|
|
5592
|
+
});
|
|
5593
|
+
const link = {
|
|
5594
|
+
id: LINK_ID,
|
|
5595
|
+
provider: 'github',
|
|
5596
|
+
repositoryName: 'acme/billing',
|
|
5597
|
+
repositoryUrl: 'https://github.com/acme/billing',
|
|
5598
|
+
repositoryDefaultBranch: 'main',
|
|
5599
|
+
baseBranch: null,
|
|
5600
|
+
effectiveBranch: 'main',
|
|
5601
|
+
label: null,
|
|
5602
|
+
languages: { TypeScript: 100 },
|
|
5603
|
+
isManaged: false,
|
|
5604
|
+
buildable: true,
|
|
5605
|
+
};
|
|
5606
|
+
const ghRepo = (id: string, fullName: string) => ({
|
|
5607
|
+
id,
|
|
5608
|
+
installationId: INSTALL_ID,
|
|
5609
|
+
githubId: 1,
|
|
5610
|
+
name: fullName.split('/')[1],
|
|
5611
|
+
fullName,
|
|
5612
|
+
private: true,
|
|
5613
|
+
visibility: 'private',
|
|
5614
|
+
archived: false,
|
|
5615
|
+
description: null,
|
|
5616
|
+
defaultBranch: 'main',
|
|
5617
|
+
language: 'TypeScript',
|
|
5618
|
+
topics: [],
|
|
5619
|
+
});
|
|
5620
|
+
const calls = (fetchSpy: ReturnType<typeof vi.fn>, method: string) =>
|
|
5621
|
+
fetchSpy.mock.calls
|
|
5622
|
+
.filter((c: unknown[]) => ((c[1] as RequestInit | undefined)?.method ?? 'GET') === method)
|
|
5623
|
+
.map((c: unknown[]) => new URL(c[0] as string));
|
|
5624
|
+
|
|
5625
|
+
it('should create a product from scratch or for an import, with what Taiga builds as the person decided', async () => {
|
|
5626
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5627
|
+
capabilities: ['products:write'],
|
|
5628
|
+
routes: {
|
|
5629
|
+
'POST /v1/products': (_url: string, init: RequestInit) => {
|
|
5630
|
+
const body = JSON.parse(init.body as string);
|
|
5631
|
+
const created = { data: product(body.projectType ? { projectType: body.projectType } : {}) };
|
|
5632
|
+
return { ok: true, status: 201, json: async () => created, text: async () => JSON.stringify(created) };
|
|
5633
|
+
},
|
|
5634
|
+
},
|
|
5635
|
+
});
|
|
5636
|
+
|
|
5637
|
+
const scratch = parseToolResult(
|
|
5638
|
+
await client.callTool({
|
|
5639
|
+
name: 'taiga_create_product',
|
|
5640
|
+
arguments: { factoryId: FACTORY_ID, name: 'Billing', start: 'scratch' },
|
|
5641
|
+
})
|
|
5642
|
+
);
|
|
5643
|
+
const firstBody = sentBody(fetchSpy, 'POST', '/v1/products');
|
|
5644
|
+
fetchSpy.mockClear();
|
|
5645
|
+
const imported = parseToolResult(
|
|
5646
|
+
await client.callTool({
|
|
5647
|
+
name: 'taiga_create_product',
|
|
5648
|
+
arguments: {
|
|
5649
|
+
factoryId: FACTORY_ID,
|
|
5650
|
+
name: 'Billing',
|
|
5651
|
+
start: 'import',
|
|
5652
|
+
ownsDeployment: false,
|
|
5653
|
+
deploymentNotes: 'Platform team owns the pipelines',
|
|
5654
|
+
},
|
|
5655
|
+
})
|
|
5656
|
+
);
|
|
5657
|
+
|
|
5658
|
+
// Following the organization is saying nothing, as the web's dialog does.
|
|
5659
|
+
expect(firstBody).toEqual({ name: 'Billing', factoryId: FACTORY_ID });
|
|
5660
|
+
expect(sentBody(fetchSpy, 'POST', '/v1/products')).toEqual({
|
|
5661
|
+
name: 'Billing',
|
|
5662
|
+
factoryId: FACTORY_ID,
|
|
5663
|
+
projectType: 'brownfield',
|
|
5664
|
+
ownsDeployment: false,
|
|
5665
|
+
deploymentNotes: 'Platform team owns the pipelines',
|
|
5666
|
+
});
|
|
5667
|
+
expect(scratch).toMatchObject({ id: PRODUCT_ID, projectType: 'greenfield' });
|
|
5668
|
+
expect(imported).toMatchObject({ projectType: 'brownfield', ownsDeployment: false });
|
|
5669
|
+
expect(imported).not.toHaveProperty('settings');
|
|
5670
|
+
expect(imported).not.toHaveProperty('ownerId');
|
|
5671
|
+
});
|
|
5672
|
+
|
|
5673
|
+
it("should change a product's settings, returning a half to the organization with null, and refuse an empty change", async () => {
|
|
5674
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5675
|
+
capabilities: ['products:write'],
|
|
5676
|
+
routes: { [`PATCH /v1/products/${PRODUCT_ID}`]: { data: product() } },
|
|
5677
|
+
});
|
|
5678
|
+
|
|
5679
|
+
await client.callTool({
|
|
5680
|
+
name: 'taiga_update_product',
|
|
5681
|
+
arguments: { productId: PRODUCT_ID, ownsInfrastructure: null, emitDesignStandards: false, name: 'Billing 2' },
|
|
5682
|
+
});
|
|
5683
|
+
const nothing = await client.callTool({ name: 'taiga_update_product', arguments: { productId: PRODUCT_ID } });
|
|
5684
|
+
|
|
5685
|
+
expect(sentBody(fetchSpy, 'PATCH', `/v1/products/${PRODUCT_ID}`)).toEqual({
|
|
5686
|
+
ownsInfrastructure: null,
|
|
5687
|
+
emitDesignStandards: false,
|
|
5688
|
+
name: 'Billing 2',
|
|
5689
|
+
});
|
|
5690
|
+
expect(nothing.isError).toBe(true);
|
|
5691
|
+
expect(calls(fetchSpy, 'PATCH')).toHaveLength(1);
|
|
5692
|
+
});
|
|
5693
|
+
|
|
5694
|
+
it('should create and rename a factory, preview a move and move a product, stopping the line only when asked', async () => {
|
|
5695
|
+
const factory = {
|
|
5696
|
+
id: OTHER_FACTORY_ID,
|
|
5697
|
+
name: 'Payments',
|
|
5698
|
+
slug: 'payments',
|
|
5699
|
+
isDefault: false,
|
|
5700
|
+
settings: {},
|
|
5701
|
+
autoMerge: null,
|
|
5702
|
+
};
|
|
5703
|
+
const preview = {
|
|
5704
|
+
sourceFactoryId: FACTORY_ID,
|
|
5705
|
+
targetFactoryId: OTHER_FACTORY_ID,
|
|
5706
|
+
loses: [],
|
|
5707
|
+
gains: [],
|
|
5708
|
+
kept: [],
|
|
5709
|
+
};
|
|
5710
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5711
|
+
capabilities: ['factories:write'],
|
|
5712
|
+
routes: {
|
|
5713
|
+
'POST /v1/factories': { data: factory },
|
|
5714
|
+
[`PATCH /v1/factories/${OTHER_FACTORY_ID}`]: { data: { ...factory, name: 'Payments EU' } },
|
|
5715
|
+
[`GET /v1/products/${PRODUCT_ID}/factory/preview?factoryId=${OTHER_FACTORY_ID}`]: { data: preview },
|
|
5716
|
+
[`PUT /v1/products/${PRODUCT_ID}/factory`]: {
|
|
5717
|
+
data: { ...product(), factoryId: OTHER_FACTORY_ID },
|
|
5718
|
+
changed: true,
|
|
5719
|
+
previousFactoryId: FACTORY_ID,
|
|
5720
|
+
lineStopped: false,
|
|
5721
|
+
factoryStopped: false,
|
|
5722
|
+
rescopedDocumentIds: [],
|
|
5723
|
+
failedDocumentIds: [],
|
|
5724
|
+
failedDocuments: [],
|
|
5725
|
+
},
|
|
5726
|
+
},
|
|
5727
|
+
});
|
|
5728
|
+
|
|
5729
|
+
const created = parseToolResult(
|
|
5730
|
+
await client.callTool({ name: 'taiga_create_factory', arguments: { name: 'Payments' } })
|
|
5731
|
+
);
|
|
5732
|
+
const renamed = parseToolResult(
|
|
5733
|
+
await client.callTool({
|
|
5734
|
+
name: 'taiga_rename_factory',
|
|
5735
|
+
arguments: { factoryId: OTHER_FACTORY_ID, name: 'Payments EU' },
|
|
5736
|
+
})
|
|
5737
|
+
);
|
|
5738
|
+
const seen = parseToolResult(
|
|
5739
|
+
await client.callTool({
|
|
5740
|
+
name: 'taiga_preview_product_move',
|
|
5741
|
+
arguments: { productId: PRODUCT_ID, factoryId: OTHER_FACTORY_ID },
|
|
5742
|
+
})
|
|
5743
|
+
);
|
|
5744
|
+
const moved = parseToolResult(
|
|
5745
|
+
await client.callTool({
|
|
5746
|
+
name: 'taiga_move_product',
|
|
5747
|
+
arguments: { productId: PRODUCT_ID, factoryId: OTHER_FACTORY_ID },
|
|
5748
|
+
})
|
|
5749
|
+
);
|
|
5750
|
+
const plainMove = sentBody(fetchSpy, 'PUT', '/factory');
|
|
5751
|
+
const factoryBody = sentBody(fetchSpy, 'POST', '/v1/factories');
|
|
5752
|
+
fetchSpy.mockClear();
|
|
5753
|
+
await client.callTool({
|
|
5754
|
+
name: 'taiga_move_product',
|
|
5755
|
+
arguments: { productId: PRODUCT_ID, factoryId: OTHER_FACTORY_ID, stopLine: true },
|
|
5756
|
+
});
|
|
5757
|
+
|
|
5758
|
+
expect(factoryBody).toEqual({ name: 'Payments' });
|
|
5759
|
+
expect(created).toEqual({ id: OTHER_FACTORY_ID, name: 'Payments', slug: 'payments', isDefault: false });
|
|
5760
|
+
expect(renamed.name).toBe('Payments EU');
|
|
5761
|
+
expect(seen).toEqual(preview);
|
|
5762
|
+
expect(plainMove).toEqual({ factoryId: OTHER_FACTORY_ID });
|
|
5763
|
+
expect(sentBody(fetchSpy, 'PUT', '/factory')).toEqual({ factoryId: OTHER_FACTORY_ID, stopLine: true });
|
|
5764
|
+
expect(moved).toMatchObject({
|
|
5765
|
+
changed: true,
|
|
5766
|
+
previousFactoryId: FACTORY_ID,
|
|
5767
|
+
product: { factoryId: OTHER_FACTORY_ID },
|
|
5768
|
+
});
|
|
5769
|
+
});
|
|
5770
|
+
|
|
5771
|
+
it('should start an import only for a product created to import, and refresh, finish and reopen Discovery', async () => {
|
|
5772
|
+
const SCRATCH_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c08';
|
|
5773
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5774
|
+
capabilities: ['products:read', 'products:write'],
|
|
5775
|
+
routes: {
|
|
5776
|
+
[`GET /v1/products/${PRODUCT_ID}`]: { data: product({ projectType: 'brownfield' }) },
|
|
5777
|
+
[`GET /v1/products/${SCRATCH_ID}`]: { data: { ...product(), id: SCRATCH_ID } },
|
|
5778
|
+
[`POST /v1/products/${PRODUCT_ID}/brownfield-import`]: { data: { jobId: JOB_ID } },
|
|
5779
|
+
[`POST /v1/products/${PRODUCT_ID}/documents/refresh-reality`]: { data: { jobId: JOB_ID } },
|
|
5780
|
+
[`POST /v1/products/${PRODUCT_ID}/setup/complete`]: { data: { ...product(), status: 'active' } },
|
|
5781
|
+
[`POST /v1/products/${PRODUCT_ID}/setup/reopen`]: { data: product() },
|
|
5782
|
+
},
|
|
5783
|
+
});
|
|
5784
|
+
|
|
5785
|
+
const scratch = await client.callTool({ name: 'taiga_start_import', arguments: { productId: SCRATCH_ID } });
|
|
5786
|
+
const started = parseToolResult(
|
|
5787
|
+
await client.callTool({ name: 'taiga_start_import', arguments: { productId: PRODUCT_ID } })
|
|
5788
|
+
);
|
|
5789
|
+
await client.callTool({
|
|
5790
|
+
name: 'taiga_refresh_reality_documents',
|
|
5791
|
+
arguments: { productId: PRODUCT_ID, docTypes: ['architecture'] },
|
|
5792
|
+
});
|
|
5793
|
+
const finished = parseToolResult(
|
|
5794
|
+
await client.callTool({ name: 'taiga_finish_discovery', arguments: { productId: PRODUCT_ID } })
|
|
5795
|
+
);
|
|
5796
|
+
const reopened = parseToolResult(
|
|
5797
|
+
await client.callTool({ name: 'taiga_reopen_discovery', arguments: { productId: PRODUCT_ID } })
|
|
5798
|
+
);
|
|
5799
|
+
|
|
5800
|
+
expect(scratch.isError).toBe(true);
|
|
5801
|
+
expect(parseToolResult(scratch).message).toMatch(/started from scratch/);
|
|
5802
|
+
expect(calls(fetchSpy, 'POST').map((u) => u.pathname)).not.toContain(
|
|
5803
|
+
`/v1/products/${SCRATCH_ID}/brownfield-import`
|
|
5804
|
+
);
|
|
5805
|
+
expect(started).toEqual({ jobId: JOB_ID });
|
|
5806
|
+
expect(sentBody(fetchSpy, 'POST', '/refresh-reality')).toEqual({ docTypes: ['architecture'] });
|
|
5807
|
+
expect(finished.status).toBe('active');
|
|
5808
|
+
expect(reopened.status).toBe('draft');
|
|
5809
|
+
});
|
|
5810
|
+
|
|
5811
|
+
it("should list a product's repositories and branches, and find, link, set and unlink one", async () => {
|
|
5812
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5813
|
+
capabilities: ['products:read', 'vcs:read', 'vcs:manage'],
|
|
5814
|
+
routes: {
|
|
5815
|
+
[`GET /v1/products/${PRODUCT_ID}/repositories`]: { data: [link], nextCursor: null },
|
|
5816
|
+
[`GET /v1/products/${PRODUCT_ID}/repositories/${LINK_ID}/branches`]: {
|
|
5817
|
+
data: [
|
|
5818
|
+
{ name: 'main', protected: true },
|
|
5819
|
+
{ name: 'develop', protected: false },
|
|
5820
|
+
],
|
|
5821
|
+
truncated: false,
|
|
5822
|
+
},
|
|
5823
|
+
'GET /v1/github/installations': {
|
|
5824
|
+
data: [
|
|
5825
|
+
{ id: INSTALL_ID, installationId: 1, accountLogin: 'acme', accountType: 'Organization', permissions: {} },
|
|
5826
|
+
],
|
|
5827
|
+
nextCursor: null,
|
|
5828
|
+
},
|
|
5829
|
+
[`GET /v1/github/installations/${INSTALL_ID}/repositories`]: {
|
|
5830
|
+
data: [ghRepo(GH_REPO_ID, 'acme/billing')],
|
|
5831
|
+
nextCursor: 'next',
|
|
5832
|
+
},
|
|
5833
|
+
[`POST /v1/products/${PRODUCT_ID}/repositories`]: () => {
|
|
5834
|
+
const created = { data: link };
|
|
5835
|
+
return { ok: true, status: 201, json: async () => created, text: async () => JSON.stringify(created) };
|
|
5836
|
+
},
|
|
5837
|
+
[`PATCH /v1/products/${PRODUCT_ID}/repositories/${LINK_ID}`]: {
|
|
5838
|
+
data: { ...link, baseBranch: 'develop', effectiveBranch: 'develop', baseBranchProtection: null },
|
|
5839
|
+
},
|
|
5840
|
+
[`DELETE /v1/products/${PRODUCT_ID}/repositories/${LINK_ID}`]: () => new Response(null, { status: 204 }),
|
|
5841
|
+
},
|
|
5842
|
+
});
|
|
5843
|
+
|
|
5844
|
+
const linked = parseToolResult(
|
|
5845
|
+
await client.callTool({ name: 'taiga_list_product_repositories', arguments: { productId: PRODUCT_ID } })
|
|
5846
|
+
);
|
|
5847
|
+
const branches = parseToolResult(
|
|
5848
|
+
await client.callTool({
|
|
5849
|
+
name: 'taiga_list_repository_branches',
|
|
5850
|
+
arguments: { productId: PRODUCT_ID, repositoryId: LINK_ID },
|
|
5851
|
+
})
|
|
5852
|
+
);
|
|
5853
|
+
const found = parseToolResult(
|
|
5854
|
+
await client.callTool({ name: 'taiga_list_linkable_repositories', arguments: { query: 'bill' } })
|
|
5855
|
+
);
|
|
5856
|
+
await client.callTool({
|
|
5857
|
+
name: 'taiga_link_repository',
|
|
5858
|
+
arguments: { productId: PRODUCT_ID, githubRepositoryId: GH_REPO_ID },
|
|
5859
|
+
});
|
|
5860
|
+
const set = parseToolResult(
|
|
5861
|
+
await client.callTool({
|
|
5862
|
+
name: 'taiga_set_repository_branch',
|
|
5863
|
+
arguments: { productId: PRODUCT_ID, repositoryId: LINK_ID, baseBranch: 'develop' },
|
|
5864
|
+
})
|
|
5865
|
+
);
|
|
5866
|
+
const nothing = await client.callTool({
|
|
5867
|
+
name: 'taiga_set_repository_branch',
|
|
5868
|
+
arguments: { productId: PRODUCT_ID, repositoryId: LINK_ID },
|
|
5869
|
+
});
|
|
5870
|
+
const unlinked = parseToolResult(
|
|
5871
|
+
await client.callTool({
|
|
5872
|
+
name: 'taiga_unlink_repository',
|
|
5873
|
+
arguments: { productId: PRODUCT_ID, repositoryId: LINK_ID },
|
|
5874
|
+
})
|
|
5875
|
+
);
|
|
5876
|
+
|
|
5877
|
+
expect(linked).toEqual([
|
|
5878
|
+
{
|
|
5879
|
+
id: LINK_ID,
|
|
5880
|
+
provider: 'github',
|
|
5881
|
+
repositoryName: 'acme/billing',
|
|
5882
|
+
repositoryUrl: 'https://github.com/acme/billing',
|
|
5883
|
+
repositoryDefaultBranch: 'main',
|
|
5884
|
+
baseBranch: null,
|
|
5885
|
+
effectiveBranch: 'main',
|
|
5886
|
+
label: null,
|
|
5887
|
+
buildable: true,
|
|
5888
|
+
isManaged: false,
|
|
5889
|
+
},
|
|
5890
|
+
]);
|
|
5891
|
+
expect(branches.branches).toHaveLength(2);
|
|
5892
|
+
expect(found.accounts).toEqual([{ id: INSTALL_ID, accountLogin: 'acme', accountType: 'Organization' }]);
|
|
5893
|
+
expect(found.repositories).toEqual({
|
|
5894
|
+
data: [
|
|
5895
|
+
{
|
|
5896
|
+
id: GH_REPO_ID,
|
|
5897
|
+
fullName: 'acme/billing',
|
|
5898
|
+
private: true,
|
|
5899
|
+
archived: false,
|
|
5900
|
+
defaultBranch: 'main',
|
|
5901
|
+
description: null,
|
|
5902
|
+
language: 'TypeScript',
|
|
5903
|
+
},
|
|
5904
|
+
],
|
|
5905
|
+
nextCursor: 'next',
|
|
5906
|
+
});
|
|
5907
|
+
expect(sentUrl(fetchSpy, `/installations/${INSTALL_ID}/repositories`).searchParams.get('q')).toBe('bill');
|
|
5908
|
+
// The web's picker sends the GitHub repository's id and nothing else; the API fills in the rest.
|
|
5909
|
+
expect(sentBody(fetchSpy, 'POST', `/v1/products/${PRODUCT_ID}/repositories`)).toEqual({
|
|
5910
|
+
githubRepositoryId: GH_REPO_ID,
|
|
5911
|
+
});
|
|
5912
|
+
expect(sentBody(fetchSpy, 'PATCH', `/repositories/${LINK_ID}`)).toEqual({ baseBranch: 'develop' });
|
|
5913
|
+
expect(set).toMatchObject({ effectiveBranch: 'develop', baseBranchProtection: null });
|
|
5914
|
+
expect(nothing.isError).toBe(true);
|
|
5915
|
+
expect(calls(fetchSpy, 'PATCH')).toHaveLength(1);
|
|
5916
|
+
expect(unlinked).toEqual({ repositoryId: LINK_ID, unlinked: true });
|
|
5917
|
+
});
|
|
5918
|
+
|
|
5919
|
+
it('should say GitHub is not connected, and read every account once when several are', async () => {
|
|
5920
|
+
const none = await bootMcpPair({
|
|
5921
|
+
capabilities: ['vcs:read'],
|
|
5922
|
+
routes: { 'GET /v1/github/installations': { data: [], nextCursor: null } },
|
|
5923
|
+
});
|
|
5924
|
+
const notConnected = await none.client.callTool({ name: 'taiga_list_linkable_repositories', arguments: {} });
|
|
5925
|
+
expect(notConnected.isError).toBe(true);
|
|
5926
|
+
expect(parseToolResult(notConnected).error).toBe('NOT_CONNECTED');
|
|
5927
|
+
|
|
5928
|
+
const two = await bootMcpPair({
|
|
5929
|
+
capabilities: ['vcs:read'],
|
|
5930
|
+
routes: {
|
|
5931
|
+
'GET /v1/github/installations': {
|
|
5932
|
+
data: [
|
|
5933
|
+
{ id: INSTALL_ID, accountLogin: 'acme', accountType: 'Organization' },
|
|
5934
|
+
{ id: INSTALL_ID_2, accountLogin: 'acme-labs', accountType: 'Organization' },
|
|
5935
|
+
],
|
|
5936
|
+
nextCursor: null,
|
|
5937
|
+
},
|
|
5938
|
+
[`GET /v1/github/installations/${INSTALL_ID}/repositories`]: {
|
|
5939
|
+
data: [ghRepo(GH_REPO_ID, 'acme/billing')],
|
|
5940
|
+
nextCursor: null,
|
|
5941
|
+
},
|
|
5942
|
+
[`GET /v1/github/installations/${INSTALL_ID_2}/repositories`]: {
|
|
5943
|
+
data: [ghRepo(LINK_ID, 'acme-labs/billing-poc')],
|
|
5944
|
+
nextCursor: 'more',
|
|
5945
|
+
},
|
|
5946
|
+
},
|
|
5947
|
+
});
|
|
5948
|
+
const both = parseToolResult(
|
|
5949
|
+
await two.client.callTool({ name: 'taiga_list_linkable_repositories', arguments: { query: 'billing' } })
|
|
5950
|
+
);
|
|
5951
|
+
expect(both.repositories.nextCursor).toBeNull();
|
|
5952
|
+
const one = parseToolResult(
|
|
5953
|
+
await two.client.callTool({
|
|
5954
|
+
name: 'taiga_list_linkable_repositories',
|
|
5955
|
+
arguments: { query: 'billing', limit: 1 },
|
|
5956
|
+
})
|
|
5957
|
+
);
|
|
5958
|
+
// The limit holds across accounts, and the answer says it was cut.
|
|
5959
|
+
expect(one.repositories.data).toHaveLength(1);
|
|
5960
|
+
expect(one.more).toMatch(/installationId/);
|
|
5961
|
+
expect(both.repositories.data.map((r: { fullName: string }) => r.fullName)).toEqual([
|
|
5962
|
+
'acme/billing',
|
|
5963
|
+
'acme-labs/billing-poc',
|
|
5964
|
+
]);
|
|
5965
|
+
expect(both.more).toMatch(/installationId/);
|
|
5966
|
+
});
|
|
5967
|
+
});
|
|
5968
|
+
|
|
5969
|
+
describe('setting a product up when the API refuses', () => {
|
|
5970
|
+
const FACTORY_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c01';
|
|
5971
|
+
const INSTALL_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c06';
|
|
5972
|
+
const INSTALL_ID_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c07';
|
|
5973
|
+
const UNKNOWN_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c09';
|
|
5974
|
+
const GH_REPO_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4c05';
|
|
5975
|
+
|
|
5976
|
+
it("should pass on the API's reason and what it found, so the model knows which documents to publish", async () => {
|
|
5977
|
+
const { client } = await bootMcpPair({
|
|
5978
|
+
capabilities: ['products:write', 'factories:write', 'vcs:manage'],
|
|
5979
|
+
routes: {
|
|
5980
|
+
[`POST /v1/products/${PRODUCT_ID}/setup/complete`]: refusal(
|
|
5981
|
+
409,
|
|
5982
|
+
'CONFLICT',
|
|
5983
|
+
'Every required document must be published before setup can be completed',
|
|
5984
|
+
{ reason: 'documents_incomplete', missingDocuments: ['threat_model', 'risk_register'] }
|
|
5985
|
+
),
|
|
5986
|
+
[`PUT /v1/products/${PRODUCT_ID}/factory`]: refusal(409, 'CONFLICT', 'A plan or build is in flight', {
|
|
5987
|
+
reason: 'work_in_flight',
|
|
5988
|
+
}),
|
|
5989
|
+
[`POST /v1/products/${PRODUCT_ID}/repositories`]: refusal(
|
|
5990
|
+
409,
|
|
5991
|
+
'CONFLICT',
|
|
5992
|
+
'acme/billing is already linked to this product.'
|
|
5993
|
+
),
|
|
5994
|
+
},
|
|
5995
|
+
});
|
|
5996
|
+
|
|
5997
|
+
const finish = await client.callTool({ name: 'taiga_finish_discovery', arguments: { productId: PRODUCT_ID } });
|
|
5998
|
+
const move = await client.callTool({
|
|
5999
|
+
name: 'taiga_move_product',
|
|
6000
|
+
arguments: { productId: PRODUCT_ID, factoryId: FACTORY_ID },
|
|
6001
|
+
});
|
|
6002
|
+
const link = await client.callTool({
|
|
6003
|
+
name: 'taiga_link_repository',
|
|
6004
|
+
arguments: { productId: PRODUCT_ID, githubRepositoryId: GH_REPO_ID },
|
|
6005
|
+
});
|
|
6006
|
+
|
|
6007
|
+
expect(finish.isError).toBe(true);
|
|
6008
|
+
expect(parseToolResult(finish)).toEqual({
|
|
6009
|
+
error: 'CONFLICT',
|
|
6010
|
+
message: 'Every required document must be published before setup can be completed',
|
|
6011
|
+
details: { reason: 'documents_incomplete', missingDocuments: ['threat_model', 'risk_register'] },
|
|
6012
|
+
});
|
|
6013
|
+
expect(parseToolResult(move)).toMatchObject({ error: 'CONFLICT', details: { reason: 'work_in_flight' } });
|
|
6014
|
+
// A refusal without details carries none.
|
|
6015
|
+
expect(parseToolResult(link)).toEqual({
|
|
6016
|
+
error: 'CONFLICT',
|
|
6017
|
+
message: 'acme/billing is already linked to this product.',
|
|
6018
|
+
});
|
|
6019
|
+
});
|
|
6020
|
+
|
|
6021
|
+
it('should refuse what it can tell is wrong before any request: an unknown account, a cursor without one, a sentence with nothing to describe', async () => {
|
|
6022
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
6023
|
+
capabilities: ['products:write', 'vcs:read'],
|
|
6024
|
+
routes: {
|
|
6025
|
+
'GET /v1/github/installations': {
|
|
6026
|
+
data: [
|
|
6027
|
+
{ id: INSTALL_ID, accountLogin: 'acme', accountType: 'Organization' },
|
|
6028
|
+
{ id: INSTALL_ID_2, accountLogin: 'acme-labs', accountType: 'Organization' },
|
|
6029
|
+
],
|
|
6030
|
+
nextCursor: null,
|
|
6031
|
+
},
|
|
6032
|
+
},
|
|
6033
|
+
});
|
|
6034
|
+
|
|
6035
|
+
const unknown = await client.callTool({
|
|
6036
|
+
name: 'taiga_list_linkable_repositories',
|
|
6037
|
+
arguments: { installationId: UNKNOWN_ID },
|
|
6038
|
+
});
|
|
6039
|
+
const cursor = await client.callTool({ name: 'taiga_list_linkable_repositories', arguments: { cursor: 'abc' } });
|
|
6040
|
+
const notes = await client.callTool({
|
|
6041
|
+
name: 'taiga_create_product',
|
|
6042
|
+
arguments: {
|
|
6043
|
+
factoryId: FACTORY_ID,
|
|
6044
|
+
name: 'Billing',
|
|
6045
|
+
start: 'scratch',
|
|
6046
|
+
ownsInfrastructure: true,
|
|
6047
|
+
ownsDeployment: true,
|
|
6048
|
+
deploymentNotes: 'Nothing to say',
|
|
6049
|
+
},
|
|
6050
|
+
});
|
|
6051
|
+
|
|
6052
|
+
expect(parseToolResult(unknown)).toMatchObject({ error: 'NOT_FOUND', details: { accounts: expect.any(Array) } });
|
|
6053
|
+
expect(parseToolResult(cursor).error).toBe('VALIDATION_FAILED');
|
|
6054
|
+
const pagedSearch = await client.callTool({
|
|
6055
|
+
name: 'taiga_list_linkable_repositories',
|
|
6056
|
+
arguments: { installationId: INSTALL_ID, query: 'bill', cursor: 'abc' },
|
|
6057
|
+
});
|
|
6058
|
+
expect(parseToolResult(pagedSearch).error).toBe('VALIDATION_FAILED');
|
|
6059
|
+
expect(parseToolResult(notes).error).toBe('VALIDATION_FAILED');
|
|
6060
|
+
const updateNotes = await client.callTool({
|
|
6061
|
+
name: 'taiga_update_product',
|
|
6062
|
+
arguments: { productId: PRODUCT_ID, ownsInfrastructure: true, ownsDeployment: true, deploymentNotes: 'x' },
|
|
6063
|
+
});
|
|
6064
|
+
expect(parseToolResult(updateNotes).error).toBe('VALIDATION_FAILED');
|
|
6065
|
+
const asked = fetchSpy.mock.calls.map((c: unknown[]) => new URL(c[0] as string).pathname);
|
|
6066
|
+
expect(asked.filter((p: string) => p.includes('/github/installations/'))).toEqual([]);
|
|
6067
|
+
expect(asked).not.toContain('/v1/products');
|
|
6068
|
+
expect(asked).not.toContain(`/v1/products/${PRODUCT_ID}`);
|
|
6069
|
+
});
|
|
6070
|
+
|
|
6071
|
+
it('should find a GitHub account on a later page of connections', async () => {
|
|
6072
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
6073
|
+
capabilities: ['vcs:read'],
|
|
6074
|
+
routes: {
|
|
6075
|
+
'GET /v1/github/installations': (url: string) =>
|
|
6076
|
+
new Response(
|
|
6077
|
+
JSON.stringify(
|
|
6078
|
+
new URL(url).searchParams.get('cursor') === 'p2'
|
|
6079
|
+
? { data: [{ id: INSTALL_ID_2, accountLogin: 'acme-labs' }], nextCursor: null }
|
|
6080
|
+
: { data: [{ id: INSTALL_ID, accountLogin: 'acme' }], nextCursor: 'p2' }
|
|
6081
|
+
)
|
|
6082
|
+
),
|
|
6083
|
+
[`GET /v1/github/installations/${INSTALL_ID_2}/repositories`]: { data: [], nextCursor: null },
|
|
6084
|
+
},
|
|
6085
|
+
});
|
|
6086
|
+
|
|
6087
|
+
const found = parseToolResult(
|
|
6088
|
+
await client.callTool({ name: 'taiga_list_linkable_repositories', arguments: { installationId: INSTALL_ID_2 } })
|
|
6089
|
+
);
|
|
6090
|
+
|
|
6091
|
+
expect(found.accounts.map((a: { accountLogin: string }) => a.accountLogin)).toEqual(['acme', 'acme-labs']);
|
|
6092
|
+
expect(found.repositories).toEqual({ data: [], nextCursor: null });
|
|
6093
|
+
expect(sentUrl(fetchSpy, 'cursor=p2').searchParams.get('limit')).toBe('100');
|
|
6094
|
+
});
|
|
6095
|
+
});
|
|
6096
|
+
|
|
5525
6097
|
describe('the Discovery lock on regenerating', () => {
|
|
5526
6098
|
it('should refuse to regenerate a document once Discovery is finished, before any job is asked for', async () => {
|
|
5527
6099
|
const { client, fetchSpy } = await bootMcpPair({
|
|
@@ -5578,11 +6150,7 @@ describe('MCP server integration', () => {
|
|
|
5578
6150
|
// Arrange
|
|
5579
6151
|
const taigaClient = new TaigaClient({ baseUrl: 'https://api.test.com', apiKey: 'test' });
|
|
5580
6152
|
await taigaClient.introspect();
|
|
5581
|
-
const
|
|
5582
|
-
registerAllTools(server, taigaClient);
|
|
5583
|
-
const mcpClient = new Client({ name: 'test-client', version: '0.0.1' });
|
|
5584
|
-
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
5585
|
-
await Promise.all([server.connect(st), mcpClient.connect(ct)]);
|
|
6153
|
+
const mcpClient = await connectServer(taigaClient);
|
|
5586
6154
|
|
|
5587
6155
|
// Act
|
|
5588
6156
|
const result = await mcpClient.callTool({ name: 'taiga_list_products', arguments: {} });
|