@taiga-ai/mcp-server 0.13.0-rc.424 → 0.14.0-dev.425
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 +1095 -13
- package/package.json +1 -1
- package/src/client.ts +313 -5
- package/src/server.integration.test.ts +899 -5
- package/src/tools/autonomy.ts +90 -0
- package/src/tools/catalogue.test.ts +3 -0
- package/src/tools/documents.ts +28 -15
- package/src/tools/inbox.ts +159 -0
- package/src/tools/index.ts +12 -0
- package/src/tools/instructions.ts +27 -0
- package/src/tools/knowledge.ts +257 -0
- package/src/tools/maintenance.ts +336 -0
- package/src/tools/observe.ts +241 -0
- package/src/tools/product-documents.ts +193 -0
- package/taiga-ai-mcp-server-0.14.0-dev.425.tgz +0 -0
- package/taiga-ai-mcp-server-0.13.0-rc.424.tgz +0 -0
|
@@ -19,6 +19,9 @@ const ALL_CAPABILITIES = [
|
|
|
19
19
|
'backlog:read',
|
|
20
20
|
'backlog:write',
|
|
21
21
|
'products:write',
|
|
22
|
+
'customDocuments:read',
|
|
23
|
+
'customDocuments:write',
|
|
24
|
+
'customDocuments:delete',
|
|
22
25
|
];
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -26,38 +29,66 @@ const ALL_CAPABILITIES = [
|
|
|
26
29
|
* sorted for deterministic comparison.
|
|
27
30
|
*/
|
|
28
31
|
const ALL_TOOL_NAMES = [
|
|
32
|
+
'taiga_add_knowledge',
|
|
29
33
|
'taiga_approve_plan',
|
|
34
|
+
'taiga_define_environment',
|
|
35
|
+
'taiga_delete_knowledge',
|
|
36
|
+
'taiga_fix_finding',
|
|
37
|
+
'taiga_fix_policy_violation',
|
|
38
|
+
'taiga_generate_product_documents',
|
|
30
39
|
'taiga_get_backlog_item',
|
|
31
40
|
'taiga_get_build',
|
|
41
|
+
'taiga_get_delivery_metrics',
|
|
42
|
+
'taiga_get_deployments',
|
|
32
43
|
'taiga_get_document',
|
|
33
44
|
'taiga_get_factory_document',
|
|
45
|
+
'taiga_get_finding',
|
|
34
46
|
'taiga_get_job',
|
|
47
|
+
'taiga_get_knowledge',
|
|
48
|
+
'taiga_get_maintenance',
|
|
49
|
+
'taiga_get_monitoring',
|
|
35
50
|
'taiga_get_plan',
|
|
51
|
+
'taiga_get_policy_standing',
|
|
36
52
|
'taiga_get_product',
|
|
37
53
|
'taiga_get_product_context',
|
|
38
54
|
'taiga_get_product_document',
|
|
39
55
|
'taiga_get_queue',
|
|
56
|
+
'taiga_ignore_finding',
|
|
57
|
+
'taiga_list_action_required',
|
|
40
58
|
'taiga_list_backlog_items',
|
|
41
59
|
'taiga_list_builds',
|
|
42
60
|
'taiga_list_documents',
|
|
43
61
|
'taiga_list_factories',
|
|
44
62
|
'taiga_list_factory_documents',
|
|
63
|
+
'taiga_list_findings',
|
|
64
|
+
'taiga_list_knowledge',
|
|
45
65
|
'taiga_list_product_documents',
|
|
46
66
|
'taiga_list_products',
|
|
67
|
+
'taiga_list_setup_tasks',
|
|
68
|
+
'taiga_mark_product_document_reviewed',
|
|
47
69
|
'taiga_move_backlog_item',
|
|
48
70
|
'taiga_pause_line',
|
|
49
71
|
'taiga_plan_again',
|
|
50
72
|
'taiga_publish_document',
|
|
51
73
|
'taiga_publish_factory_document',
|
|
74
|
+
'taiga_publish_product_document',
|
|
52
75
|
'taiga_queue_backlog_item',
|
|
76
|
+
'taiga_regenerate_product_document',
|
|
53
77
|
'taiga_reject_plan',
|
|
54
78
|
'taiga_request_initiatives',
|
|
79
|
+
'taiga_resolve_action',
|
|
55
80
|
'taiga_resolve_product',
|
|
56
81
|
'taiga_resume_backlog_item',
|
|
82
|
+
'taiga_retry_knowledge',
|
|
57
83
|
'taiga_set_auto_build',
|
|
84
|
+
'taiga_set_auto_merge',
|
|
85
|
+
'taiga_set_product_autonomy',
|
|
58
86
|
'taiga_stop_build',
|
|
87
|
+
'taiga_stop_generating_documents',
|
|
59
88
|
'taiga_update_document',
|
|
89
|
+
'taiga_update_environment',
|
|
60
90
|
'taiga_update_factory_document',
|
|
91
|
+
'taiga_update_product_document',
|
|
61
92
|
'taiga_wait_for_backlog_item',
|
|
62
93
|
'taiga_wait_for_build',
|
|
63
94
|
'taiga_wait_for_job',
|
|
@@ -370,6 +401,15 @@ describe('MCP server integration', () => {
|
|
|
370
401
|
'taiga_wait_for_build',
|
|
371
402
|
'taiga_get_job',
|
|
372
403
|
'taiga_wait_for_job',
|
|
404
|
+
'taiga_get_maintenance',
|
|
405
|
+
'taiga_list_findings',
|
|
406
|
+
'taiga_get_finding',
|
|
407
|
+
'taiga_get_policy_standing',
|
|
408
|
+
'taiga_list_action_required',
|
|
409
|
+
'taiga_list_setup_tasks',
|
|
410
|
+
'taiga_get_deployments',
|
|
411
|
+
'taiga_get_delivery_metrics',
|
|
412
|
+
'taiga_get_monitoring',
|
|
373
413
|
],
|
|
374
414
|
'backlog:read': [
|
|
375
415
|
'taiga_list_backlog_items',
|
|
@@ -379,16 +419,39 @@ describe('MCP server integration', () => {
|
|
|
379
419
|
],
|
|
380
420
|
'factories:read': ['taiga_list_factories', 'taiga_list_factory_documents', 'taiga_get_factory_document'],
|
|
381
421
|
'tenant:documents:read': ['taiga_list_documents', 'taiga_get_document'],
|
|
422
|
+
'customDocuments:read': ['taiga_list_knowledge', 'taiga_get_knowledge'],
|
|
382
423
|
};
|
|
383
424
|
const PAIRED_CONTRACT: Record<string, string[]> = {
|
|
384
425
|
'tenant:documents:read tenant:documents:write': ['taiga_update_document', 'taiga_publish_document'],
|
|
385
426
|
'factories:read factories:documents:write': ['taiga_update_factory_document', 'taiga_publish_factory_document'],
|
|
386
|
-
'backlog:write': ['taiga_move_backlog_item'],
|
|
427
|
+
'backlog:write': ['taiga_move_backlog_item', 'taiga_set_auto_merge'],
|
|
387
428
|
'backlog:write products:write': ['taiga_queue_backlog_item'],
|
|
388
|
-
'products:write': [
|
|
429
|
+
'products:write': [
|
|
430
|
+
'taiga_resume_backlog_item',
|
|
431
|
+
'taiga_set_auto_build',
|
|
432
|
+
'taiga_request_initiatives',
|
|
433
|
+
'taiga_set_product_autonomy',
|
|
434
|
+
'taiga_publish_product_document',
|
|
435
|
+
'taiga_mark_product_document_reviewed',
|
|
436
|
+
'taiga_generate_product_documents',
|
|
437
|
+
'taiga_stop_generating_documents',
|
|
438
|
+
],
|
|
389
439
|
'backlog:read products:write': ['taiga_approve_plan', 'taiga_reject_plan'],
|
|
390
440
|
'backlog:read backlog:write products:write': ['taiga_plan_again'],
|
|
391
|
-
'products:read products:write': [
|
|
441
|
+
'products:read products:write': [
|
|
442
|
+
'taiga_stop_build',
|
|
443
|
+
'taiga_pause_line',
|
|
444
|
+
'taiga_ignore_finding',
|
|
445
|
+
'taiga_fix_finding',
|
|
446
|
+
'taiga_fix_policy_violation',
|
|
447
|
+
'taiga_resolve_action',
|
|
448
|
+
'taiga_define_environment',
|
|
449
|
+
'taiga_update_environment',
|
|
450
|
+
'taiga_update_product_document',
|
|
451
|
+
'taiga_regenerate_product_document',
|
|
452
|
+
],
|
|
453
|
+
'customDocuments:read customDocuments:write': ['taiga_add_knowledge', 'taiga_retry_knowledge'],
|
|
454
|
+
'customDocuments:read customDocuments:write customDocuments:delete': ['taiga_delete_knowledge'],
|
|
392
455
|
};
|
|
393
456
|
|
|
394
457
|
for (const [capability, expectedTools] of [...Object.entries(CONTRACT), ...Object.entries(PAIRED_CONTRACT)]) {
|
|
@@ -2766,13 +2829,29 @@ describe('MCP server integration', () => {
|
|
|
2766
2829
|
'taiga_stop_build',
|
|
2767
2830
|
'taiga_request_initiatives',
|
|
2768
2831
|
'taiga_plan_again',
|
|
2832
|
+
'taiga_define_environment',
|
|
2833
|
+
'taiga_add_knowledge',
|
|
2834
|
+
'taiga_retry_knowledge',
|
|
2769
2835
|
]) {
|
|
2770
2836
|
expect(byName[name], name).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: false });
|
|
2771
2837
|
}
|
|
2772
2838
|
// Settings: the same call twice leaves things as one call does.
|
|
2773
|
-
for (const name of [
|
|
2839
|
+
for (const name of [
|
|
2840
|
+
'taiga_set_auto_build',
|
|
2841
|
+
'taiga_pause_line',
|
|
2842
|
+
'taiga_update_environment',
|
|
2843
|
+
'taiga_set_product_autonomy',
|
|
2844
|
+
'taiga_set_auto_merge',
|
|
2845
|
+
]) {
|
|
2774
2846
|
expect(byName[name], name).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: true });
|
|
2775
2847
|
}
|
|
2848
|
+
// A section id points at the read tool of its own scope, which lists the valid ids.
|
|
2849
|
+
const sectionHelp = (name: string) =>
|
|
2850
|
+
(tools.find((t) => t.name === name)!.inputSchema.properties as Record<string, { description?: string }>)
|
|
2851
|
+
.sectionId!.description;
|
|
2852
|
+
expect(sectionHelp('taiga_update_document')).toContain('taiga_get_document,');
|
|
2853
|
+
expect(sectionHelp('taiga_update_factory_document')).toContain('taiga_get_factory_document');
|
|
2854
|
+
expect(sectionHelp('taiga_update_product_document')).toContain('taiga_get_product_document');
|
|
2776
2855
|
for (const name of ['taiga_get_plan', 'taiga_get_job', 'taiga_wait_for_job']) {
|
|
2777
2856
|
expect(byName[name], name).toMatchObject({ readOnlyHint: true });
|
|
2778
2857
|
}
|
|
@@ -2996,13 +3075,21 @@ describe('MCP server integration', () => {
|
|
|
2996
3075
|
const backlogOnly = await bootMcpPair({ capabilities: ['backlog:write'] });
|
|
2997
3076
|
const operatorOnly = await bootMcpPair({ capabilities: ['products:write'] });
|
|
2998
3077
|
|
|
2999
|
-
expect(toolNames((await backlogOnly.client.listTools()).tools)).toEqual([
|
|
3078
|
+
expect(toolNames((await backlogOnly.client.listTools()).tools)).toEqual([
|
|
3079
|
+
'taiga_move_backlog_item',
|
|
3080
|
+
'taiga_set_auto_merge',
|
|
3081
|
+
]);
|
|
3000
3082
|
// A request, the resume and the review gate take a product or an item ID a request or a queue hands
|
|
3001
3083
|
// back, so they need no backlog read; the verdicts and Plan again act on what only a read shows.
|
|
3002
3084
|
expect(toolNames((await operatorOnly.client.listTools()).tools)).toEqual([
|
|
3085
|
+
'taiga_generate_product_documents',
|
|
3086
|
+
'taiga_mark_product_document_reviewed',
|
|
3087
|
+
'taiga_publish_product_document',
|
|
3003
3088
|
'taiga_request_initiatives',
|
|
3004
3089
|
'taiga_resume_backlog_item',
|
|
3005
3090
|
'taiga_set_auto_build',
|
|
3091
|
+
'taiga_set_product_autonomy',
|
|
3092
|
+
'taiga_stop_generating_documents',
|
|
3006
3093
|
]);
|
|
3007
3094
|
});
|
|
3008
3095
|
|
|
@@ -4659,6 +4746,813 @@ describe('MCP server integration', () => {
|
|
|
4659
4746
|
});
|
|
4660
4747
|
});
|
|
4661
4748
|
|
|
4749
|
+
describe('operating the whole product', () => {
|
|
4750
|
+
const FINDING_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b01';
|
|
4751
|
+
const ENV_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b02';
|
|
4752
|
+
const ENV_ID_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b0b';
|
|
4753
|
+
const KNOWLEDGE_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b03';
|
|
4754
|
+
const INBOX_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b04';
|
|
4755
|
+
const VIOLATION_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b05';
|
|
4756
|
+
const finding = {
|
|
4757
|
+
id: FINDING_ID,
|
|
4758
|
+
productId: PRODUCT_ID,
|
|
4759
|
+
kind: 'vulnerable_dependency',
|
|
4760
|
+
severity: 'high',
|
|
4761
|
+
tier: 'urgent',
|
|
4762
|
+
status: 'open',
|
|
4763
|
+
title: 'lodash 4.17.20: prototype pollution',
|
|
4764
|
+
ecosystem: 'npm',
|
|
4765
|
+
packageName: 'lodash',
|
|
4766
|
+
currentVersion: '4.17.20',
|
|
4767
|
+
recommendedVersion: '4.17.21',
|
|
4768
|
+
advisoryId: 'GHSA-xxxx',
|
|
4769
|
+
fingerprint: 'noise the row must not carry',
|
|
4770
|
+
summary: 'a long advisory summary the row must not carry',
|
|
4771
|
+
remediableBy: 'coding_agent',
|
|
4772
|
+
};
|
|
4773
|
+
const callsTo = (fetchSpy: ReturnType<typeof vi.fn>, method: string) =>
|
|
4774
|
+
fetchSpy.mock.calls
|
|
4775
|
+
.filter((c: unknown[]) => ((c[1] as RequestInit | undefined)?.method ?? 'GET') === method)
|
|
4776
|
+
.map((c: unknown[]) => new URL(c[0] as string));
|
|
4777
|
+
|
|
4778
|
+
it("should read a product's maintenance standing with its latest sweeps, and say when it was never swept", async () => {
|
|
4779
|
+
const summary = {
|
|
4780
|
+
productId: PRODUCT_ID,
|
|
4781
|
+
openFindings: 2,
|
|
4782
|
+
severityCounts: { high: 2 },
|
|
4783
|
+
kindCounts: {},
|
|
4784
|
+
topFindings: [],
|
|
4785
|
+
};
|
|
4786
|
+
const swept = await bootMcpPair({
|
|
4787
|
+
capabilities: ['products:read'],
|
|
4788
|
+
routes: {
|
|
4789
|
+
[`/v1/products/${PRODUCT_ID}/maintenance/summary`]: { data: summary },
|
|
4790
|
+
[`/v1/products/${PRODUCT_ID}/maintenance/runs`]: {
|
|
4791
|
+
data: [
|
|
4792
|
+
{ id: 'run-1', trigger: 'event', status: 'completed', scanners: { deps: 'passed' }, agentJobId: 'x' },
|
|
4793
|
+
],
|
|
4794
|
+
nextCursor: null,
|
|
4795
|
+
},
|
|
4796
|
+
},
|
|
4797
|
+
});
|
|
4798
|
+
const standing = parseToolResult(
|
|
4799
|
+
await swept.client.callTool({ name: 'taiga_get_maintenance', arguments: { productId: PRODUCT_ID } })
|
|
4800
|
+
);
|
|
4801
|
+
expect(standing).toMatchObject({ openFindings: 2, latestSweeps: [{ id: 'run-1', status: 'completed' }] });
|
|
4802
|
+
expect(standing.latestSweeps[0]).not.toHaveProperty('agentJobId');
|
|
4803
|
+
expect(standing).not.toHaveProperty('note');
|
|
4804
|
+
expect(sentUrl(swept.fetchSpy, '/maintenance/runs').searchParams.get('limit')).toBe('3');
|
|
4805
|
+
|
|
4806
|
+
const never = await bootMcpPair({
|
|
4807
|
+
capabilities: ['products:read'],
|
|
4808
|
+
routes: {
|
|
4809
|
+
[`/v1/products/${PRODUCT_ID}/maintenance/summary`]: { data: { ...summary, openFindings: 0 } },
|
|
4810
|
+
[`/v1/products/${PRODUCT_ID}/maintenance/runs`]: { data: [], nextCursor: null },
|
|
4811
|
+
},
|
|
4812
|
+
});
|
|
4813
|
+
const quiet = parseToolResult(
|
|
4814
|
+
await never.client.callTool({ name: 'taiga_get_maintenance', arguments: { productId: PRODUCT_ID } })
|
|
4815
|
+
);
|
|
4816
|
+
expect(quiet.note).toMatch(/never been swept/);
|
|
4817
|
+
});
|
|
4818
|
+
|
|
4819
|
+
it('should list findings as rows with the filters passed through', async () => {
|
|
4820
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
4821
|
+
capabilities: ['products:read'],
|
|
4822
|
+
routes: {
|
|
4823
|
+
[`/v1/products/${PRODUCT_ID}/maintenance/findings`]: { data: [finding], nextCursor: null, totalCount: 1 },
|
|
4824
|
+
},
|
|
4825
|
+
});
|
|
4826
|
+
|
|
4827
|
+
const result = parseToolResult(
|
|
4828
|
+
await client.callTool({
|
|
4829
|
+
name: 'taiga_list_findings',
|
|
4830
|
+
arguments: { productId: PRODUCT_ID, kind: 'vulnerable_dependency', severity: 'high' },
|
|
4831
|
+
})
|
|
4832
|
+
);
|
|
4833
|
+
|
|
4834
|
+
expect(result).toMatchObject({ totalCount: 1, nextCursor: null, data: [{ id: FINDING_ID, tier: 'urgent' }] });
|
|
4835
|
+
expect(result.data[0]).not.toHaveProperty('fingerprint');
|
|
4836
|
+
expect(result.data[0]).not.toHaveProperty('summary');
|
|
4837
|
+
const url = sentUrl(fetchSpy, '/maintenance/findings');
|
|
4838
|
+
expect(url.searchParams.get('kind')).toBe('vulnerable_dependency');
|
|
4839
|
+
expect(url.searchParams.get('severity')).toBe('high');
|
|
4840
|
+
expect(url.searchParams.has('status')).toBe(false);
|
|
4841
|
+
});
|
|
4842
|
+
|
|
4843
|
+
it('should ignore a finding with its reason, note and expiry, and reopen one', async () => {
|
|
4844
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
4845
|
+
capabilities: ['products:read', 'products:write'],
|
|
4846
|
+
routes: { [`PATCH /v1/maintenance/findings/${FINDING_ID}`]: { data: { ...finding, status: 'ignored' } } },
|
|
4847
|
+
});
|
|
4848
|
+
|
|
4849
|
+
await client.callTool({
|
|
4850
|
+
name: 'taiga_ignore_finding',
|
|
4851
|
+
arguments: {
|
|
4852
|
+
findingId: FINDING_ID,
|
|
4853
|
+
ignored: true,
|
|
4854
|
+
reason: 'not_reachable',
|
|
4855
|
+
note: 'The vulnerable function is never called.',
|
|
4856
|
+
expiresAt: '2099-01-01T00:00:00Z',
|
|
4857
|
+
},
|
|
4858
|
+
});
|
|
4859
|
+
expect(sentBody(fetchSpy, 'PATCH', '/maintenance/findings/')).toEqual({
|
|
4860
|
+
status: 'ignored',
|
|
4861
|
+
ignoreReason: 'not_reachable',
|
|
4862
|
+
ignoreNote: 'The vulnerable function is never called.',
|
|
4863
|
+
ignoreExpiresAt: '2099-01-01T00:00:00Z',
|
|
4864
|
+
});
|
|
4865
|
+
|
|
4866
|
+
fetchSpy.mockClear();
|
|
4867
|
+
vi.stubGlobal('fetch', fetchSpy);
|
|
4868
|
+
await client.callTool({ name: 'taiga_ignore_finding', arguments: { findingId: FINDING_ID, ignored: false } });
|
|
4869
|
+
expect(sentBody(fetchSpy, 'PATCH', '/maintenance/findings/')).toEqual({ status: 'open' });
|
|
4870
|
+
|
|
4871
|
+
// An ignore says why: without a reason it is refused before any request, as the API would.
|
|
4872
|
+
fetchSpy.mockClear();
|
|
4873
|
+
const unexplained = await client.callTool({
|
|
4874
|
+
name: 'taiga_ignore_finding',
|
|
4875
|
+
arguments: { findingId: FINDING_ID, ignored: true },
|
|
4876
|
+
});
|
|
4877
|
+
expect(unexplained.isError).toBe(true);
|
|
4878
|
+
expect(callsTo(fetchSpy, 'PATCH')).toHaveLength(0);
|
|
4879
|
+
});
|
|
4880
|
+
|
|
4881
|
+
it("should fix a finding, or its whole group with the finding's own package, version and repository", async () => {
|
|
4882
|
+
const repoId = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b06';
|
|
4883
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
4884
|
+
capabilities: ['products:read', 'products:write'],
|
|
4885
|
+
routes: {
|
|
4886
|
+
[`POST /v1/products/${PRODUCT_ID}/maintenance/findings/${FINDING_ID}/remediate`]: {
|
|
4887
|
+
data: { finding: { ...finding, status: 'remediating' }, backlogItemId: ITEM_ID },
|
|
4888
|
+
},
|
|
4889
|
+
[`GET /v1/maintenance/findings/${FINDING_ID}`]: { data: { ...finding, productRepositoryId: repoId } },
|
|
4890
|
+
[`POST /v1/products/${PRODUCT_ID}/maintenance/packages/remediate`]: {
|
|
4891
|
+
data: { backlogItemId: ITEM_ID, findingIds: [FINDING_ID] },
|
|
4892
|
+
},
|
|
4893
|
+
},
|
|
4894
|
+
});
|
|
4895
|
+
|
|
4896
|
+
const one = parseToolResult(
|
|
4897
|
+
await client.callTool({
|
|
4898
|
+
name: 'taiga_fix_finding',
|
|
4899
|
+
arguments: { productId: PRODUCT_ID, findingId: FINDING_ID },
|
|
4900
|
+
})
|
|
4901
|
+
);
|
|
4902
|
+
expect(one).toMatchObject({ backlogItemId: ITEM_ID, finding: { id: FINDING_ID, status: 'remediating' } });
|
|
4903
|
+
expect(one.finding).not.toHaveProperty('fingerprint');
|
|
4904
|
+
|
|
4905
|
+
const group = parseToolResult(
|
|
4906
|
+
await client.callTool({
|
|
4907
|
+
name: 'taiga_fix_finding',
|
|
4908
|
+
arguments: { productId: PRODUCT_ID, findingId: FINDING_ID, wholeGroup: true },
|
|
4909
|
+
})
|
|
4910
|
+
);
|
|
4911
|
+
// The route matches the group exactly: package, installed version and repository, all from the finding.
|
|
4912
|
+
expect(sentBody(fetchSpy, 'POST', '/packages/remediate')).toEqual({
|
|
4913
|
+
ecosystem: 'npm',
|
|
4914
|
+
packageName: 'lodash',
|
|
4915
|
+
currentVersion: '4.17.20',
|
|
4916
|
+
productRepositoryId: repoId,
|
|
4917
|
+
});
|
|
4918
|
+
expect(group).toEqual({ backlogItemId: ITEM_ID, findingIds: [FINDING_ID] });
|
|
4919
|
+
|
|
4920
|
+
// A finding of another product is not fixed as a group of this one.
|
|
4921
|
+
const before = callsTo(fetchSpy, 'POST').length;
|
|
4922
|
+
const elsewhere = await client.callTool({
|
|
4923
|
+
name: 'taiga_fix_finding',
|
|
4924
|
+
arguments: { productId: PRODUCT_ID_2, findingId: FINDING_ID, wholeGroup: true },
|
|
4925
|
+
});
|
|
4926
|
+
expect(elsewhere.isError).toBe(true);
|
|
4927
|
+
expect(parseToolResult(elsewhere).error).toBe('NOT_FOUND');
|
|
4928
|
+
expect(callsTo(fetchSpy, 'POST')).toHaveLength(before);
|
|
4929
|
+
});
|
|
4930
|
+
|
|
4931
|
+
it('should report the controls something is recorded against, and the violations without their evidence unless asked', async () => {
|
|
4932
|
+
const standing = {
|
|
4933
|
+
productId: PRODUCT_ID,
|
|
4934
|
+
hasPolicies: true,
|
|
4935
|
+
canAssess: true,
|
|
4936
|
+
controls: [
|
|
4937
|
+
{
|
|
4938
|
+
controlId: 'SEC-3.4',
|
|
4939
|
+
policyType: 'policy_security',
|
|
4940
|
+
text: 'Encrypt at rest',
|
|
4941
|
+
state: 'diverged',
|
|
4942
|
+
findingCount: 0,
|
|
4943
|
+
divergenceCount: 1,
|
|
4944
|
+
plannedCount: 0,
|
|
4945
|
+
workItems: [],
|
|
4946
|
+
},
|
|
4947
|
+
{
|
|
4948
|
+
controlId: 'ARC-1.1',
|
|
4949
|
+
policyType: 'policy_architecture',
|
|
4950
|
+
text: 'Quiet',
|
|
4951
|
+
state: 'no_evidence',
|
|
4952
|
+
findingCount: 0,
|
|
4953
|
+
divergenceCount: 0,
|
|
4954
|
+
plannedCount: 0,
|
|
4955
|
+
workItems: [],
|
|
4956
|
+
},
|
|
4957
|
+
],
|
|
4958
|
+
divergences: [
|
|
4959
|
+
{
|
|
4960
|
+
id: VIOLATION_ID,
|
|
4961
|
+
controlId: 'SEC-3.4',
|
|
4962
|
+
severity: 'high',
|
|
4963
|
+
status: 'open',
|
|
4964
|
+
title: 'Plaintext',
|
|
4965
|
+
summary: 's',
|
|
4966
|
+
evidence: 'long evidence',
|
|
4967
|
+
policyExcerpt: 'excerpt',
|
|
4968
|
+
work: null,
|
|
4969
|
+
},
|
|
4970
|
+
],
|
|
4971
|
+
resolvedDivergences: [{ id: 'r1' }, { id: 'r2' }],
|
|
4972
|
+
assessment: { state: 'complete' },
|
|
4973
|
+
documents: { commit: 'abc' },
|
|
4974
|
+
};
|
|
4975
|
+
const { client } = await bootMcpPair({
|
|
4976
|
+
capabilities: ['products:read'],
|
|
4977
|
+
routes: { [`/v1/products/${PRODUCT_ID}/maintenance/policies`]: { data: standing } },
|
|
4978
|
+
});
|
|
4979
|
+
|
|
4980
|
+
const summary = parseToolResult(
|
|
4981
|
+
await client.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID } })
|
|
4982
|
+
);
|
|
4983
|
+
const full = parseToolResult(
|
|
4984
|
+
await client.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID, full: true } })
|
|
4985
|
+
);
|
|
4986
|
+
|
|
4987
|
+
expect(summary.controls.map((c: { controlId: string }) => c.controlId)).toEqual(['SEC-3.4']);
|
|
4988
|
+
expect(summary.violations[0]).toMatchObject({ id: VIOLATION_ID, title: 'Plaintext' });
|
|
4989
|
+
expect(summary.violations[0]).not.toHaveProperty('evidence');
|
|
4990
|
+
expect(summary.resolvedViolations).toBe(2);
|
|
4991
|
+
expect(full.divergences[0].evidence).toBe('long evidence');
|
|
4992
|
+
});
|
|
4993
|
+
|
|
4994
|
+
it('should fix a control or one violation through its own route', async () => {
|
|
4995
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
4996
|
+
capabilities: ['products:read', 'products:write'],
|
|
4997
|
+
routes: {
|
|
4998
|
+
[`POST /v1/products/${PRODUCT_ID}/maintenance/policies/controls/SEC-3.4/remediate`]: {
|
|
4999
|
+
data: { backlogItemId: ITEM_ID, divergenceIds: [VIOLATION_ID], created: true },
|
|
5000
|
+
},
|
|
5001
|
+
[`POST /v1/products/${PRODUCT_ID}/maintenance/policies/divergences/${VIOLATION_ID}/remediate`]: {
|
|
5002
|
+
data: { backlogItemId: ITEM_ID, divergenceIds: [VIOLATION_ID], created: true },
|
|
5003
|
+
},
|
|
5004
|
+
},
|
|
5005
|
+
});
|
|
5006
|
+
|
|
5007
|
+
const control = parseToolResult(
|
|
5008
|
+
await client.callTool({
|
|
5009
|
+
name: 'taiga_fix_policy_violation',
|
|
5010
|
+
arguments: { productId: PRODUCT_ID, controlId: 'SEC-3.4' },
|
|
5011
|
+
})
|
|
5012
|
+
);
|
|
5013
|
+
await client.callTool({
|
|
5014
|
+
name: 'taiga_fix_policy_violation',
|
|
5015
|
+
arguments: { productId: PRODUCT_ID, violationId: VIOLATION_ID },
|
|
5016
|
+
});
|
|
5017
|
+
const both = await client.callTool({
|
|
5018
|
+
name: 'taiga_fix_policy_violation',
|
|
5019
|
+
arguments: { productId: PRODUCT_ID, controlId: 'SEC-3.4', violationId: VIOLATION_ID },
|
|
5020
|
+
});
|
|
5021
|
+
|
|
5022
|
+
expect(control).toEqual({ backlogItemId: ITEM_ID, divergenceIds: [VIOLATION_ID], created: true });
|
|
5023
|
+
expect(callsTo(fetchSpy, 'POST').map((u) => u.pathname)).toEqual([
|
|
5024
|
+
`/v1/products/${PRODUCT_ID}/maintenance/policies/controls/SEC-3.4/remediate`,
|
|
5025
|
+
`/v1/products/${PRODUCT_ID}/maintenance/policies/divergences/${VIOLATION_ID}/remediate`,
|
|
5026
|
+
]);
|
|
5027
|
+
expect(both.isError).toBe(true);
|
|
5028
|
+
});
|
|
5029
|
+
|
|
5030
|
+
it('should read Action Required across products or for one, and resolve or snooze an item', async () => {
|
|
5031
|
+
const inboxItem = {
|
|
5032
|
+
id: INBOX_ID,
|
|
5033
|
+
productId: PRODUCT_ID,
|
|
5034
|
+
category: 'gate',
|
|
5035
|
+
kind: 'review_pr',
|
|
5036
|
+
title: 'Review PR #25',
|
|
5037
|
+
status: 'open',
|
|
5038
|
+
createdBy: 'u',
|
|
5039
|
+
};
|
|
5040
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5041
|
+
capabilities: ['products:read', 'products:write'],
|
|
5042
|
+
routes: {
|
|
5043
|
+
'/v1/inbox': { data: [inboxItem], nextCursor: null },
|
|
5044
|
+
[`/v1/products/${PRODUCT_ID}/inbox`]: {
|
|
5045
|
+
data: [
|
|
5046
|
+
inboxItem,
|
|
5047
|
+
{ ...inboxItem, id: 'later', status: 'snoozed', snoozedUntil: '2999-01-01T00:00:00.000Z' },
|
|
5048
|
+
{ ...inboxItem, id: 'woken', status: 'snoozed', snoozedUntil: '2020-01-01T00:00:00.000Z' },
|
|
5049
|
+
],
|
|
5050
|
+
nextCursor: null,
|
|
5051
|
+
},
|
|
5052
|
+
[`PATCH /v1/inbox/${INBOX_ID}`]: { data: { ...inboxItem, status: 'done' } },
|
|
5053
|
+
},
|
|
5054
|
+
});
|
|
5055
|
+
|
|
5056
|
+
const all = parseToolResult(
|
|
5057
|
+
await client.callTool({ name: 'taiga_list_action_required', arguments: { category: 'gate', resolved: true } })
|
|
5058
|
+
);
|
|
5059
|
+
expect(all.data[0]).toMatchObject({ id: INBOX_ID, kind: 'review_pr' });
|
|
5060
|
+
expect(all.data[0]).not.toHaveProperty('createdBy');
|
|
5061
|
+
const across = sentUrl(fetchSpy, '/v1/inbox');
|
|
5062
|
+
expect(across.searchParams.get('category')).toBe('gate');
|
|
5063
|
+
expect(across.searchParams.get('resolved')).toBe('true');
|
|
5064
|
+
|
|
5065
|
+
const open = parseToolResult(
|
|
5066
|
+
await client.callTool({
|
|
5067
|
+
name: 'taiga_list_action_required',
|
|
5068
|
+
arguments: { productId: PRODUCT_ID, resolved: false },
|
|
5069
|
+
})
|
|
5070
|
+
);
|
|
5071
|
+
// Snoozed until later is left out until then, as the page leaves it out; a lapsed snooze is due again.
|
|
5072
|
+
expect(open.data.map((item: { id: string }) => item.id)).toEqual([INBOX_ID, 'woken']);
|
|
5073
|
+
expect(open.snoozedHidden).toBe(1);
|
|
5074
|
+
expect(all).not.toHaveProperty('snoozedHidden');
|
|
5075
|
+
const product = callsTo(fetchSpy, 'GET').find((u) => u.pathname === `/v1/products/${PRODUCT_ID}/inbox`)!;
|
|
5076
|
+
// The route coerces any value to true, so false is not sent at all.
|
|
5077
|
+
expect(product.searchParams.has('resolved')).toBe(false);
|
|
5078
|
+
|
|
5079
|
+
const done = parseToolResult(
|
|
5080
|
+
await client.callTool({ name: 'taiga_resolve_action', arguments: { itemId: INBOX_ID, status: 'done' } })
|
|
5081
|
+
);
|
|
5082
|
+
expect(done.status).toBe('done');
|
|
5083
|
+
expect(sentBody(fetchSpy, 'PATCH', '/inbox/')).toEqual({ status: 'done' });
|
|
5084
|
+
|
|
5085
|
+
fetchSpy.mockClear();
|
|
5086
|
+
vi.stubGlobal('fetch', fetchSpy);
|
|
5087
|
+
await client.callTool({ name: 'taiga_resolve_action', arguments: { itemId: INBOX_ID, snoozedUntil: null } });
|
|
5088
|
+
expect(sentBody(fetchSpy, 'PATCH', '/inbox/')).toEqual({ snoozedUntil: null });
|
|
5089
|
+
|
|
5090
|
+
const nothing = await client.callTool({ name: 'taiga_resolve_action', arguments: { itemId: INBOX_ID } });
|
|
5091
|
+
expect(nothing.isError).toBe(true);
|
|
5092
|
+
});
|
|
5093
|
+
|
|
5094
|
+
it('should read what runs where, and keep the answer when only the sync status cannot be read', async () => {
|
|
5095
|
+
const deployment = {
|
|
5096
|
+
id: 'd1',
|
|
5097
|
+
environmentId: ENV_ID,
|
|
5098
|
+
status: 'active',
|
|
5099
|
+
commitSha: 'abc',
|
|
5100
|
+
commitSubject: 'Merge #1',
|
|
5101
|
+
artifacts: {},
|
|
5102
|
+
metadata: { big: true },
|
|
5103
|
+
};
|
|
5104
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5105
|
+
capabilities: ['products:read'],
|
|
5106
|
+
routes: {
|
|
5107
|
+
// Two pages: every environment is read, not the first page's.
|
|
5108
|
+
[`/v1/products/${PRODUCT_ID}/environments`]: (url: string) =>
|
|
5109
|
+
new Response(
|
|
5110
|
+
JSON.stringify(
|
|
5111
|
+
new URL(url).searchParams.get('cursor') === 'p2'
|
|
5112
|
+
? { data: [{ id: ENV_ID_2, name: 'staging', slug: 'staging', type: 'staging' }], nextCursor: null }
|
|
5113
|
+
: {
|
|
5114
|
+
data: [
|
|
5115
|
+
{
|
|
5116
|
+
id: ENV_ID,
|
|
5117
|
+
name: 'production',
|
|
5118
|
+
slug: 'prod',
|
|
5119
|
+
type: 'production',
|
|
5120
|
+
deployConfig: {},
|
|
5121
|
+
url: 'https://app.example.com',
|
|
5122
|
+
},
|
|
5123
|
+
],
|
|
5124
|
+
nextCursor: 'p2',
|
|
5125
|
+
}
|
|
5126
|
+
)
|
|
5127
|
+
),
|
|
5128
|
+
[`/v1/products/${PRODUCT_ID}/deployments/current`]: { data: { current: [deployment], deploying: [] } },
|
|
5129
|
+
[`/v1/products/${PRODUCT_ID}/deployments`]: { data: [deployment], nextCursor: 'more' },
|
|
5130
|
+
[`/v1/products/${PRODUCT_ID}/deployments/sync-status`]: refusal(500, 'INTERNAL', 'boom'),
|
|
5131
|
+
},
|
|
5132
|
+
});
|
|
5133
|
+
|
|
5134
|
+
const result = parseToolResult(
|
|
5135
|
+
await client.callTool({ name: 'taiga_get_deployments', arguments: { productId: PRODUCT_ID } })
|
|
5136
|
+
);
|
|
5137
|
+
|
|
5138
|
+
expect(result.environments[0]).toEqual({
|
|
5139
|
+
id: ENV_ID,
|
|
5140
|
+
name: 'production',
|
|
5141
|
+
slug: 'prod',
|
|
5142
|
+
type: 'production',
|
|
5143
|
+
url: 'https://app.example.com',
|
|
5144
|
+
});
|
|
5145
|
+
expect(result.environments.map((e: { id: string }) => e.id)).toEqual([ENV_ID, ENV_ID_2]);
|
|
5146
|
+
expect(result).not.toHaveProperty('moreEnvironments');
|
|
5147
|
+
expect(result.running[0]).toMatchObject({ id: 'd1', commitSubject: 'Merge #1' });
|
|
5148
|
+
expect(result.running[0]).not.toHaveProperty('metadata');
|
|
5149
|
+
expect(result.history).toMatchObject({ nextCursor: 'more' });
|
|
5150
|
+
expect(result.syncStatus).toBeNull();
|
|
5151
|
+
expect(result.syncUnavailable).toContain('500');
|
|
5152
|
+
expect(sentUrl(fetchSpy, `/products/${PRODUCT_ID}/deployments?`).searchParams.get('limit')).toBe('10');
|
|
5153
|
+
});
|
|
5154
|
+
|
|
5155
|
+
it('should read delivery metrics for a window the page offers, and refuse any other', async () => {
|
|
5156
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5157
|
+
capabilities: ['products:read'],
|
|
5158
|
+
routes: {
|
|
5159
|
+
[`/v1/products/${PRODUCT_ID}/deployments/insights`]: { data: { deploymentFrequency: { band: 'high' } } },
|
|
5160
|
+
},
|
|
5161
|
+
});
|
|
5162
|
+
|
|
5163
|
+
const metrics = parseToolResult(
|
|
5164
|
+
await client.callTool({
|
|
5165
|
+
name: 'taiga_get_delivery_metrics',
|
|
5166
|
+
arguments: { productId: PRODUCT_ID, environmentId: ENV_ID, windowDays: 60 },
|
|
5167
|
+
})
|
|
5168
|
+
);
|
|
5169
|
+
const odd = await client.callTool({
|
|
5170
|
+
name: 'taiga_get_delivery_metrics',
|
|
5171
|
+
arguments: { productId: PRODUCT_ID, environmentId: ENV_ID, windowDays: 45 },
|
|
5172
|
+
});
|
|
5173
|
+
|
|
5174
|
+
expect(metrics).toEqual({ deploymentFrequency: { band: 'high' } });
|
|
5175
|
+
const url = sentUrl(fetchSpy, '/deployments/insights');
|
|
5176
|
+
expect(url.searchParams.get('environmentId')).toBe(ENV_ID);
|
|
5177
|
+
expect(url.searchParams.get('windowDays')).toBe('60');
|
|
5178
|
+
expect(odd.isError).toBe(true);
|
|
5179
|
+
});
|
|
5180
|
+
|
|
5181
|
+
it("should read health for every environment, and one environment's signals with a missing one named", async () => {
|
|
5182
|
+
const health = [
|
|
5183
|
+
{ environmentId: ENV_ID, environmentName: 'prod', overallState: 'operational' },
|
|
5184
|
+
{ environmentId: 'other', environmentName: 'dev', overallState: 'down' },
|
|
5185
|
+
];
|
|
5186
|
+
const { client } = await bootMcpPair({
|
|
5187
|
+
capabilities: ['products:read'],
|
|
5188
|
+
routes: {
|
|
5189
|
+
[`/v1/products/${PRODUCT_ID}/monitoring/health`]: { data: health },
|
|
5190
|
+
[`/v1/products/${PRODUCT_ID}/monitoring/uptime`]: { data: { uptimePercent: 99.9 } },
|
|
5191
|
+
[`/v1/products/${PRODUCT_ID}/monitoring/rum`]: { data: { score: null } },
|
|
5192
|
+
[`/v1/products/${PRODUCT_ID}/monitoring/client-health`]: refusal(503, 'UNAVAILABLE', 'Later'),
|
|
5193
|
+
},
|
|
5194
|
+
});
|
|
5195
|
+
|
|
5196
|
+
const all = parseToolResult(
|
|
5197
|
+
await client.callTool({ name: 'taiga_get_monitoring', arguments: { productId: PRODUCT_ID } })
|
|
5198
|
+
);
|
|
5199
|
+
const one = parseToolResult(
|
|
5200
|
+
await client.callTool({
|
|
5201
|
+
name: 'taiga_get_monitoring',
|
|
5202
|
+
arguments: { productId: PRODUCT_ID, environmentId: ENV_ID },
|
|
5203
|
+
})
|
|
5204
|
+
);
|
|
5205
|
+
|
|
5206
|
+
expect(all.health).toHaveLength(2);
|
|
5207
|
+
const odd = await client.callTool({
|
|
5208
|
+
name: 'taiga_get_monitoring',
|
|
5209
|
+
arguments: { productId: PRODUCT_ID, environmentId: ENV_ID, windowDays: 7 },
|
|
5210
|
+
});
|
|
5211
|
+
expect(odd.isError).toBe(true);
|
|
5212
|
+
expect(one).toMatchObject({
|
|
5213
|
+
health: [{ environmentId: ENV_ID }],
|
|
5214
|
+
windowDays: 30,
|
|
5215
|
+
uptime: { uptimePercent: 99.9 },
|
|
5216
|
+
realUserExperience: { score: null },
|
|
5217
|
+
clientHealth: { unavailable: expect.stringContaining('503') },
|
|
5218
|
+
});
|
|
5219
|
+
});
|
|
5220
|
+
|
|
5221
|
+
it('should define an environment and set its App URL, refusing an update that changes nothing', async () => {
|
|
5222
|
+
const env = { id: ENV_ID, name: 'staging', slug: 'staging', type: 'staging', url: null, deployConfig: {} };
|
|
5223
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5224
|
+
capabilities: ['products:read', 'products:write'],
|
|
5225
|
+
routes: {
|
|
5226
|
+
[`POST /v1/products/${PRODUCT_ID}/environments`]: { data: env },
|
|
5227
|
+
[`PATCH /v1/environments/${ENV_ID}`]: { data: { ...env, url: 'https://staging.example.com' } },
|
|
5228
|
+
},
|
|
5229
|
+
});
|
|
5230
|
+
|
|
5231
|
+
await client.callTool({
|
|
5232
|
+
name: 'taiga_define_environment',
|
|
5233
|
+
arguments: { productId: PRODUCT_ID, name: 'staging', type: 'staging' },
|
|
5234
|
+
});
|
|
5235
|
+
expect(sentBody(fetchSpy, 'POST', '/environments')).toEqual({ name: 'staging', type: 'staging' });
|
|
5236
|
+
|
|
5237
|
+
const updated = parseToolResult(
|
|
5238
|
+
await client.callTool({
|
|
5239
|
+
name: 'taiga_update_environment',
|
|
5240
|
+
arguments: { environmentId: ENV_ID, url: 'https://staging.example.com' },
|
|
5241
|
+
})
|
|
5242
|
+
);
|
|
5243
|
+
expect(sentBody(fetchSpy, 'PATCH', '/environments/')).toEqual({ url: 'https://staging.example.com' });
|
|
5244
|
+
expect(updated).not.toHaveProperty('deployConfig');
|
|
5245
|
+
|
|
5246
|
+
const nothing = await client.callTool({ name: 'taiga_update_environment', arguments: { environmentId: ENV_ID } });
|
|
5247
|
+
expect(nothing.isError).toBe(true);
|
|
5248
|
+
});
|
|
5249
|
+
|
|
5250
|
+
it('should list knowledge one level at a time, as the route reads null', async () => {
|
|
5251
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5252
|
+
capabilities: ['customDocuments:read'],
|
|
5253
|
+
routes: { '/v1/custom-documents': { data: [], nextCursor: null } },
|
|
5254
|
+
});
|
|
5255
|
+
|
|
5256
|
+
for (const args of [{}, { factoryId: FACTORY_ID }, { productId: PRODUCT_ID }]) {
|
|
5257
|
+
await client.callTool({ name: 'taiga_list_knowledge', arguments: args });
|
|
5258
|
+
}
|
|
5259
|
+
const two = await client.callTool({
|
|
5260
|
+
name: 'taiga_list_knowledge',
|
|
5261
|
+
arguments: { factoryId: FACTORY_ID, productId: PRODUCT_ID },
|
|
5262
|
+
});
|
|
5263
|
+
expect(two.isError).toBe(true);
|
|
5264
|
+
|
|
5265
|
+
const scopes = callsTo(fetchSpy, 'GET')
|
|
5266
|
+
.filter((u) => u.pathname === '/v1/custom-documents')
|
|
5267
|
+
.map((u) => [u.searchParams.get('factoryId'), u.searchParams.get('productId')]);
|
|
5268
|
+
expect(scopes).toEqual([
|
|
5269
|
+
['null', 'null'],
|
|
5270
|
+
[FACTORY_ID, 'null'],
|
|
5271
|
+
[null, PRODUCT_ID],
|
|
5272
|
+
]);
|
|
5273
|
+
});
|
|
5274
|
+
|
|
5275
|
+
it('should add a Markdown file as knowledge with a multipart upload at the scope named', async () => {
|
|
5276
|
+
const filed = {
|
|
5277
|
+
id: KNOWLEDGE_ID,
|
|
5278
|
+
factoryId: FACTORY_ID,
|
|
5279
|
+
productId: PRODUCT_ID,
|
|
5280
|
+
fileName: 'contract.md',
|
|
5281
|
+
mime: 'text/markdown',
|
|
5282
|
+
status: 'pending',
|
|
5283
|
+
sha256: 'x',
|
|
5284
|
+
};
|
|
5285
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5286
|
+
capabilities: ['customDocuments:read', 'customDocuments:write'],
|
|
5287
|
+
routes: { 'POST /v1/custom-documents': { data: filed, deduplicated: true } },
|
|
5288
|
+
});
|
|
5289
|
+
|
|
5290
|
+
const result = parseToolResult(
|
|
5291
|
+
await client.callTool({
|
|
5292
|
+
name: 'taiga_add_knowledge',
|
|
5293
|
+
arguments: { productId: PRODUCT_ID, fileName: 'contract.md', content: '# Orders API\n' },
|
|
5294
|
+
})
|
|
5295
|
+
);
|
|
5296
|
+
const twoLevels = await client.callTool({
|
|
5297
|
+
name: 'taiga_add_knowledge',
|
|
5298
|
+
arguments: { factoryId: FACTORY_ID, productId: PRODUCT_ID, fileName: 'contract.md', content: 'x' },
|
|
5299
|
+
});
|
|
5300
|
+
|
|
5301
|
+
const call = fetchSpy.mock.calls.find((c: unknown[]) => (c[1] as RequestInit)?.method === 'POST')!;
|
|
5302
|
+
const init = call[1] as RequestInit;
|
|
5303
|
+
const form = init.body as FormData;
|
|
5304
|
+
// A form sets its own multipart type with its boundary.
|
|
5305
|
+
expect((init.headers as Record<string, string>)['Content-Type']).toBeUndefined();
|
|
5306
|
+
// A product's own: the route resolves its factory, so none is sent to mismatch it.
|
|
5307
|
+
expect(form.get('factoryId')).toBeNull();
|
|
5308
|
+
expect(form.get('productId')).toBe(PRODUCT_ID);
|
|
5309
|
+
const file = form.get('file') as File;
|
|
5310
|
+
expect(file.name).toBe('contract.md');
|
|
5311
|
+
expect(file.type).toBe('text/markdown');
|
|
5312
|
+
expect(await file.text()).toBe('# Orders API\n');
|
|
5313
|
+
expect(result).toMatchObject({ id: KNOWLEDGE_ID, status: 'pending' });
|
|
5314
|
+
// The file already there: said so, so it is not taken for a new one.
|
|
5315
|
+
expect(result.deduplicated).toBe(true);
|
|
5316
|
+
expect(result).not.toHaveProperty('sha256');
|
|
5317
|
+
expect(twoLevels.isError).toBe(true);
|
|
5318
|
+
});
|
|
5319
|
+
|
|
5320
|
+
it('should read a text file’s content through its download URL, bounded, and delete a file', async () => {
|
|
5321
|
+
const text = { id: KNOWLEDGE_ID, fileName: 'contract.md', mime: 'text/markdown', status: 'ready' };
|
|
5322
|
+
const PDF_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b07';
|
|
5323
|
+
const BIG_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b08';
|
|
5324
|
+
const GONE_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b09';
|
|
5325
|
+
const FAILED_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4b0a';
|
|
5326
|
+
const download = (name: string) => ({ data: { url: `https://storage.example.com/${name}?sig=1` } });
|
|
5327
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5328
|
+
capabilities: ['customDocuments:read', 'customDocuments:write', 'customDocuments:delete'],
|
|
5329
|
+
routes: {
|
|
5330
|
+
[`GET /v1/custom-documents/${KNOWLEDGE_ID}`]: { data: text },
|
|
5331
|
+
[`/v1/custom-documents/${KNOWLEDGE_ID}/download`]: download('contract.md'),
|
|
5332
|
+
[`GET /v1/custom-documents/${PDF_ID}`]: { data: { ...text, id: PDF_ID, mime: 'application/pdf' } },
|
|
5333
|
+
[`/v1/custom-documents/${PDF_ID}/download`]: download('guide.pdf'),
|
|
5334
|
+
[`GET /v1/custom-documents/${BIG_ID}`]: { data: { ...text, id: BIG_ID } },
|
|
5335
|
+
[`/v1/custom-documents/${BIG_ID}/download`]: download('big.md'),
|
|
5336
|
+
[`GET /v1/custom-documents/${GONE_ID}`]: { data: { ...text, id: GONE_ID } },
|
|
5337
|
+
[`/v1/custom-documents/${GONE_ID}/download`]: download('gone.md'),
|
|
5338
|
+
[`GET /v1/custom-documents/${FAILED_ID}`]: { data: { ...text, id: FAILED_ID, status: 'failed' } },
|
|
5339
|
+
'/contract.md': () => new Response('# Orders API\n'),
|
|
5340
|
+
'/big.md': () => new Response('x'.repeat(400_000)),
|
|
5341
|
+
'/gone.md': () => new Response('expired', { status: 403 }),
|
|
5342
|
+
[`DELETE /v1/custom-documents/${KNOWLEDGE_ID}`]: () => new Response(null, { status: 204 }),
|
|
5343
|
+
},
|
|
5344
|
+
});
|
|
5345
|
+
const read = async (knowledgeId: string) =>
|
|
5346
|
+
parseToolResult(await client.callTool({ name: 'taiga_get_knowledge', arguments: { knowledgeId } }));
|
|
5347
|
+
|
|
5348
|
+
expect(await read(KNOWLEDGE_ID)).toMatchObject({ id: KNOWLEDGE_ID, content: '# Orders API\n' });
|
|
5349
|
+
// The storage URL gets no Taiga credentials, follows no redirect and has a deadline.
|
|
5350
|
+
const storage = fetchSpy.mock.calls.find((c: unknown[]) => (c[0] as string).includes('/contract.md'))!;
|
|
5351
|
+
const init = storage[1] as RequestInit;
|
|
5352
|
+
expect(init.headers).toBeUndefined();
|
|
5353
|
+
expect(init.redirect).toBe('error');
|
|
5354
|
+
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
5355
|
+
|
|
5356
|
+
const pdf = await read(PDF_ID);
|
|
5357
|
+
expect(pdf).not.toHaveProperty('content');
|
|
5358
|
+
expect(pdf.downloadUrl).toBe('https://storage.example.com/guide.pdf?sig=1');
|
|
5359
|
+
expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).includes('/guide.pdf'))).toBe(false);
|
|
5360
|
+
|
|
5361
|
+
const big = await read(BIG_ID);
|
|
5362
|
+
expect(big.content).toMatch(/^x{20000}\n\n\[Cut at 20000 characters\.\]$/);
|
|
5363
|
+
expect(big.downloadUrl).toContain('/big.md');
|
|
5364
|
+
|
|
5365
|
+
const gone = await read(GONE_ID);
|
|
5366
|
+
expect(gone).not.toHaveProperty('content');
|
|
5367
|
+
expect(gone.note).toBe('Storage answered 403.');
|
|
5368
|
+
|
|
5369
|
+
// A file that failed processing is read by nothing until it is retried, this tool included.
|
|
5370
|
+
const failed = await read(FAILED_ID);
|
|
5371
|
+
expect(failed).toMatchObject({ id: FAILED_ID, status: 'failed' });
|
|
5372
|
+
expect(failed).not.toHaveProperty('content');
|
|
5373
|
+
expect(failed).not.toHaveProperty('downloadUrl');
|
|
5374
|
+
expect(failed.note).toMatch(/taiga_retry_knowledge/);
|
|
5375
|
+
expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).includes(`${FAILED_ID}/download`))).toBe(
|
|
5376
|
+
false
|
|
5377
|
+
);
|
|
5378
|
+
|
|
5379
|
+
const deleted = parseToolResult(
|
|
5380
|
+
await client.callTool({ name: 'taiga_delete_knowledge', arguments: { knowledgeId: KNOWLEDGE_ID } })
|
|
5381
|
+
);
|
|
5382
|
+
expect(deleted).toEqual({ id: KNOWLEDGE_ID, deleted: true });
|
|
5383
|
+
});
|
|
5384
|
+
|
|
5385
|
+
it("should set a product's autonomy defaults, writing null where the page writes it, and an initiative's merge switch", async () => {
|
|
5386
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5387
|
+
capabilities: ['products:write', 'backlog:write'],
|
|
5388
|
+
routes: {
|
|
5389
|
+
[`PATCH /v1/products/${PRODUCT_ID}`]: {
|
|
5390
|
+
data: { id: PRODUCT_ID, name: 'Web', autoBuild: false, autoMerge: null, settings: {} },
|
|
5391
|
+
},
|
|
5392
|
+
[`PATCH /v1/backlog/${ITEM_ID}`]: {
|
|
5393
|
+
data: { id: ITEM_ID, title: 'Add login', autoMerge: false, description: 'long' },
|
|
5394
|
+
},
|
|
5395
|
+
},
|
|
5396
|
+
});
|
|
5397
|
+
|
|
5398
|
+
const product = parseToolResult(
|
|
5399
|
+
await client.callTool({
|
|
5400
|
+
name: 'taiga_set_product_autonomy',
|
|
5401
|
+
arguments: { productId: PRODUCT_ID, autoBuild: false, autoMerge: true },
|
|
5402
|
+
})
|
|
5403
|
+
);
|
|
5404
|
+
expect(sentBody(fetchSpy, 'PATCH', `/products/${PRODUCT_ID}`)).toEqual({ autoBuild: false, autoMerge: null });
|
|
5405
|
+
expect(product).not.toHaveProperty('settings');
|
|
5406
|
+
|
|
5407
|
+
const item = parseToolResult(
|
|
5408
|
+
await client.callTool({ name: 'taiga_set_auto_merge', arguments: { itemId: ITEM_ID, autoMerge: false } })
|
|
5409
|
+
);
|
|
5410
|
+
expect(sentBody(fetchSpy, 'PATCH', `/backlog/${ITEM_ID}`)).toEqual({ autoMerge: false });
|
|
5411
|
+
expect(item).not.toHaveProperty('description');
|
|
5412
|
+
|
|
5413
|
+
const nothing = await client.callTool({
|
|
5414
|
+
name: 'taiga_set_product_autonomy',
|
|
5415
|
+
arguments: { productId: PRODUCT_ID },
|
|
5416
|
+
});
|
|
5417
|
+
expect(nothing.isError).toBe(true);
|
|
5418
|
+
});
|
|
5419
|
+
|
|
5420
|
+
it('should edit a product document section in its draft, publish it, and ask agents to generate', async () => {
|
|
5421
|
+
const published = {
|
|
5422
|
+
id: 'doc-1',
|
|
5423
|
+
type: 'specification',
|
|
5424
|
+
version: 2,
|
|
5425
|
+
isDraft: false,
|
|
5426
|
+
data: { sections: { vision: { content: 'Old', source: 'ai' }, scope: { content: 'Keep' } } },
|
|
5427
|
+
};
|
|
5428
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5429
|
+
capabilities: ['products:read', 'products:write'],
|
|
5430
|
+
routes: {
|
|
5431
|
+
[`GET /v1/products/${PRODUCT_ID}/documents/specification/draft`]: { data: null },
|
|
5432
|
+
[`GET /v1/products/${PRODUCT_ID}/documents/specification?origin=plan`]: { data: published },
|
|
5433
|
+
[`PUT /v1/products/${PRODUCT_ID}/documents/specification/draft`]: () => {
|
|
5434
|
+
const body = {
|
|
5435
|
+
data: {
|
|
5436
|
+
...published,
|
|
5437
|
+
isDraft: true,
|
|
5438
|
+
data: JSON.parse((fetchSpy.mock.calls.at(-1)![1] as RequestInit).body as string).data,
|
|
5439
|
+
},
|
|
5440
|
+
};
|
|
5441
|
+
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
|
5442
|
+
},
|
|
5443
|
+
[`POST /v1/products/${PRODUCT_ID}/documents/specification/publish`]: {
|
|
5444
|
+
data: { ...published, version: 3 },
|
|
5445
|
+
warnings: { unknownPolicyCitations: ['SEC-99.9'] },
|
|
5446
|
+
},
|
|
5447
|
+
[`POST /v1/products/${PRODUCT_ID}/documents/specification/review`]: { data: { reviewed: true } },
|
|
5448
|
+
'POST /v1/agent-jobs': {
|
|
5449
|
+
data: { id: 'job-9', type: 'generate_document', documentType: 'architecture', status: 'pending' },
|
|
5450
|
+
},
|
|
5451
|
+
[`POST /v1/products/${PRODUCT_ID}/documents/generate-all`]: { data: { jobIds: ['job-10'] } },
|
|
5452
|
+
[`GET /v1/products/${PRODUCT_ID}`]: { data: { id: PRODUCT_ID, status: 'draft' } },
|
|
5453
|
+
},
|
|
5454
|
+
});
|
|
5455
|
+
|
|
5456
|
+
const edited = parseToolResult(
|
|
5457
|
+
await client.callTool({
|
|
5458
|
+
name: 'taiga_update_product_document',
|
|
5459
|
+
arguments: { productId: PRODUCT_ID, type: 'specification', sectionId: 'vision', content: 'New' },
|
|
5460
|
+
})
|
|
5461
|
+
);
|
|
5462
|
+
expect(sentBody(fetchSpy, 'PUT', '/documents/specification/draft')).toEqual({
|
|
5463
|
+
data: { sections: { vision: { content: 'New', source: 'ai' }, scope: { content: 'Keep' } } },
|
|
5464
|
+
});
|
|
5465
|
+
expect(edited.section).toEqual({ id: 'vision', content: 'New' });
|
|
5466
|
+
|
|
5467
|
+
const out = parseToolResult(
|
|
5468
|
+
await client.callTool({
|
|
5469
|
+
name: 'taiga_publish_product_document',
|
|
5470
|
+
arguments: { productId: PRODUCT_ID, type: 'specification' },
|
|
5471
|
+
})
|
|
5472
|
+
);
|
|
5473
|
+
expect(out.version).toBe(3);
|
|
5474
|
+
// A publish that cites a control no policy has says so: the publisher hears of it only here.
|
|
5475
|
+
expect(out.warnings).toEqual({ unknownPolicyCitations: ['SEC-99.9'] });
|
|
5476
|
+
|
|
5477
|
+
await client.callTool({
|
|
5478
|
+
name: 'taiga_mark_product_document_reviewed',
|
|
5479
|
+
arguments: { productId: PRODUCT_ID, type: 'specification' },
|
|
5480
|
+
});
|
|
5481
|
+
const regen = parseToolResult(
|
|
5482
|
+
await client.callTool({
|
|
5483
|
+
name: 'taiga_regenerate_product_document',
|
|
5484
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture' },
|
|
5485
|
+
})
|
|
5486
|
+
);
|
|
5487
|
+
expect(sentBody(fetchSpy, 'POST', '/agent-jobs')).toEqual({
|
|
5488
|
+
scope: 'product',
|
|
5489
|
+
type: 'generate_document',
|
|
5490
|
+
targetId: PRODUCT_ID,
|
|
5491
|
+
documentType: 'architecture',
|
|
5492
|
+
});
|
|
5493
|
+
expect(regen).toMatchObject({ id: 'job-9', status: 'pending' });
|
|
5494
|
+
|
|
5495
|
+
const all = parseToolResult(
|
|
5496
|
+
await client.callTool({ name: 'taiga_generate_product_documents', arguments: { productId: PRODUCT_ID } })
|
|
5497
|
+
);
|
|
5498
|
+
expect(all).toEqual({ jobIds: ['job-10'] });
|
|
5499
|
+
expect(callsTo(fetchSpy, 'POST').map((u) => u.pathname)).toContain(
|
|
5500
|
+
`/v1/products/${PRODUCT_ID}/documents/specification/review`
|
|
5501
|
+
);
|
|
5502
|
+
|
|
5503
|
+
// Each tool takes the types its route serves: the specification is not regenerated from other
|
|
5504
|
+
// documents, and the codebase snapshot is not a document a person reviews.
|
|
5505
|
+
const posts = callsTo(fetchSpy, 'POST').length;
|
|
5506
|
+
const spec = await client.callTool({
|
|
5507
|
+
name: 'taiga_regenerate_product_document',
|
|
5508
|
+
arguments: { productId: PRODUCT_ID, type: 'specification' },
|
|
5509
|
+
});
|
|
5510
|
+
const snapshot = await client.callTool({
|
|
5511
|
+
name: 'taiga_mark_product_document_reviewed',
|
|
5512
|
+
arguments: { productId: PRODUCT_ID, type: 'codebase_snapshot' },
|
|
5513
|
+
});
|
|
5514
|
+
const snapshotEdit = await client.callTool({
|
|
5515
|
+
name: 'taiga_update_product_document',
|
|
5516
|
+
arguments: { productId: PRODUCT_ID, type: 'codebase_snapshot', sectionId: 'vision', content: 'x' },
|
|
5517
|
+
});
|
|
5518
|
+
expect(spec.isError).toBe(true);
|
|
5519
|
+
expect(snapshot.isError).toBe(true);
|
|
5520
|
+
expect(snapshotEdit.isError).toBe(true);
|
|
5521
|
+
expect(callsTo(fetchSpy, 'POST')).toHaveLength(posts);
|
|
5522
|
+
});
|
|
5523
|
+
});
|
|
5524
|
+
|
|
5525
|
+
describe('the Discovery lock on regenerating', () => {
|
|
5526
|
+
it('should refuse to regenerate a document once Discovery is finished, before any job is asked for', async () => {
|
|
5527
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
5528
|
+
capabilities: ['products:read', 'products:write'],
|
|
5529
|
+
routes: {
|
|
5530
|
+
[`GET /v1/products/${PRODUCT_ID}`]: { data: { id: PRODUCT_ID, status: 'active' } },
|
|
5531
|
+
'POST /v1/agent-jobs': {
|
|
5532
|
+
data: { id: 'job-11', type: 'generate_document', documentType: 'service_blueprint', status: 'pending' },
|
|
5533
|
+
},
|
|
5534
|
+
},
|
|
5535
|
+
});
|
|
5536
|
+
|
|
5537
|
+
const result = await client.callTool({
|
|
5538
|
+
name: 'taiga_regenerate_product_document',
|
|
5539
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture' },
|
|
5540
|
+
});
|
|
5541
|
+
|
|
5542
|
+
expect(result.isError).toBe(true);
|
|
5543
|
+
expect(parseToolResult(result).message).toMatch(/Discovery is finished/);
|
|
5544
|
+
expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).includes('/agent-jobs'))).toBe(false);
|
|
5545
|
+
|
|
5546
|
+
// The optional Service Blueprint is not one setup locks, so it is still generated after it.
|
|
5547
|
+
const blueprint = await client.callTool({
|
|
5548
|
+
name: 'taiga_regenerate_product_document',
|
|
5549
|
+
arguments: { productId: PRODUCT_ID, type: 'service_blueprint' },
|
|
5550
|
+
});
|
|
5551
|
+
expect(blueprint.isError).toBeFalsy();
|
|
5552
|
+
expect(sentBody(fetchSpy, 'POST', '/agent-jobs')).toMatchObject({ documentType: 'service_blueprint' });
|
|
5553
|
+
});
|
|
5554
|
+
});
|
|
5555
|
+
|
|
4662
5556
|
describe('error handling', () => {
|
|
4663
5557
|
it('should surface API errors as structured MCP error content', async () => {
|
|
4664
5558
|
// Introspect succeeds, but all other endpoints fail
|