@taiga-ai/mcp-server 0.9.0 → 0.10.0-rc.405
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 +326 -19
- package/package.json +1 -1
- package/src/client.ts +61 -0
- package/src/server.integration.test.ts +665 -2
- package/src/tools/annotations.ts +24 -2
- package/src/tools/backlogs.ts +233 -3
- package/src/tools/builds.ts +145 -15
- package/src/tools/catalogue.test.ts +2 -0
- package/src/tools/instructions.ts +10 -1
- package/src/tools/products.ts +6 -5
- package/src/tools/prompts.ts +11 -3
- package/taiga-ai-mcp-server-0.10.0-rc.405.tgz +0 -0
- package/taiga-ai-mcp-server-0.9.0.tgz +0 -0
|
@@ -17,6 +17,8 @@ const ALL_CAPABILITIES = [
|
|
|
17
17
|
'tenant:documents:read',
|
|
18
18
|
'tenant:documents:write',
|
|
19
19
|
'backlog:read',
|
|
20
|
+
'backlog:write',
|
|
21
|
+
'products:write',
|
|
20
22
|
];
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -24,10 +26,13 @@ const ALL_CAPABILITIES = [
|
|
|
24
26
|
* sorted for deterministic comparison.
|
|
25
27
|
*/
|
|
26
28
|
const ALL_TOOL_NAMES = [
|
|
29
|
+
'taiga_approve_plan',
|
|
30
|
+
'taiga_create_backlog_item',
|
|
27
31
|
'taiga_get_backlog_item',
|
|
28
32
|
'taiga_get_build',
|
|
29
33
|
'taiga_get_document',
|
|
30
34
|
'taiga_get_factory_document',
|
|
35
|
+
'taiga_get_plan',
|
|
31
36
|
'taiga_get_product',
|
|
32
37
|
'taiga_get_product_context',
|
|
33
38
|
'taiga_get_product_document',
|
|
@@ -38,9 +43,15 @@ const ALL_TOOL_NAMES = [
|
|
|
38
43
|
'taiga_list_factory_documents',
|
|
39
44
|
'taiga_list_product_documents',
|
|
40
45
|
'taiga_list_products',
|
|
46
|
+
'taiga_move_backlog_item',
|
|
41
47
|
'taiga_publish_document',
|
|
42
48
|
'taiga_publish_factory_document',
|
|
49
|
+
'taiga_queue_backlog_item',
|
|
50
|
+
'taiga_reject_plan',
|
|
43
51
|
'taiga_resolve_product',
|
|
52
|
+
'taiga_resume_backlog_item',
|
|
53
|
+
'taiga_resume_build',
|
|
54
|
+
'taiga_stop_build',
|
|
44
55
|
'taiga_update_document',
|
|
45
56
|
'taiga_update_factory_document',
|
|
46
57
|
];
|
|
@@ -87,6 +98,15 @@ function sentBody(fetchSpy: ReturnType<typeof vi.fn>, method: string, pathPart:
|
|
|
87
98
|
return JSON.parse((call![1] as RequestInit).body as string);
|
|
88
99
|
}
|
|
89
100
|
|
|
101
|
+
/** What the API answers when it refuses: the status and the error envelope its error handler sends. */
|
|
102
|
+
class Refusal {
|
|
103
|
+
constructor(
|
|
104
|
+
readonly status: number,
|
|
105
|
+
readonly body: { error: string; code: string; message: string }
|
|
106
|
+
) {}
|
|
107
|
+
}
|
|
108
|
+
const refusal = (status: number, code: string, message: string) => new Refusal(status, { error: code, code, message });
|
|
109
|
+
|
|
90
110
|
function makeFetchRouter(routes: Record<string, unknown>) {
|
|
91
111
|
return vi.fn().mockImplementation(async (url: string, init: RequestInit) => {
|
|
92
112
|
const { pathname: path, search } = new URL(url);
|
|
@@ -94,6 +114,17 @@ function makeFetchRouter(routes: Record<string, unknown>) {
|
|
|
94
114
|
// Most specific first: a route keyed with its query string (the two
|
|
95
115
|
// document timelines share a path) wins over one keyed by path alone.
|
|
96
116
|
const body = routes[`${method} ${path}${search}`] ?? routes[`${method} ${path}`] ?? routes[path];
|
|
117
|
+
// A route given as a function answers or throws like fetch would.
|
|
118
|
+
if (typeof body === 'function') return (body as () => unknown)();
|
|
119
|
+
// A refusal the API would send, with its status: `refusal(409, {...})`.
|
|
120
|
+
if (body instanceof Refusal) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
status: body.status,
|
|
124
|
+
json: () => Promise.resolve(body.body),
|
|
125
|
+
text: () => Promise.resolve(JSON.stringify(body.body)),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
97
128
|
if (body !== undefined) {
|
|
98
129
|
return {
|
|
99
130
|
ok: true,
|
|
@@ -325,13 +356,18 @@ describe('MCP server integration', () => {
|
|
|
325
356
|
'taiga_list_builds',
|
|
326
357
|
'taiga_get_build',
|
|
327
358
|
],
|
|
328
|
-
'backlog:read': ['taiga_list_backlog_items', 'taiga_get_backlog_item'],
|
|
359
|
+
'backlog:read': ['taiga_list_backlog_items', 'taiga_get_backlog_item', 'taiga_get_plan'],
|
|
329
360
|
'factories:read': ['taiga_list_factories', 'taiga_list_factory_documents', 'taiga_get_factory_document'],
|
|
330
361
|
'tenant:documents:read': ['taiga_list_documents', 'taiga_get_document'],
|
|
331
362
|
};
|
|
332
363
|
const PAIRED_CONTRACT: Record<string, string[]> = {
|
|
333
364
|
'tenant:documents:read tenant:documents:write': ['taiga_update_document', 'taiga_publish_document'],
|
|
334
365
|
'factories:read factories:documents:write': ['taiga_update_factory_document', 'taiga_publish_factory_document'],
|
|
366
|
+
'backlog:write': ['taiga_create_backlog_item', 'taiga_move_backlog_item'],
|
|
367
|
+
'backlog:write products:write': ['taiga_queue_backlog_item'],
|
|
368
|
+
'products:write': ['taiga_resume_backlog_item'],
|
|
369
|
+
'backlog:read products:write': ['taiga_approve_plan', 'taiga_reject_plan'],
|
|
370
|
+
'products:read products:write': ['taiga_stop_build', 'taiga_resume_build'],
|
|
335
371
|
};
|
|
336
372
|
|
|
337
373
|
for (const [capability, expectedTools] of [...Object.entries(CONTRACT), ...Object.entries(PAIRED_CONTRACT)]) {
|
|
@@ -2456,8 +2492,29 @@ describe('MCP server integration', () => {
|
|
|
2456
2492
|
const failure = await client.getPrompt({ name: 'taiga_start_session', arguments: {} }).catch((e: unknown) => e);
|
|
2457
2493
|
|
|
2458
2494
|
expect(failure).toMatchObject({ code: -32602 });
|
|
2495
|
+
// A slash command with no argument lands here; the message says how to pass one.
|
|
2459
2496
|
expect(String((failure as Error).message)).toMatch(
|
|
2460
|
-
/^MCP error -32602: VALIDATION_FAILED: Name the product exactly once
|
|
2497
|
+
/^MCP error -32602: VALIDATION_FAILED: Name the product exactly once: slug or productId or remoteUrl\. Pass the slug as the first argument \(\/taiga_start_session <slug>/
|
|
2498
|
+
);
|
|
2499
|
+
});
|
|
2500
|
+
|
|
2501
|
+
it('should take the slug as the first prompt argument, for a client that passes them positionally', async () => {
|
|
2502
|
+
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes });
|
|
2503
|
+
|
|
2504
|
+
const prompt = (await client.listPrompts()).prompts.find((p) => p.name === 'taiga_start_session')!;
|
|
2505
|
+
|
|
2506
|
+
expect(prompt.arguments!.map((a) => a.name)).toEqual(['slug', 'productId', 'remoteUrl']);
|
|
2507
|
+
});
|
|
2508
|
+
|
|
2509
|
+
it('should still refuse two names at once', async () => {
|
|
2510
|
+
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes });
|
|
2511
|
+
|
|
2512
|
+
const failure = await client
|
|
2513
|
+
.getPrompt({ name: 'taiga_start_session', arguments: { slug: 'web', productId: PRODUCT_ID } })
|
|
2514
|
+
.catch((e: unknown) => e);
|
|
2515
|
+
|
|
2516
|
+
expect(String((failure as Error).message)).toMatch(
|
|
2517
|
+
/Name the product exactly once: slug or productId or remoteUrl/
|
|
2461
2518
|
);
|
|
2462
2519
|
});
|
|
2463
2520
|
|
|
@@ -2557,6 +2614,612 @@ describe('MCP server integration', () => {
|
|
|
2557
2614
|
});
|
|
2558
2615
|
});
|
|
2559
2616
|
|
|
2617
|
+
describe('operating the line', () => {
|
|
2618
|
+
const PLAN_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60';
|
|
2619
|
+
const OTHER_ITEM = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a61';
|
|
2620
|
+
const lifecycle = { turn: 'you', stage: 'planned', cta: 'start_build', latestBuild: null };
|
|
2621
|
+
const item = {
|
|
2622
|
+
id: ITEM_ID,
|
|
2623
|
+
productId: PRODUCT_ID,
|
|
2624
|
+
title: 'Add login',
|
|
2625
|
+
description: 'A long brief the outcome must not echo',
|
|
2626
|
+
status: 'backlog',
|
|
2627
|
+
state: 'backlog',
|
|
2628
|
+
planningStatus: 'unplanned',
|
|
2629
|
+
needsHumanCause: null,
|
|
2630
|
+
needsHumanDetail: null,
|
|
2631
|
+
acceptanceCriteria: [{ criterion: 'Users can log in', met: false }],
|
|
2632
|
+
technicalContext: { patterns: ['hexagonal'] },
|
|
2633
|
+
lifecycle,
|
|
2634
|
+
};
|
|
2635
|
+
const queued = { ...item, state: 'queue', status: 'in_progress' };
|
|
2636
|
+
const plan = {
|
|
2637
|
+
id: PLAN_ID,
|
|
2638
|
+
backlogItemId: ITEM_ID,
|
|
2639
|
+
version: 1,
|
|
2640
|
+
status: 'active',
|
|
2641
|
+
steps: [{ id: 'step-1', planId: PLAN_ID, title: 'Add the route', writes: ['src/routes/login.ts'] }],
|
|
2642
|
+
};
|
|
2643
|
+
const run = {
|
|
2644
|
+
id: BUILD_ID,
|
|
2645
|
+
runNumber: 1,
|
|
2646
|
+
productId: PRODUCT_ID,
|
|
2647
|
+
backlogItemId: ITEM_ID,
|
|
2648
|
+
targetTitle: 'Add login',
|
|
2649
|
+
status: 'implementing',
|
|
2650
|
+
failedReason: null,
|
|
2651
|
+
completedSteps: 1,
|
|
2652
|
+
totalSteps: 3,
|
|
2653
|
+
prUrl: null,
|
|
2654
|
+
actions: ['stop'],
|
|
2655
|
+
error: 'noise the row must not carry',
|
|
2656
|
+
metrics: { tokens: 1 },
|
|
2657
|
+
};
|
|
2658
|
+
const outcomeOf = (result: Record<string, unknown>) => parseToolResult(result);
|
|
2659
|
+
|
|
2660
|
+
it('should mark every tool that starts or stops agent work destructive, and the create not', async () => {
|
|
2661
|
+
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
2662
|
+
|
|
2663
|
+
const { tools } = await client.listTools();
|
|
2664
|
+
const byName = Object.fromEntries(tools.map((t) => [t.name, t.annotations]));
|
|
2665
|
+
|
|
2666
|
+
for (const name of [
|
|
2667
|
+
'taiga_queue_backlog_item',
|
|
2668
|
+
'taiga_move_backlog_item',
|
|
2669
|
+
'taiga_resume_backlog_item',
|
|
2670
|
+
'taiga_approve_plan',
|
|
2671
|
+
'taiga_reject_plan',
|
|
2672
|
+
'taiga_stop_build',
|
|
2673
|
+
'taiga_resume_build',
|
|
2674
|
+
]) {
|
|
2675
|
+
expect(byName[name], name).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: false });
|
|
2676
|
+
}
|
|
2677
|
+
expect(byName.taiga_create_backlog_item).toMatchObject({ readOnlyHint: false, destructiveHint: false });
|
|
2678
|
+
expect(byName.taiga_get_plan).toMatchObject({ readOnlyHint: true });
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
it('should create an initiative in the product and answer with where it stands, not the brief', async () => {
|
|
2682
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
2683
|
+
capabilities: ALL_CAPABILITIES,
|
|
2684
|
+
routes: { [`POST /v1/products/${PRODUCT_ID}/backlog`]: { data: item } },
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
const result = await client.callTool({
|
|
2688
|
+
name: 'taiga_create_backlog_item',
|
|
2689
|
+
arguments: {
|
|
2690
|
+
productId: PRODUCT_ID,
|
|
2691
|
+
title: 'Add login',
|
|
2692
|
+
description: 'Users log in.',
|
|
2693
|
+
category: 'feature',
|
|
2694
|
+
priority: 2,
|
|
2695
|
+
},
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
expect(result.isError).toBeFalsy();
|
|
2699
|
+
// The route wants the product in the body as well as the path.
|
|
2700
|
+
expect(sentBody(fetchSpy, 'POST', `/products/${PRODUCT_ID}/backlog`)).toEqual({
|
|
2701
|
+
productId: PRODUCT_ID,
|
|
2702
|
+
title: 'Add login',
|
|
2703
|
+
description: 'Users log in.',
|
|
2704
|
+
category: 'feature',
|
|
2705
|
+
priority: 2,
|
|
2706
|
+
});
|
|
2707
|
+
const outcome = outcomeOf(result);
|
|
2708
|
+
expect(outcome).toMatchObject({ id: ITEM_ID, state: 'backlog', lifecycle });
|
|
2709
|
+
expect(outcome).not.toHaveProperty('description');
|
|
2710
|
+
expect(outcome).not.toHaveProperty('acceptanceCriteria');
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
it('should refuse a create with no title or a brief past the limit before any request', async () => {
|
|
2714
|
+
const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
2715
|
+
const before = fetchSpy.mock.calls.length;
|
|
2716
|
+
|
|
2717
|
+
for (const args of [
|
|
2718
|
+
{ productId: PRODUCT_ID, title: '', description: 'x' },
|
|
2719
|
+
{ productId: PRODUCT_ID, title: 'T', description: 'x'.repeat(20_001) },
|
|
2720
|
+
]) {
|
|
2721
|
+
const result = await client.callTool({ name: 'taiga_create_backlog_item', arguments: args });
|
|
2722
|
+
expect(result.isError).toBe(true);
|
|
2723
|
+
}
|
|
2724
|
+
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
2725
|
+
});
|
|
2726
|
+
|
|
2727
|
+
it('should queue an initiative with the move the web makes and answer with the refreshed item', async () => {
|
|
2728
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
2729
|
+
capabilities: ALL_CAPABILITIES,
|
|
2730
|
+
routes: { [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: queued } },
|
|
2731
|
+
});
|
|
2732
|
+
|
|
2733
|
+
const result = await client.callTool({ name: 'taiga_queue_backlog_item', arguments: { itemId: ITEM_ID } });
|
|
2734
|
+
|
|
2735
|
+
expect(result.isError).toBeFalsy();
|
|
2736
|
+
// One write, no read around it: the route answers with the item.
|
|
2737
|
+
expect(fetchSpy.mock.calls.filter((c: unknown[]) => (c[0] as string).includes('/backlog/'))).toHaveLength(1);
|
|
2738
|
+
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'queue' });
|
|
2739
|
+
expect(outcomeOf(result)).toMatchObject({ id: ITEM_ID, state: 'queue', status: 'in_progress' });
|
|
2740
|
+
expect(outcomeOf(result)).not.toHaveProperty('description');
|
|
2741
|
+
});
|
|
2742
|
+
|
|
2743
|
+
it('should place a queued initiative after another, or at the head with null', async () => {
|
|
2744
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
2745
|
+
capabilities: ALL_CAPABILITIES,
|
|
2746
|
+
routes: { [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: queued } },
|
|
2747
|
+
});
|
|
2748
|
+
|
|
2749
|
+
await client.callTool({ name: 'taiga_queue_backlog_item', arguments: { itemId: ITEM_ID, afterId: OTHER_ITEM } });
|
|
2750
|
+
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'queue', afterId: OTHER_ITEM });
|
|
2751
|
+
|
|
2752
|
+
fetchSpy.mockClear();
|
|
2753
|
+
vi.stubGlobal('fetch', fetchSpy);
|
|
2754
|
+
await client.callTool({ name: 'taiga_queue_backlog_item', arguments: { itemId: ITEM_ID, afterId: null } });
|
|
2755
|
+
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'queue', afterId: null });
|
|
2756
|
+
});
|
|
2757
|
+
|
|
2758
|
+
it("should hand the model the API's refusal when the initiative cannot move into the queue", async () => {
|
|
2759
|
+
const { client } = await bootMcpPair({
|
|
2760
|
+
capabilities: ALL_CAPABILITIES,
|
|
2761
|
+
routes: {
|
|
2762
|
+
[`PUT /v1/backlog/${ITEM_ID}/state`]: refusal(409, 'CONFLICT', 'Stop the running agent before re-planning'),
|
|
2763
|
+
},
|
|
2764
|
+
});
|
|
2765
|
+
|
|
2766
|
+
const result = await client.callTool({ name: 'taiga_queue_backlog_item', arguments: { itemId: ITEM_ID } });
|
|
2767
|
+
|
|
2768
|
+
expect(result.isError).toBe(true);
|
|
2769
|
+
expect(outcomeOf(result)).toEqual({ error: 'CONFLICT', message: 'Stop the running agent before re-planning' });
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
it('should report an item the key cannot see as a miss', async () => {
|
|
2773
|
+
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
2774
|
+
|
|
2775
|
+
const result = await client.callTool({ name: 'taiga_queue_backlog_item', arguments: { itemId: ITEM_ID } });
|
|
2776
|
+
|
|
2777
|
+
expect(result.isError).toBe(true);
|
|
2778
|
+
expect(outcomeOf(result).error).toBe('NOT_FOUND');
|
|
2779
|
+
});
|
|
2780
|
+
|
|
2781
|
+
it('should hide the queue from a key that may write the backlog but not start work, and the verdicts from one that cannot read the IDs they take', async () => {
|
|
2782
|
+
const backlogOnly = await bootMcpPair({ capabilities: ['backlog:write'] });
|
|
2783
|
+
const operatorOnly = await bootMcpPair({ capabilities: ['products:write'] });
|
|
2784
|
+
|
|
2785
|
+
expect(toolNames((await backlogOnly.client.listTools()).tools)).toEqual([
|
|
2786
|
+
'taiga_create_backlog_item',
|
|
2787
|
+
'taiga_move_backlog_item',
|
|
2788
|
+
]);
|
|
2789
|
+
// The resume takes an item ID a create or a queue hands back, so it needs no backlog read;
|
|
2790
|
+
// the verdicts take a plan ID only the plan read gives.
|
|
2791
|
+
expect(toolNames((await operatorOnly.client.listTools()).tools)).toEqual(['taiga_resume_backlog_item']);
|
|
2792
|
+
});
|
|
2793
|
+
|
|
2794
|
+
it('should move an initiative into plan with a full key, and refuse queue or plan for a key without products:write before any request', async () => {
|
|
2795
|
+
const full = await bootMcpPair({
|
|
2796
|
+
capabilities: ALL_CAPABILITIES,
|
|
2797
|
+
routes: {
|
|
2798
|
+
[`PUT /v1/backlog/${ITEM_ID}/state`]: { data: { ...item, state: 'plan', planningStatus: 'planning' } },
|
|
2799
|
+
},
|
|
2800
|
+
});
|
|
2801
|
+
const planned = await full.client.callTool({
|
|
2802
|
+
name: 'taiga_move_backlog_item',
|
|
2803
|
+
arguments: { itemId: ITEM_ID, state: 'plan' },
|
|
2804
|
+
});
|
|
2805
|
+
expect(planned.isError).toBeFalsy();
|
|
2806
|
+
expect(sentBody(full.fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'plan' });
|
|
2807
|
+
expect(outcomeOf(planned)).toMatchObject({ state: 'plan', planningStatus: 'planning' });
|
|
2808
|
+
|
|
2809
|
+
const narrow = await bootMcpPair({ capabilities: ['backlog:read', 'backlog:write'] });
|
|
2810
|
+
const before = narrow.fetchSpy.mock.calls.length;
|
|
2811
|
+
for (const state of ['queue', 'plan']) {
|
|
2812
|
+
const result = await narrow.client.callTool({
|
|
2813
|
+
name: 'taiga_move_backlog_item',
|
|
2814
|
+
arguments: { itemId: ITEM_ID, state },
|
|
2815
|
+
});
|
|
2816
|
+
expect(result.isError, state).toBe(true);
|
|
2817
|
+
expect(outcomeOf(result)).toEqual({
|
|
2818
|
+
error: 'FORBIDDEN',
|
|
2819
|
+
message: `A move into ${state} starts agent work and needs products:write, which this key lacks.`,
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
expect(narrow.fetchSpy.mock.calls.length).toBe(before);
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
it("should pass on the API's refusal of a create, a resume, a rejection and a run resume alike", async () => {
|
|
2826
|
+
const { client } = await bootMcpPair({
|
|
2827
|
+
capabilities: ALL_CAPABILITIES,
|
|
2828
|
+
routes: {
|
|
2829
|
+
[`POST /v1/products/${PRODUCT_ID}/backlog`]: refusal(400, 'VALIDATION_ERROR', 'title too long'),
|
|
2830
|
+
[`POST /v1/backlog/${ITEM_ID}/resume`]: refusal(
|
|
2831
|
+
409,
|
|
2832
|
+
'CONFLICT',
|
|
2833
|
+
'This initiative is not stopped, so there is nothing to resume.'
|
|
2834
|
+
),
|
|
2835
|
+
[`POST /v1/plans/${PLAN_ID}/reject`]: refusal(409, 'CONFLICT', 'This plan has been replaced'),
|
|
2836
|
+
[`POST /v1/builds/${BUILD_ID}/resume`]: refusal(409, 'CONFLICT', 'Build run is not resumable'),
|
|
2837
|
+
},
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
const calls = [
|
|
2841
|
+
[
|
|
2842
|
+
'taiga_create_backlog_item',
|
|
2843
|
+
{ productId: PRODUCT_ID, title: 'T', description: 'D' },
|
|
2844
|
+
'VALIDATION_ERROR',
|
|
2845
|
+
'title too long',
|
|
2846
|
+
],
|
|
2847
|
+
[
|
|
2848
|
+
'taiga_resume_backlog_item',
|
|
2849
|
+
{ itemId: ITEM_ID },
|
|
2850
|
+
'CONFLICT',
|
|
2851
|
+
'This initiative is not stopped, so there is nothing to resume.',
|
|
2852
|
+
],
|
|
2853
|
+
['taiga_reject_plan', { planId: PLAN_ID, comment: 'Again' }, 'CONFLICT', 'This plan has been replaced'],
|
|
2854
|
+
['taiga_resume_build', { buildId: BUILD_ID }, 'CONFLICT', 'Build run is not resumable'],
|
|
2855
|
+
] as const;
|
|
2856
|
+
for (const [name, args, error, message] of calls) {
|
|
2857
|
+
const result = await client.callTool({ name, arguments: { ...args } });
|
|
2858
|
+
expect(result.isError, name).toBe(true);
|
|
2859
|
+
expect(outcomeOf(result), name).toEqual({ error, message });
|
|
2860
|
+
}
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
it('should move an initiative on the board with the column and the neighbour to follow', async () => {
|
|
2864
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
2865
|
+
capabilities: ALL_CAPABILITIES,
|
|
2866
|
+
routes: { [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: { ...item, state: 'todo' } } },
|
|
2867
|
+
});
|
|
2868
|
+
|
|
2869
|
+
const result = await client.callTool({
|
|
2870
|
+
name: 'taiga_move_backlog_item',
|
|
2871
|
+
arguments: { itemId: ITEM_ID, state: 'todo', afterId: null },
|
|
2872
|
+
});
|
|
2873
|
+
|
|
2874
|
+
expect(result.isError).toBeFalsy();
|
|
2875
|
+
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'todo', afterId: null });
|
|
2876
|
+
expect(outcomeOf(result)).toMatchObject({ state: 'todo' });
|
|
2877
|
+
});
|
|
2878
|
+
|
|
2879
|
+
it('should offer exactly the columns a person may drop into, and refuse build', async () => {
|
|
2880
|
+
const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
2881
|
+
|
|
2882
|
+
const { tools } = await client.listTools();
|
|
2883
|
+
const move = tools.find((t) => t.name === 'taiga_move_backlog_item')!;
|
|
2884
|
+
const props = move.inputSchema.properties as Record<string, { enum?: string[] }>;
|
|
2885
|
+
expect(props.state!.enum).toEqual(['backlog', 'todo', 'queue', 'done', 'canceled', 'plan']);
|
|
2886
|
+
|
|
2887
|
+
const before = fetchSpy.mock.calls.length;
|
|
2888
|
+
const result = await client.callTool({
|
|
2889
|
+
name: 'taiga_move_backlog_item',
|
|
2890
|
+
arguments: { itemId: ITEM_ID, state: 'build' },
|
|
2891
|
+
});
|
|
2892
|
+
expect(result.isError).toBe(true);
|
|
2893
|
+
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
2894
|
+
});
|
|
2895
|
+
|
|
2896
|
+
it('should pass the API refusal through when scope membership forbids a move', async () => {
|
|
2897
|
+
const { client } = await bootMcpPair({
|
|
2898
|
+
capabilities: ALL_CAPABILITIES,
|
|
2899
|
+
routes: {
|
|
2900
|
+
[`PUT /v1/backlog/${ITEM_ID}/state`]: refusal(403, 'FORBIDDEN', 'Not a member of this product'),
|
|
2901
|
+
},
|
|
2902
|
+
});
|
|
2903
|
+
|
|
2904
|
+
const result = await client.callTool({
|
|
2905
|
+
name: 'taiga_move_backlog_item',
|
|
2906
|
+
arguments: { itemId: ITEM_ID, state: 'todo' },
|
|
2907
|
+
});
|
|
2908
|
+
|
|
2909
|
+
expect(result.isError).toBe(true);
|
|
2910
|
+
expect(outcomeOf(result)).toEqual({ error: 'FORBIDDEN', message: 'Not a member of this product' });
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2913
|
+
it('should resume an initiative the line stopped on and answer with the lifecycle', async () => {
|
|
2914
|
+
const resumed = { ...item, needsHumanCause: null, lifecycle: { ...lifecycle, stage: 'building', turn: 'agent' } };
|
|
2915
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
2916
|
+
capabilities: ALL_CAPABILITIES,
|
|
2917
|
+
routes: { [`POST /v1/backlog/${ITEM_ID}/resume`]: { data: resumed } },
|
|
2918
|
+
});
|
|
2919
|
+
|
|
2920
|
+
const result = await client.callTool({ name: 'taiga_resume_backlog_item', arguments: { itemId: ITEM_ID } });
|
|
2921
|
+
|
|
2922
|
+
expect(result.isError).toBeFalsy();
|
|
2923
|
+
const call = fetchSpy.mock.calls.find((c: unknown[]) => (c[0] as string).endsWith('/resume'))!;
|
|
2924
|
+
expect((call[1] as RequestInit).method).toBe('POST');
|
|
2925
|
+
expect((call[1] as RequestInit).body).toBeNull();
|
|
2926
|
+
expect(outcomeOf(result).lifecycle).toEqual(resumed.lifecycle);
|
|
2927
|
+
});
|
|
2928
|
+
|
|
2929
|
+
it('should read the active plan with its steps and say when there is none', async () => {
|
|
2930
|
+
const { client } = await bootMcpPair({
|
|
2931
|
+
capabilities: ['backlog:read'],
|
|
2932
|
+
routes: {
|
|
2933
|
+
[`GET /v1/backlog/${ITEM_ID}/plans/active`]: { data: plan },
|
|
2934
|
+
[`GET /v1/backlog/${OTHER_ITEM}/plans/active`]: refusal(404, 'NOT_FOUND', 'No active plan found'),
|
|
2935
|
+
},
|
|
2936
|
+
});
|
|
2937
|
+
|
|
2938
|
+
const found = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: ITEM_ID } });
|
|
2939
|
+
expect(outcomeOf(found)).toEqual(plan);
|
|
2940
|
+
|
|
2941
|
+
const none = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: OTHER_ITEM } });
|
|
2942
|
+
expect(none.isError).toBe(true);
|
|
2943
|
+
expect(outcomeOf(none)).toEqual({ error: 'NOT_FOUND', message: 'No active plan found' });
|
|
2944
|
+
});
|
|
2945
|
+
|
|
2946
|
+
it('should approve a plan by its ID and reject one with the comment', async () => {
|
|
2947
|
+
const building = { ...item, status: 'in_progress', state: 'build', planningStatus: 'planned' };
|
|
2948
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
2949
|
+
capabilities: ALL_CAPABILITIES,
|
|
2950
|
+
routes: {
|
|
2951
|
+
[`POST /v1/plans/${PLAN_ID}/approve`]: { data: building },
|
|
2952
|
+
[`POST /v1/plans/${PLAN_ID}/reject`]: { data: { ...item, planningStatus: 'planning' } },
|
|
2953
|
+
},
|
|
2954
|
+
});
|
|
2955
|
+
|
|
2956
|
+
const approved = await client.callTool({ name: 'taiga_approve_plan', arguments: { planId: PLAN_ID } });
|
|
2957
|
+
expect(approved.isError).toBeFalsy();
|
|
2958
|
+
expect(outcomeOf(approved)).toMatchObject({ state: 'build' });
|
|
2959
|
+
|
|
2960
|
+
const rejected = await client.callTool({
|
|
2961
|
+
name: 'taiga_reject_plan',
|
|
2962
|
+
arguments: { planId: PLAN_ID, comment: ' Split the migration into its own step. ' },
|
|
2963
|
+
});
|
|
2964
|
+
expect(rejected.isError).toBeFalsy();
|
|
2965
|
+
expect(sentBody(fetchSpy, 'POST', `/plans/${PLAN_ID}/reject`)).toEqual({
|
|
2966
|
+
comment: 'Split the migration into its own step.',
|
|
2967
|
+
});
|
|
2968
|
+
expect(outcomeOf(rejected)).toMatchObject({ planningStatus: 'planning' });
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
it('should refuse an empty rejection comment, or one past the limit, before any request', async () => {
|
|
2972
|
+
const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
2973
|
+
const before = fetchSpy.mock.calls.length;
|
|
2974
|
+
|
|
2975
|
+
for (const comment of [' ', 'x'.repeat(2001)]) {
|
|
2976
|
+
const result = await client.callTool({ name: 'taiga_reject_plan', arguments: { planId: PLAN_ID, comment } });
|
|
2977
|
+
expect(result.isError).toBe(true);
|
|
2978
|
+
}
|
|
2979
|
+
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
2980
|
+
});
|
|
2981
|
+
|
|
2982
|
+
it("should pass on the API's refusal of a plan that was replaced", async () => {
|
|
2983
|
+
const { client } = await bootMcpPair({
|
|
2984
|
+
capabilities: ALL_CAPABILITIES,
|
|
2985
|
+
routes: { [`POST /v1/plans/${PLAN_ID}/approve`]: refusal(409, 'CONFLICT', 'This plan has been replaced') },
|
|
2986
|
+
});
|
|
2987
|
+
|
|
2988
|
+
const result = await client.callTool({ name: 'taiga_approve_plan', arguments: { planId: PLAN_ID } });
|
|
2989
|
+
|
|
2990
|
+
expect(result.isError).toBe(true);
|
|
2991
|
+
expect(outcomeOf(result)).toEqual({ error: 'CONFLICT', message: 'This plan has been replaced' });
|
|
2992
|
+
});
|
|
2993
|
+
|
|
2994
|
+
it('should stop and resume a run and answer with its state and next actions, not its noise', async () => {
|
|
2995
|
+
const stopped = { ...run, status: 'stopped', failedReason: 'user_stopped', actions: ['resume', 'start_over'] };
|
|
2996
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
2997
|
+
capabilities: ['products:read', 'products:write'],
|
|
2998
|
+
routes: {
|
|
2999
|
+
[`POST /v1/builds/${BUILD_ID}/stop`]: { data: stopped },
|
|
3000
|
+
[`POST /v1/builds/${BUILD_ID}/resume`]: { data: { ...run, status: 'pending', actions: ['stop'] } },
|
|
3001
|
+
},
|
|
3002
|
+
});
|
|
3003
|
+
|
|
3004
|
+
const stop = await client.callTool({ name: 'taiga_stop_build', arguments: { buildId: BUILD_ID } });
|
|
3005
|
+
expect(stop.isError).toBeFalsy();
|
|
3006
|
+
expect(outcomeOf(stop)).toEqual({
|
|
3007
|
+
id: BUILD_ID,
|
|
3008
|
+
runNumber: 1,
|
|
3009
|
+
backlogItemId: ITEM_ID,
|
|
3010
|
+
status: 'stopped',
|
|
3011
|
+
failedReason: 'user_stopped',
|
|
3012
|
+
completedSteps: 1,
|
|
3013
|
+
totalSteps: 3,
|
|
3014
|
+
prUrl: null,
|
|
3015
|
+
actions: ['resume', 'start_over'],
|
|
3016
|
+
});
|
|
3017
|
+
const call = fetchSpy.mock.calls.find((c: unknown[]) => (c[0] as string).endsWith('/stop'))!;
|
|
3018
|
+
expect((call[1] as RequestInit).body).toBeNull();
|
|
3019
|
+
|
|
3020
|
+
const resume = await client.callTool({ name: 'taiga_resume_build', arguments: { buildId: BUILD_ID } });
|
|
3021
|
+
expect(resume.isError).toBeFalsy();
|
|
3022
|
+
expect(outcomeOf(resume)).toMatchObject({ status: 'pending', actions: ['stop'] });
|
|
3023
|
+
});
|
|
3024
|
+
|
|
3025
|
+
it("should pass on the API's refusal to stop a run that is not running", async () => {
|
|
3026
|
+
const { client } = await bootMcpPair({
|
|
3027
|
+
capabilities: ['products:read', 'products:write'],
|
|
3028
|
+
routes: {
|
|
3029
|
+
[`POST /v1/builds/${BUILD_ID}/stop`]: refusal(409, 'CONFLICT', 'Cannot stop a run that is completed'),
|
|
3030
|
+
},
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
const result = await client.callTool({ name: 'taiga_stop_build', arguments: { buildId: BUILD_ID } });
|
|
3034
|
+
|
|
3035
|
+
expect(result.isError).toBe(true);
|
|
3036
|
+
expect(outcomeOf(result)).toEqual({ error: 'CONFLICT', message: 'Cannot stop a run that is completed' });
|
|
3037
|
+
});
|
|
3038
|
+
|
|
3039
|
+
it('should list the runs of every product the key can see, each row naming its product', async () => {
|
|
3040
|
+
const other = { ...run, id: OTHER_ITEM, productId: PRODUCT_ID_2, status: 'completed', actions: [] };
|
|
3041
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
3042
|
+
capabilities: ['products:read'],
|
|
3043
|
+
routes: {
|
|
3044
|
+
'/v1/builds': { data: [run, other], nextCursor: 'next' },
|
|
3045
|
+
'/v1/products': {
|
|
3046
|
+
data: [
|
|
3047
|
+
{ id: PRODUCT_ID, name: 'Web', slug: 'web' },
|
|
3048
|
+
{ id: PRODUCT_ID_2, name: 'API', slug: 'api' },
|
|
3049
|
+
],
|
|
3050
|
+
nextCursor: null,
|
|
3051
|
+
},
|
|
3052
|
+
},
|
|
3053
|
+
});
|
|
3054
|
+
|
|
3055
|
+
const result = await client.callTool({ name: 'taiga_list_builds', arguments: { active: true, limit: 10 } });
|
|
3056
|
+
|
|
3057
|
+
expect(result.isError).toBeFalsy();
|
|
3058
|
+
const { data, nextCursor } = outcomeOf(result);
|
|
3059
|
+
expect(nextCursor).toBe('next');
|
|
3060
|
+
expect(data).toHaveLength(2);
|
|
3061
|
+
expect(data[0]).toMatchObject({
|
|
3062
|
+
id: BUILD_ID,
|
|
3063
|
+
targetTitle: 'Add login',
|
|
3064
|
+
actions: ['stop'],
|
|
3065
|
+
product: { name: 'Web', slug: 'web' },
|
|
3066
|
+
});
|
|
3067
|
+
expect(data[0]).not.toHaveProperty('error');
|
|
3068
|
+
expect(data[1].product).toEqual({ name: 'API', slug: 'api' });
|
|
3069
|
+
const url = sentUrl(fetchSpy, '/builds');
|
|
3070
|
+
expect(url.pathname).toBe('/v1/builds');
|
|
3071
|
+
expect(url.searchParams.get('active')).toBe('true');
|
|
3072
|
+
expect(url.searchParams.get('limit')).toBe('10');
|
|
3073
|
+
expect(sentUrl(fetchSpy, '/products').searchParams.get('limit')).toBe('100');
|
|
3074
|
+
});
|
|
3075
|
+
|
|
3076
|
+
it('should refuse backlogItemId without productId before any request rather than answer with every run', async () => {
|
|
3077
|
+
const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
|
|
3078
|
+
const before = fetchSpy.mock.calls.length;
|
|
3079
|
+
|
|
3080
|
+
const result = await client.callTool({ name: 'taiga_list_builds', arguments: { backlogItemId: ITEM_ID } });
|
|
3081
|
+
|
|
3082
|
+
expect(result.isError).toBe(true);
|
|
3083
|
+
expect(outcomeOf(result)).toMatchObject({ error: 'VALIDATION_FAILED' });
|
|
3084
|
+
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
3085
|
+
});
|
|
3086
|
+
|
|
3087
|
+
it('should say when only the first page of products could be named', async () => {
|
|
3088
|
+
const { client } = await bootMcpPair({
|
|
3089
|
+
capabilities: ['products:read'],
|
|
3090
|
+
routes: {
|
|
3091
|
+
'/v1/builds': { data: [{ ...run, productId: PRODUCT_ID_2 }], nextCursor: null },
|
|
3092
|
+
'/v1/products': { data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web' }], nextCursor: 'more' },
|
|
3093
|
+
},
|
|
3094
|
+
});
|
|
3095
|
+
|
|
3096
|
+
const result = await client.callTool({ name: 'taiga_list_builds', arguments: {} });
|
|
3097
|
+
|
|
3098
|
+
const { data, note } = outcomeOf(result);
|
|
3099
|
+
expect(data[0]).not.toHaveProperty('product');
|
|
3100
|
+
expect(note).toMatch(/Only the first 100 products could be named/);
|
|
3101
|
+
});
|
|
3102
|
+
|
|
3103
|
+
it('should say when an unnamed row belongs to a product the key cannot list, not that the page was cut', async () => {
|
|
3104
|
+
const { client } = await bootMcpPair({
|
|
3105
|
+
capabilities: ['products:read'],
|
|
3106
|
+
routes: {
|
|
3107
|
+
'/v1/builds': { data: [{ ...run, productId: PRODUCT_ID_2 }], nextCursor: null },
|
|
3108
|
+
'/v1/products': { data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web' }], nextCursor: null },
|
|
3109
|
+
},
|
|
3110
|
+
});
|
|
3111
|
+
|
|
3112
|
+
const { data, note } = outcomeOf(await client.callTool({ name: 'taiga_list_builds', arguments: {} }));
|
|
3113
|
+
|
|
3114
|
+
expect(data[0]).not.toHaveProperty('product');
|
|
3115
|
+
expect(note).toMatch(/cannot list \(deleted since, or not visible/);
|
|
3116
|
+
expect(note).not.toMatch(/first 100/);
|
|
3117
|
+
});
|
|
3118
|
+
|
|
3119
|
+
it('should carry no note when the products page was cut but every row still got a name', async () => {
|
|
3120
|
+
const { client } = await bootMcpPair({
|
|
3121
|
+
capabilities: ['products:read'],
|
|
3122
|
+
routes: {
|
|
3123
|
+
'/v1/builds': { data: [run], nextCursor: null },
|
|
3124
|
+
'/v1/products': { data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web' }], nextCursor: 'more' },
|
|
3125
|
+
},
|
|
3126
|
+
});
|
|
3127
|
+
|
|
3128
|
+
const result = outcomeOf(await client.callTool({ name: 'taiga_list_builds', arguments: {} }));
|
|
3129
|
+
|
|
3130
|
+
expect(result.data[0].product).toEqual({ name: 'Web', slug: 'web' });
|
|
3131
|
+
expect(result).not.toHaveProperty('note');
|
|
3132
|
+
});
|
|
3133
|
+
|
|
3134
|
+
it('should leave rows unnamed on a network failure of the products read, and surface a bug in it', async () => {
|
|
3135
|
+
const offline = await bootMcpPair({
|
|
3136
|
+
capabilities: ['products:read'],
|
|
3137
|
+
routes: {
|
|
3138
|
+
'/v1/builds': { data: [run], nextCursor: null },
|
|
3139
|
+
'/v1/products': () => {
|
|
3140
|
+
throw new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } });
|
|
3141
|
+
},
|
|
3142
|
+
},
|
|
3143
|
+
});
|
|
3144
|
+
const result = outcomeOf(await offline.client.callTool({ name: 'taiga_list_builds', arguments: {} }));
|
|
3145
|
+
expect(result.data[0]).not.toHaveProperty('product');
|
|
3146
|
+
expect(result.note).toMatch(/products could not be read/);
|
|
3147
|
+
|
|
3148
|
+
const buggy = await bootMcpPair({
|
|
3149
|
+
capabilities: ['products:read'],
|
|
3150
|
+
routes: {
|
|
3151
|
+
'/v1/builds': { data: [run], nextCursor: null },
|
|
3152
|
+
'/v1/products': () => {
|
|
3153
|
+
throw new Error('a bug in the products path');
|
|
3154
|
+
},
|
|
3155
|
+
},
|
|
3156
|
+
});
|
|
3157
|
+
const failure = await buggy.client.callTool({ name: 'taiga_list_builds', arguments: {} });
|
|
3158
|
+
expect(failure.isError).toBe(true);
|
|
3159
|
+
expect(JSON.stringify(failure.content)).toContain('a bug in the products path');
|
|
3160
|
+
expect(JSON.stringify(failure.content)).not.toContain('could not be read');
|
|
3161
|
+
});
|
|
3162
|
+
|
|
3163
|
+
it('should keep the org-wide rows when the products read fails, unnamed', async () => {
|
|
3164
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
3165
|
+
capabilities: ['products:read'],
|
|
3166
|
+
routes: {
|
|
3167
|
+
'/v1/builds': { data: [run], nextCursor: null },
|
|
3168
|
+
'/v1/products': refusal(500, 'INTERNAL', 'boom'),
|
|
3169
|
+
},
|
|
3170
|
+
});
|
|
3171
|
+
|
|
3172
|
+
const result = await client.callTool({ name: 'taiga_list_builds', arguments: {} });
|
|
3173
|
+
|
|
3174
|
+
expect(result.isError).toBeFalsy();
|
|
3175
|
+
const { data, note } = outcomeOf(result);
|
|
3176
|
+
expect(data[0]).toMatchObject({ id: BUILD_ID, productId: PRODUCT_ID });
|
|
3177
|
+
expect(data[0]).not.toHaveProperty('product');
|
|
3178
|
+
expect(note).toMatch(/products could not be read/);
|
|
3179
|
+
expect(sentUrl(fetchSpy, '/builds').search).toBe('');
|
|
3180
|
+
});
|
|
3181
|
+
|
|
3182
|
+
it('should carry no note when every product could be named', async () => {
|
|
3183
|
+
const { client } = await bootMcpPair({
|
|
3184
|
+
capabilities: ['products:read'],
|
|
3185
|
+
routes: {
|
|
3186
|
+
'/v1/builds': { data: [run], nextCursor: null },
|
|
3187
|
+
'/v1/products': { data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web' }], nextCursor: null },
|
|
3188
|
+
},
|
|
3189
|
+
});
|
|
3190
|
+
|
|
3191
|
+
expect(outcomeOf(await client.callTool({ name: 'taiga_list_builds', arguments: {} }))).not.toHaveProperty('note');
|
|
3192
|
+
});
|
|
3193
|
+
|
|
3194
|
+
it('should refuse active with productId before any request rather than drop the filter', async () => {
|
|
3195
|
+
const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
|
|
3196
|
+
const before = fetchSpy.mock.calls.length;
|
|
3197
|
+
|
|
3198
|
+
const result = await client.callTool({
|
|
3199
|
+
name: 'taiga_list_builds',
|
|
3200
|
+
arguments: { productId: PRODUCT_ID, active: true },
|
|
3201
|
+
});
|
|
3202
|
+
|
|
3203
|
+
expect(result.isError).toBe(true);
|
|
3204
|
+
expect(outcomeOf(result)).toMatchObject({ error: 'VALIDATION_FAILED' });
|
|
3205
|
+
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
3206
|
+
});
|
|
3207
|
+
|
|
3208
|
+
it('should keep the product list unread on the product route', async () => {
|
|
3209
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
3210
|
+
capabilities: ['products:read'],
|
|
3211
|
+
routes: { [`/v1/products/${PRODUCT_ID}/builds`]: { data: [run], nextCursor: null } },
|
|
3212
|
+
});
|
|
3213
|
+
|
|
3214
|
+
const result = await client.callTool({ name: 'taiga_list_builds', arguments: { productId: PRODUCT_ID } });
|
|
3215
|
+
|
|
3216
|
+
expect(result.isError).toBeFalsy();
|
|
3217
|
+
expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).endsWith('/v1/products?limit=100'))).toBe(
|
|
3218
|
+
false
|
|
3219
|
+
);
|
|
3220
|
+
});
|
|
3221
|
+
});
|
|
3222
|
+
|
|
2560
3223
|
describe('error handling', () => {
|
|
2561
3224
|
it('should surface API errors as structured MCP error content', async () => {
|
|
2562
3225
|
// Introspect succeeds, but all other endpoints fail
|