@taiga-ai/mcp-server 0.12.0-rc.419 → 0.13.0-dev.422
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 +350 -86
- package/package.json +1 -1
- package/src/client.ts +34 -21
- package/src/server.integration.test.ts +295 -92
- package/src/tools/annotations.ts +6 -0
- package/src/tools/backlogs.ts +118 -58
- package/src/tools/builds.ts +13 -29
- package/src/tools/index.ts +2 -0
- package/src/tools/instructions.ts +19 -13
- package/src/tools/jobs.ts +89 -0
- package/src/tools/queue.ts +61 -4
- package/src/tools/request-prompt.test.ts +32 -0
- package/src/tools/request-prompt.ts +28 -0
- package/src/tools/wait.ts +38 -0
- package/taiga-ai-mcp-server-0.13.0-dev.422.tgz +0 -0
- package/taiga-ai-mcp-server-0.12.0-rc.419.tgz +0 -0
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -297,6 +297,34 @@ export class TaigaClient {
|
|
|
297
297
|
);
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Stop or start the product's line. Stopping halts pulls and builds and
|
|
302
|
+
* stops the plan and build in flight; starting picks the interrupted work
|
|
303
|
+
* back up.
|
|
304
|
+
*/
|
|
305
|
+
async setLineStopped(productId: string, stopped: boolean) {
|
|
306
|
+
return this.request<{ data: unknown }>('PUT', `/products/${seg(productId)}/line`, { stopped });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Agent jobs
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
/** Ask an agent for work: the job is queued and runs in the background. */
|
|
314
|
+
async createAgentJob(body: {
|
|
315
|
+
scope: 'product' | 'tenant';
|
|
316
|
+
type: string;
|
|
317
|
+
targetId: string;
|
|
318
|
+
documentType?: string | undefined;
|
|
319
|
+
metadata?: Record<string, unknown> | undefined;
|
|
320
|
+
}) {
|
|
321
|
+
return this.request<{ data: unknown }>('POST', '/agent-jobs', body);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async getAgentJob(id: string, signal?: AbortSignal) {
|
|
325
|
+
return this.request<{ data: unknown }>('GET', `/agent-jobs/${seg(id)}`, undefined, { signal });
|
|
326
|
+
}
|
|
327
|
+
|
|
300
328
|
async getProductDocument(productId: string, type: string, origin?: string) {
|
|
301
329
|
const qs = origin ? `?${new URLSearchParams({ origin })}` : '';
|
|
302
330
|
return this.request<{ data: unknown }>('GET', `/products/${seg(productId)}/documents/${seg(type)}${qs}`);
|
|
@@ -340,26 +368,15 @@ export class TaigaClient {
|
|
|
340
368
|
return this.request<{ data: unknown }>('GET', `/backlog/${seg(itemId)}`, undefined, { signal });
|
|
341
369
|
}
|
|
342
370
|
|
|
343
|
-
async createBacklogItem(
|
|
344
|
-
productId: string,
|
|
345
|
-
body: {
|
|
346
|
-
title: string;
|
|
347
|
-
description: string;
|
|
348
|
-
category?: string | undefined;
|
|
349
|
-
priority?: number | undefined;
|
|
350
|
-
estimate?: number | undefined;
|
|
351
|
-
}
|
|
352
|
-
) {
|
|
353
|
-
// The route's body schema names the product too, as the web's
|
|
354
|
-
// CreateBacklogItemInput does (the live run found it; the handler then
|
|
355
|
-
// takes the one from the path). Dropping it is an API contract change.
|
|
356
|
-
return this.request<{ data: unknown }>('POST', `/products/${seg(productId)}/backlog`, { ...body, productId });
|
|
357
|
-
}
|
|
358
|
-
|
|
359
371
|
/** A person's move on the line board: the column dropped into, and what to follow there. */
|
|
360
372
|
async transitionInitiative(
|
|
361
373
|
itemId: string,
|
|
362
|
-
body: {
|
|
374
|
+
body: {
|
|
375
|
+
state: string;
|
|
376
|
+
afterId?: string | null | undefined;
|
|
377
|
+
archived?: boolean | undefined;
|
|
378
|
+
force?: boolean | undefined;
|
|
379
|
+
}
|
|
363
380
|
) {
|
|
364
381
|
return this.request<{ data: unknown }>('PUT', `/backlog/${seg(itemId)}/state`, body);
|
|
365
382
|
}
|
|
@@ -433,10 +450,6 @@ export class TaigaClient {
|
|
|
433
450
|
return this.request<{ data: unknown }>('POST', `/builds/${seg(id)}/stop`);
|
|
434
451
|
}
|
|
435
452
|
|
|
436
|
-
async resumeBuildRun(id: string) {
|
|
437
|
-
return this.request<{ data: unknown }>('POST', `/builds/${seg(id)}/resume`);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
453
|
async listBuildRunSteps(id: string) {
|
|
441
454
|
return this.request<{ data: unknown[] }>('GET', `/builds/${seg(id)}/steps`);
|
|
442
455
|
}
|
|
@@ -27,11 +27,11 @@ const ALL_CAPABILITIES = [
|
|
|
27
27
|
*/
|
|
28
28
|
const ALL_TOOL_NAMES = [
|
|
29
29
|
'taiga_approve_plan',
|
|
30
|
-
'taiga_create_backlog_item',
|
|
31
30
|
'taiga_get_backlog_item',
|
|
32
31
|
'taiga_get_build',
|
|
33
32
|
'taiga_get_document',
|
|
34
33
|
'taiga_get_factory_document',
|
|
34
|
+
'taiga_get_job',
|
|
35
35
|
'taiga_get_plan',
|
|
36
36
|
'taiga_get_product',
|
|
37
37
|
'taiga_get_product_context',
|
|
@@ -45,19 +45,22 @@ const ALL_TOOL_NAMES = [
|
|
|
45
45
|
'taiga_list_product_documents',
|
|
46
46
|
'taiga_list_products',
|
|
47
47
|
'taiga_move_backlog_item',
|
|
48
|
+
'taiga_pause_line',
|
|
49
|
+
'taiga_plan_again',
|
|
48
50
|
'taiga_publish_document',
|
|
49
51
|
'taiga_publish_factory_document',
|
|
50
52
|
'taiga_queue_backlog_item',
|
|
51
53
|
'taiga_reject_plan',
|
|
54
|
+
'taiga_request_initiatives',
|
|
52
55
|
'taiga_resolve_product',
|
|
53
56
|
'taiga_resume_backlog_item',
|
|
54
|
-
'taiga_resume_build',
|
|
55
57
|
'taiga_set_auto_build',
|
|
56
58
|
'taiga_stop_build',
|
|
57
59
|
'taiga_update_document',
|
|
58
60
|
'taiga_update_factory_document',
|
|
59
61
|
'taiga_wait_for_backlog_item',
|
|
60
62
|
'taiga_wait_for_build',
|
|
63
|
+
'taiga_wait_for_job',
|
|
61
64
|
];
|
|
62
65
|
|
|
63
66
|
const PRODUCT_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5c';
|
|
@@ -365,6 +368,8 @@ describe('MCP server integration', () => {
|
|
|
365
368
|
'taiga_get_build',
|
|
366
369
|
'taiga_get_queue',
|
|
367
370
|
'taiga_wait_for_build',
|
|
371
|
+
'taiga_get_job',
|
|
372
|
+
'taiga_wait_for_job',
|
|
368
373
|
],
|
|
369
374
|
'backlog:read': [
|
|
370
375
|
'taiga_list_backlog_items',
|
|
@@ -378,11 +383,12 @@ describe('MCP server integration', () => {
|
|
|
378
383
|
const PAIRED_CONTRACT: Record<string, string[]> = {
|
|
379
384
|
'tenant:documents:read tenant:documents:write': ['taiga_update_document', 'taiga_publish_document'],
|
|
380
385
|
'factories:read factories:documents:write': ['taiga_update_factory_document', 'taiga_publish_factory_document'],
|
|
381
|
-
'backlog:write': ['
|
|
386
|
+
'backlog:write': ['taiga_move_backlog_item'],
|
|
382
387
|
'backlog:write products:write': ['taiga_queue_backlog_item'],
|
|
383
|
-
'products:write': ['taiga_resume_backlog_item', 'taiga_set_auto_build'],
|
|
388
|
+
'products:write': ['taiga_resume_backlog_item', 'taiga_set_auto_build', 'taiga_request_initiatives'],
|
|
384
389
|
'backlog:read products:write': ['taiga_approve_plan', 'taiga_reject_plan'],
|
|
385
|
-
'
|
|
390
|
+
'backlog:read backlog:write products:write': ['taiga_plan_again'],
|
|
391
|
+
'products:read products:write': ['taiga_stop_build', 'taiga_pause_line'],
|
|
386
392
|
};
|
|
387
393
|
|
|
388
394
|
for (const [capability, expectedTools] of [...Object.entries(CONTRACT), ...Object.entries(PAIRED_CONTRACT)]) {
|
|
@@ -2745,7 +2751,7 @@ describe('MCP server integration', () => {
|
|
|
2745
2751
|
};
|
|
2746
2752
|
const outcomeOf = (result: Record<string, unknown>) => parseToolResult(result);
|
|
2747
2753
|
|
|
2748
|
-
it('should mark every tool that starts or stops agent work destructive, and the
|
|
2754
|
+
it('should mark every tool that starts or stops agent work destructive, and the reads not', async () => {
|
|
2749
2755
|
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
2750
2756
|
|
|
2751
2757
|
const { tools } = await client.listTools();
|
|
@@ -2758,59 +2764,177 @@ describe('MCP server integration', () => {
|
|
|
2758
2764
|
'taiga_approve_plan',
|
|
2759
2765
|
'taiga_reject_plan',
|
|
2760
2766
|
'taiga_stop_build',
|
|
2761
|
-
'
|
|
2762
|
-
'
|
|
2767
|
+
'taiga_request_initiatives',
|
|
2768
|
+
'taiga_plan_again',
|
|
2763
2769
|
]) {
|
|
2764
2770
|
expect(byName[name], name).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: false });
|
|
2765
2771
|
}
|
|
2766
|
-
|
|
2767
|
-
|
|
2772
|
+
// Settings: the same call twice leaves things as one call does.
|
|
2773
|
+
for (const name of ['taiga_set_auto_build', 'taiga_pause_line']) {
|
|
2774
|
+
expect(byName[name], name).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: true });
|
|
2775
|
+
}
|
|
2776
|
+
for (const name of ['taiga_get_plan', 'taiga_get_job', 'taiga_wait_for_job']) {
|
|
2777
|
+
expect(byName[name], name).toMatchObject({ readOnlyHint: true });
|
|
2778
|
+
}
|
|
2768
2779
|
});
|
|
2769
2780
|
|
|
2770
|
-
it('should
|
|
2781
|
+
it('should ask an agent for initiatives the way the web does, and answer with the job', async () => {
|
|
2782
|
+
const job = {
|
|
2783
|
+
id: 'job-1',
|
|
2784
|
+
type: 'generate_backlog',
|
|
2785
|
+
status: 'pending',
|
|
2786
|
+
targetId: PRODUCT_ID,
|
|
2787
|
+
result: null,
|
|
2788
|
+
metadata: {},
|
|
2789
|
+
};
|
|
2771
2790
|
const { client, fetchSpy } = await bootMcpPair({
|
|
2772
2791
|
capabilities: ALL_CAPABILITIES,
|
|
2773
|
-
routes: {
|
|
2792
|
+
routes: { 'POST /v1/agent-jobs': { data: job } },
|
|
2774
2793
|
});
|
|
2775
2794
|
|
|
2776
2795
|
const result = await client.callTool({
|
|
2777
|
-
name: '
|
|
2778
|
-
arguments: {
|
|
2779
|
-
productId: PRODUCT_ID,
|
|
2780
|
-
title: 'Add login',
|
|
2781
|
-
description: 'Users log in.',
|
|
2782
|
-
category: 'feature',
|
|
2783
|
-
priority: 2,
|
|
2784
|
-
},
|
|
2796
|
+
name: 'taiga_request_initiatives',
|
|
2797
|
+
arguments: { productId: PRODUCT_ID, title: ' Add login ', description: ' Users log in. ' },
|
|
2785
2798
|
});
|
|
2786
2799
|
|
|
2787
2800
|
expect(result.isError).toBeFalsy();
|
|
2788
|
-
// The
|
|
2789
|
-
expect(sentBody(fetchSpy, 'POST',
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
priority: 2,
|
|
2801
|
+
// The body generateBacklogJob.ts builds: title and description trimmed, joined by a blank line.
|
|
2802
|
+
expect(sentBody(fetchSpy, 'POST', '/agent-jobs')).toEqual({
|
|
2803
|
+
scope: 'product',
|
|
2804
|
+
type: 'generate_backlog',
|
|
2805
|
+
targetId: PRODUCT_ID,
|
|
2806
|
+
metadata: { userPrompt: 'Add login\n\nUsers log in.' },
|
|
2795
2807
|
});
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2808
|
+
expect(outcomeOf(result)).toEqual({
|
|
2809
|
+
id: 'job-1',
|
|
2810
|
+
type: 'generate_backlog',
|
|
2811
|
+
targetId: PRODUCT_ID,
|
|
2812
|
+
status: 'pending',
|
|
2813
|
+
});
|
|
2814
|
+
// Nothing writes an initiative directly: the agent does.
|
|
2815
|
+
expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).endsWith('/backlog'))).toBe(false);
|
|
2800
2816
|
});
|
|
2801
2817
|
|
|
2802
|
-
it('should
|
|
2803
|
-
const { client, fetchSpy } = await bootMcpPair({
|
|
2804
|
-
|
|
2818
|
+
it('should ask for a suggestion when the request is empty, sending no prompt at all', async () => {
|
|
2819
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
2820
|
+
capabilities: ALL_CAPABILITIES,
|
|
2821
|
+
routes: { 'POST /v1/agent-jobs': { data: { id: 'job-2', type: 'generate_backlog', status: 'pending' } } },
|
|
2822
|
+
});
|
|
2805
2823
|
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
}
|
|
2813
|
-
|
|
2824
|
+
await client.callTool({ name: 'taiga_request_initiatives', arguments: { productId: PRODUCT_ID, title: ' ' } });
|
|
2825
|
+
|
|
2826
|
+
expect(sentBody(fetchSpy, 'POST', '/agent-jobs')).toEqual({
|
|
2827
|
+
scope: 'product',
|
|
2828
|
+
type: 'generate_backlog',
|
|
2829
|
+
targetId: PRODUCT_ID,
|
|
2830
|
+
});
|
|
2831
|
+
});
|
|
2832
|
+
|
|
2833
|
+
it('should say what an initiative request came to: the initiatives it added, or why it added none', async () => {
|
|
2834
|
+
const JOB_1 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a71';
|
|
2835
|
+
const JOB_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a72';
|
|
2836
|
+
const reason = {
|
|
2837
|
+
policyCode: 'ARC',
|
|
2838
|
+
quotedSpan: 'Services must not share a database.',
|
|
2839
|
+
conflict: 'The request shares the orders database.',
|
|
2840
|
+
alternative: 'An API between the services.',
|
|
2841
|
+
overridePath: 'Record an exception with the architecture board.',
|
|
2842
|
+
};
|
|
2843
|
+
const { client } = await bootMcpPair({
|
|
2844
|
+
capabilities: ['products:read'],
|
|
2845
|
+
routes: {
|
|
2846
|
+
[`/v1/agent-jobs/${JOB_1}`]: {
|
|
2847
|
+
data: {
|
|
2848
|
+
id: JOB_1,
|
|
2849
|
+
type: 'generate_backlog',
|
|
2850
|
+
status: 'completed',
|
|
2851
|
+
metadata: { userPrompt: 'the request' },
|
|
2852
|
+
result: { createdInitiatives: [{ id: ITEM_ID, title: 'Add login' }], createdInitiativeIds: [ITEM_ID] },
|
|
2853
|
+
},
|
|
2854
|
+
},
|
|
2855
|
+
[`/v1/agent-jobs/${JOB_2}`]: {
|
|
2856
|
+
data: {
|
|
2857
|
+
id: JOB_2,
|
|
2858
|
+
type: 'generate_backlog',
|
|
2859
|
+
status: 'completed',
|
|
2860
|
+
result: {
|
|
2861
|
+
outcome: 'needs_decision',
|
|
2862
|
+
message: 'Your architecture policy does not allow this.',
|
|
2863
|
+
coveredByPriority: null,
|
|
2864
|
+
coveredById: null,
|
|
2865
|
+
coveredInitiative: null,
|
|
2866
|
+
reason,
|
|
2867
|
+
},
|
|
2868
|
+
},
|
|
2869
|
+
},
|
|
2870
|
+
},
|
|
2871
|
+
});
|
|
2872
|
+
|
|
2873
|
+
const added = outcomeOf(await client.callTool({ name: 'taiga_get_job', arguments: { jobId: JOB_1 } }));
|
|
2874
|
+
const refused = outcomeOf(await client.callTool({ name: 'taiga_get_job', arguments: { jobId: JOB_2 } }));
|
|
2875
|
+
|
|
2876
|
+
expect(added).toEqual({
|
|
2877
|
+
id: JOB_1,
|
|
2878
|
+
type: 'generate_backlog',
|
|
2879
|
+
status: 'completed',
|
|
2880
|
+
outcome: { outcome: 'created', initiatives: [{ id: ITEM_ID, title: 'Add login' }] },
|
|
2881
|
+
});
|
|
2882
|
+
expect(added).not.toHaveProperty('metadata');
|
|
2883
|
+
expect(refused.outcome).toEqual({
|
|
2884
|
+
outcome: 'needs_decision',
|
|
2885
|
+
message: 'Your architecture policy does not allow this.',
|
|
2886
|
+
coveredBy: null,
|
|
2887
|
+
policy: reason,
|
|
2888
|
+
});
|
|
2889
|
+
});
|
|
2890
|
+
|
|
2891
|
+
it('should read an older request that recorded only the ids of what it added', async () => {
|
|
2892
|
+
const JOB = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a74';
|
|
2893
|
+
const { client } = await bootMcpPair({
|
|
2894
|
+
capabilities: ['products:read'],
|
|
2895
|
+
routes: {
|
|
2896
|
+
[`/v1/agent-jobs/${JOB}`]: {
|
|
2897
|
+
data: {
|
|
2898
|
+
id: JOB,
|
|
2899
|
+
type: 'generate_backlog',
|
|
2900
|
+
status: 'completed',
|
|
2901
|
+
result: { createdInitiativeIds: [ITEM_ID] },
|
|
2902
|
+
},
|
|
2903
|
+
},
|
|
2904
|
+
},
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
const job = outcomeOf(await client.callTool({ name: 'taiga_get_job', arguments: { jobId: JOB } }));
|
|
2908
|
+
|
|
2909
|
+
expect(job.outcome).toEqual({ outcome: 'created', initiatives: [{ id: ITEM_ID }] });
|
|
2910
|
+
});
|
|
2911
|
+
|
|
2912
|
+
it('should wait for a request to finish and answer with what it came to', async () => {
|
|
2913
|
+
const JOB = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a73';
|
|
2914
|
+
const running = { id: JOB, type: 'generate_backlog', status: 'processing', progress: 40, result: null };
|
|
2915
|
+
const finished = {
|
|
2916
|
+
...running,
|
|
2917
|
+
status: 'completed',
|
|
2918
|
+
progress: 100,
|
|
2919
|
+
result: { createdInitiatives: [{ id: ITEM_ID, title: 'Add login' }] },
|
|
2920
|
+
};
|
|
2921
|
+
let call = 0;
|
|
2922
|
+
const { client } = await bootMcpPair({
|
|
2923
|
+
capabilities: ['products:read'],
|
|
2924
|
+
routes: {
|
|
2925
|
+
[`/v1/agent-jobs/${JOB}`]: () => {
|
|
2926
|
+
const body = { data: call++ < 2 ? running : finished };
|
|
2927
|
+
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
|
2928
|
+
},
|
|
2929
|
+
},
|
|
2930
|
+
});
|
|
2931
|
+
|
|
2932
|
+
const result = outcomeOf(await client.callTool({ name: 'taiga_wait_for_job', arguments: { jobId: JOB } }));
|
|
2933
|
+
|
|
2934
|
+
expect(result).toMatchObject({
|
|
2935
|
+
reached: true,
|
|
2936
|
+
job: { status: 'completed', outcome: { outcome: 'created', initiatives: [{ id: ITEM_ID }] } },
|
|
2937
|
+
});
|
|
2814
2938
|
});
|
|
2815
2939
|
|
|
2816
2940
|
it('should queue an initiative with the move the web makes and answer with the refreshed item', async () => {
|
|
@@ -2872,70 +2996,93 @@ describe('MCP server integration', () => {
|
|
|
2872
2996
|
const backlogOnly = await bootMcpPair({ capabilities: ['backlog:write'] });
|
|
2873
2997
|
const operatorOnly = await bootMcpPair({ capabilities: ['products:write'] });
|
|
2874
2998
|
|
|
2875
|
-
expect(toolNames((await backlogOnly.client.listTools()).tools)).toEqual([
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
]);
|
|
2879
|
-
// The resume and the review gate take an item ID a create or a queue hands back, so they need no
|
|
2880
|
-
// backlog read; the verdicts take a plan ID only the plan read gives.
|
|
2999
|
+
expect(toolNames((await backlogOnly.client.listTools()).tools)).toEqual(['taiga_move_backlog_item']);
|
|
3000
|
+
// A request, the resume and the review gate take a product or an item ID a request or a queue hands
|
|
3001
|
+
// back, so they need no backlog read; the verdicts and Plan again act on what only a read shows.
|
|
2881
3002
|
expect(toolNames((await operatorOnly.client.listTools()).tools)).toEqual([
|
|
3003
|
+
'taiga_request_initiatives',
|
|
2882
3004
|
'taiga_resume_backlog_item',
|
|
2883
3005
|
'taiga_set_auto_build',
|
|
2884
3006
|
]);
|
|
2885
3007
|
});
|
|
2886
3008
|
|
|
2887
|
-
it('should
|
|
2888
|
-
const
|
|
3009
|
+
it('should refuse a move into the queue for a key without products:write before any request', async () => {
|
|
3010
|
+
const narrow = await bootMcpPair({ capabilities: ['backlog:read', 'backlog:write'] });
|
|
3011
|
+
const before = narrow.fetchSpy.mock.calls.length;
|
|
3012
|
+
|
|
3013
|
+
const result = await narrow.client.callTool({
|
|
3014
|
+
name: 'taiga_move_backlog_item',
|
|
3015
|
+
arguments: { itemId: ITEM_ID, state: 'queue' },
|
|
3016
|
+
});
|
|
3017
|
+
|
|
3018
|
+
expect(result.isError).toBe(true);
|
|
3019
|
+
expect(outcomeOf(result)).toEqual({
|
|
3020
|
+
error: 'FORBIDDEN',
|
|
3021
|
+
message: 'A move into queue starts agent work and needs products:write, which this key lacks.',
|
|
3022
|
+
});
|
|
3023
|
+
expect(narrow.fetchSpy.mock.calls.length).toBe(before);
|
|
3024
|
+
});
|
|
3025
|
+
|
|
3026
|
+
it('should plan again only at the Build station, and point elsewhere at the queue', async () => {
|
|
3027
|
+
const atStation = { ...item, state: 'plan', planningStatus: 'failed' };
|
|
3028
|
+
const station = await bootMcpPair({
|
|
2889
3029
|
capabilities: ALL_CAPABILITIES,
|
|
2890
3030
|
routes: {
|
|
2891
|
-
[`
|
|
3031
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: atStation },
|
|
3032
|
+
[`PUT /v1/backlog/${ITEM_ID}/state`]: { data: { ...atStation, planningStatus: 'planning' } },
|
|
2892
3033
|
},
|
|
2893
3034
|
});
|
|
2894
|
-
const
|
|
2895
|
-
|
|
2896
|
-
|
|
3035
|
+
const replanned = await station.client.callTool({ name: 'taiga_plan_again', arguments: { itemId: ITEM_ID } });
|
|
3036
|
+
expect(replanned.isError).toBeFalsy();
|
|
3037
|
+
expect(sentBody(station.fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'plan' });
|
|
3038
|
+
expect(outcomeOf(replanned)).toMatchObject({ state: 'plan', planningStatus: 'planning' });
|
|
3039
|
+
|
|
3040
|
+
const idle = await bootMcpPair({
|
|
3041
|
+
capabilities: ALL_CAPABILITIES,
|
|
3042
|
+
routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: item } },
|
|
2897
3043
|
});
|
|
2898
|
-
|
|
2899
|
-
expect(
|
|
2900
|
-
expect(outcomeOf(
|
|
3044
|
+
const refused = await idle.client.callTool({ name: 'taiga_plan_again', arguments: { itemId: ITEM_ID } });
|
|
3045
|
+
expect(refused.isError).toBe(true);
|
|
3046
|
+
expect(outcomeOf(refused).message).toMatch(/not at the Build station\. The queue is the only way to start work/);
|
|
3047
|
+
// Refused on the read: no move was sent.
|
|
3048
|
+
expect(idle.fetchSpy.mock.calls.some((c: unknown[]) => (c[1] as RequestInit)?.method === 'PUT')).toBe(false);
|
|
3049
|
+
});
|
|
2901
3050
|
|
|
2902
|
-
|
|
2903
|
-
const
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
}
|
|
2915
|
-
expect(narrow.fetchSpy.mock.calls.length).toBe(before);
|
|
3051
|
+
it('should plan again past an advisory gate only when force says so', async () => {
|
|
3052
|
+
const atStation = { ...item, state: 'build', planningStatus: 'planned' };
|
|
3053
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
3054
|
+
capabilities: ALL_CAPABILITIES,
|
|
3055
|
+
routes: {
|
|
3056
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: atStation },
|
|
3057
|
+
[`PUT /v1/backlog/${ITEM_ID}/state`]: { data: { ...atStation, state: 'plan', planningStatus: 'planning' } },
|
|
3058
|
+
},
|
|
3059
|
+
});
|
|
3060
|
+
|
|
3061
|
+
await client.callTool({ name: 'taiga_plan_again', arguments: { itemId: ITEM_ID, force: true } });
|
|
3062
|
+
|
|
3063
|
+
expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'plan', force: true });
|
|
2916
3064
|
});
|
|
2917
3065
|
|
|
2918
|
-
it("should pass on the API's refusal of a
|
|
3066
|
+
it("should pass on the API's refusal of a request, a resume and a rejection alike", async () => {
|
|
2919
3067
|
const { client } = await bootMcpPair({
|
|
2920
3068
|
capabilities: ALL_CAPABILITIES,
|
|
2921
3069
|
routes: {
|
|
2922
|
-
|
|
3070
|
+
'POST /v1/agent-jobs': refusal(409, 'CONFLICT', 'Initiatives are already being generated for this product'),
|
|
2923
3071
|
[`POST /v1/backlog/${ITEM_ID}/resume`]: refusal(
|
|
2924
3072
|
409,
|
|
2925
3073
|
'CONFLICT',
|
|
2926
3074
|
'This initiative is not stopped, so there is nothing to resume.'
|
|
2927
3075
|
),
|
|
2928
3076
|
[`POST /v1/plans/${PLAN_ID}/reject`]: refusal(409, 'CONFLICT', 'This plan has been replaced'),
|
|
2929
|
-
[`POST /v1/builds/${BUILD_ID}/resume`]: refusal(409, 'CONFLICT', 'Build run is not resumable'),
|
|
2930
3077
|
},
|
|
2931
3078
|
});
|
|
2932
3079
|
|
|
2933
3080
|
const calls = [
|
|
2934
3081
|
[
|
|
2935
|
-
'
|
|
2936
|
-
{ productId: PRODUCT_ID, title: 'T'
|
|
2937
|
-
'
|
|
2938
|
-
'
|
|
3082
|
+
'taiga_request_initiatives',
|
|
3083
|
+
{ productId: PRODUCT_ID, title: 'T' },
|
|
3084
|
+
'CONFLICT',
|
|
3085
|
+
'Initiatives are already being generated for this product',
|
|
2939
3086
|
],
|
|
2940
3087
|
[
|
|
2941
3088
|
'taiga_resume_backlog_item',
|
|
@@ -2944,7 +3091,6 @@ describe('MCP server integration', () => {
|
|
|
2944
3091
|
'This initiative is not stopped, so there is nothing to resume.',
|
|
2945
3092
|
],
|
|
2946
3093
|
['taiga_reject_plan', { planId: PLAN_ID, comment: 'Again' }, 'CONFLICT', 'This plan has been replaced'],
|
|
2947
|
-
['taiga_resume_build', { buildId: BUILD_ID }, 'CONFLICT', 'Build run is not resumable'],
|
|
2948
3094
|
] as const;
|
|
2949
3095
|
for (const [name, args, error, message] of calls) {
|
|
2950
3096
|
const result = await client.callTool({ name, arguments: { ...args } });
|
|
@@ -3021,20 +3167,23 @@ describe('MCP server integration', () => {
|
|
|
3021
3167
|
});
|
|
3022
3168
|
});
|
|
3023
3169
|
|
|
3024
|
-
it('should offer exactly the columns a person may drop into, and refuse build', async () => {
|
|
3170
|
+
it('should offer exactly the columns a person may drop into, and refuse build and plan', async () => {
|
|
3025
3171
|
const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
3026
3172
|
|
|
3027
3173
|
const { tools } = await client.listTools();
|
|
3028
3174
|
const move = tools.find((t) => t.name === 'taiga_move_backlog_item')!;
|
|
3029
3175
|
const props = move.inputSchema.properties as Record<string, { enum?: string[] }>;
|
|
3030
|
-
|
|
3176
|
+
// The queue is the only way to start work: no "plan this now", nothing dropped into Build.
|
|
3177
|
+
expect(props.state!.enum).toEqual(['backlog', 'todo', 'queue', 'done', 'canceled']);
|
|
3031
3178
|
|
|
3032
3179
|
const before = fetchSpy.mock.calls.length;
|
|
3033
|
-
const
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3180
|
+
for (const state of ['build', 'plan']) {
|
|
3181
|
+
const result = await client.callTool({
|
|
3182
|
+
name: 'taiga_move_backlog_item',
|
|
3183
|
+
arguments: { itemId: ITEM_ID, state },
|
|
3184
|
+
});
|
|
3185
|
+
expect(result.isError, state).toBe(true);
|
|
3186
|
+
}
|
|
3038
3187
|
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
3039
3188
|
});
|
|
3040
3189
|
|
|
@@ -3161,13 +3310,12 @@ describe('MCP server integration', () => {
|
|
|
3161
3310
|
expect(outcomeOf(result)).toEqual({ error: 'CONFLICT', message: 'This plan has been replaced' });
|
|
3162
3311
|
});
|
|
3163
3312
|
|
|
3164
|
-
it('should stop
|
|
3313
|
+
it('should stop a run and answer with its state and next actions, not its noise', async () => {
|
|
3165
3314
|
const stopped = { ...run, status: 'stopped', failedReason: 'user_stopped', actions: ['resume', 'start_over'] };
|
|
3166
3315
|
const { client, fetchSpy } = await bootMcpPair({
|
|
3167
3316
|
capabilities: ['products:read', 'products:write'],
|
|
3168
3317
|
routes: {
|
|
3169
3318
|
[`POST /v1/builds/${BUILD_ID}/stop`]: { data: stopped },
|
|
3170
|
-
[`POST /v1/builds/${BUILD_ID}/resume`]: { data: { ...run, status: 'pending', actions: ['stop'] } },
|
|
3171
3319
|
},
|
|
3172
3320
|
});
|
|
3173
3321
|
|
|
@@ -3186,10 +3334,6 @@ describe('MCP server integration', () => {
|
|
|
3186
3334
|
});
|
|
3187
3335
|
const call = fetchSpy.mock.calls.find((c: unknown[]) => (c[0] as string).endsWith('/stop'))!;
|
|
3188
3336
|
expect((call[1] as RequestInit).body).toBeNull();
|
|
3189
|
-
|
|
3190
|
-
const resume = await client.callTool({ name: 'taiga_resume_build', arguments: { buildId: BUILD_ID } });
|
|
3191
|
-
expect(resume.isError).toBeFalsy();
|
|
3192
|
-
expect(outcomeOf(resume)).toMatchObject({ status: 'pending', actions: ['stop'] });
|
|
3193
3337
|
});
|
|
3194
3338
|
|
|
3195
3339
|
it("should pass on the API's refusal to stop a run that is not running", async () => {
|
|
@@ -3647,6 +3791,45 @@ describe('MCP server integration', () => {
|
|
|
3647
3791
|
);
|
|
3648
3792
|
});
|
|
3649
3793
|
|
|
3794
|
+
it('should pause and resume the line, and say in the queue read that a paused line waits for nothing else', async () => {
|
|
3795
|
+
const paused = { id: PRODUCT_ID, lineStoppedAt: '2026-10-08T20:00:00.000Z' };
|
|
3796
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
3797
|
+
capabilities: ['products:read', 'products:write', 'backlog:read'],
|
|
3798
|
+
routes: {
|
|
3799
|
+
[`PUT /v1/products/${PRODUCT_ID}/line`]: { data: paused },
|
|
3800
|
+
[`GET /v1/products/${PRODUCT_ID}`]: { data: paused },
|
|
3801
|
+
[`/v1/products/${PRODUCT_ID}/queue`]: { data: [entry(HEAD_ITEM, 'active')] },
|
|
3802
|
+
[`/v1/backlog/${HEAD_ITEM}`]: { data: head },
|
|
3803
|
+
},
|
|
3804
|
+
});
|
|
3805
|
+
|
|
3806
|
+
const pause = parseToolResult(
|
|
3807
|
+
await client.callTool({ name: 'taiga_pause_line', arguments: { productId: PRODUCT_ID, paused: true } })
|
|
3808
|
+
);
|
|
3809
|
+
expect(sentBody(fetchSpy, 'PUT', '/line')).toEqual({ stopped: true });
|
|
3810
|
+
expect(pause).toEqual({ productId: PRODUCT_ID, linePaused: true, lineStoppedAt: '2026-10-08T20:00:00.000Z' });
|
|
3811
|
+
|
|
3812
|
+
const queue = parseToolResult(
|
|
3813
|
+
await client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } })
|
|
3814
|
+
);
|
|
3815
|
+
expect(queue.linePaused).toBe(true);
|
|
3816
|
+
|
|
3817
|
+
// Resuming sends the other answer and reports the line running.
|
|
3818
|
+
fetchSpy.mockClear();
|
|
3819
|
+
fetchSpy.mockImplementation(
|
|
3820
|
+
makeFetchRouter({
|
|
3821
|
+
'/v1/auth/introspect': introspectionResponse(['products:read', 'products:write', 'backlog:read']),
|
|
3822
|
+
[`PUT /v1/products/${PRODUCT_ID}/line`]: { data: { id: PRODUCT_ID, lineStoppedAt: null } },
|
|
3823
|
+
}).getMockImplementation()!
|
|
3824
|
+
);
|
|
3825
|
+
const resumed = parseToolResult(
|
|
3826
|
+
await client.callTool({ name: 'taiga_pause_line', arguments: { productId: PRODUCT_ID, paused: false } })
|
|
3827
|
+
);
|
|
3828
|
+
expect(sentBody(fetchSpy, 'PUT', '/line')).toEqual({ stopped: false });
|
|
3829
|
+
expect(resumed).toEqual({ productId: PRODUCT_ID, linePaused: false, lineStoppedAt: null });
|
|
3830
|
+
expect(queue.waitingOn).toMatch(/^The line is paused \(since 2026-10-08T20:00:00.000Z\)/);
|
|
3831
|
+
});
|
|
3832
|
+
|
|
3650
3833
|
it('should keep the queue when an entry cannot be named, and say why entries carry ids only', async () => {
|
|
3651
3834
|
const queueRoute = { [`/v1/products/${PRODUCT_ID}/queue`]: { data: [entry(HEAD_ITEM, 'active')] } };
|
|
3652
3835
|
// One pair at a time: each boot stubs the global fetch the previous client reads through.
|
|
@@ -4110,6 +4293,26 @@ describe('MCP server integration', () => {
|
|
|
4110
4293
|
expect(productCalls).toBe(2);
|
|
4111
4294
|
});
|
|
4112
4295
|
|
|
4296
|
+
it("should wake a wait for any change when a landed plan's review gate is set, though the lifecycle reads the same", async () => {
|
|
4297
|
+
const landed = {
|
|
4298
|
+
...item,
|
|
4299
|
+
state: 'plan',
|
|
4300
|
+
planningStatus: 'planned',
|
|
4301
|
+
autoBuild: null,
|
|
4302
|
+
lifecycle: { turn: 'you', stage: 'planned', cta: 'start_build', latestBuild: null },
|
|
4303
|
+
};
|
|
4304
|
+
const { client } = await bootMcpPair({
|
|
4305
|
+
capabilities: ['backlog:read'],
|
|
4306
|
+
routes: { [`/v1/backlog/${ITEM_ID}`]: sequence(landed, landed, { ...landed, autoBuild: false }) },
|
|
4307
|
+
});
|
|
4308
|
+
|
|
4309
|
+
const result = parseToolResult(
|
|
4310
|
+
await client.callTool({ name: 'taiga_wait_for_backlog_item', arguments: { itemId: ITEM_ID, until: 'changed' } })
|
|
4311
|
+
);
|
|
4312
|
+
|
|
4313
|
+
expect(result).toMatchObject({ reached: true, item: { autoBuild: false } });
|
|
4314
|
+
});
|
|
4315
|
+
|
|
4113
4316
|
it('should answer reached: false at the timeout', async () => {
|
|
4114
4317
|
const { client } = await bootMcpPair({
|
|
4115
4318
|
capabilities: ['backlog:read'],
|
package/src/tools/annotations.ts
CHANGED
|
@@ -30,3 +30,9 @@ export const OPERATE = {
|
|
|
30
30
|
idempotentHint: false,
|
|
31
31
|
openWorldHint: false,
|
|
32
32
|
} as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An OPERATE write that sets a value rather than adding one: the same call
|
|
36
|
+
* twice leaves things as one call does (a review gate set, a line paused).
|
|
37
|
+
*/
|
|
38
|
+
export const OPERATE_SETTING = { ...OPERATE, idempotentHint: true } as const;
|