@taiga-ai/mcp-server 0.17.0-dev.441 → 0.18.0-dev.443
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 +209 -85
- package/package.json +1 -1
- package/src/client.ts +15 -2
- package/src/server.integration.test.ts +404 -37
- package/src/tools/autonomy.ts +2 -2
- package/src/tools/backlogs.ts +67 -8
- package/src/tools/catalogue.test.ts +15 -1
- package/src/tools/context.ts +31 -18
- package/src/tools/documents.ts +13 -10
- package/src/tools/hints.ts +1 -1
- package/src/tools/inbox.ts +24 -5
- package/src/tools/index.ts +23 -2
- package/src/tools/instructions.ts +7 -6
- package/src/tools/knowledge.ts +3 -3
- package/src/tools/maintenance.ts +5 -3
- package/src/tools/observe.ts +29 -13
- package/src/tools/product-documents.ts +4 -5
- package/src/tools/products.ts +7 -11
- package/src/tools/repositories.ts +47 -16
- package/src/tools/sections.ts +17 -5
- package/src/tools/setup.ts +8 -8
- package/taiga-ai-mcp-server-0.18.0-dev.443.tgz +0 -0
- package/taiga-ai-mcp-server-0.17.0-dev.441.tgz +0 -0
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -434,6 +434,11 @@ export class TaigaClient {
|
|
|
434
434
|
return this.request<{ data: unknown }>('GET', `/backlog/${seg(itemId)}`, undefined, { signal });
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
/** The initiative's history, oldest first: every turn event and audited move, each with who did it. */
|
|
438
|
+
async listBacklogItemActivity(itemId: string) {
|
|
439
|
+
return this.request<{ data: unknown[] }>('GET', `/backlog/${seg(itemId)}/activity`);
|
|
440
|
+
}
|
|
441
|
+
|
|
437
442
|
/** A person's move on the line board: the column dropped into, and what to follow there. */
|
|
438
443
|
async transitionInitiative(
|
|
439
444
|
itemId: string,
|
|
@@ -615,12 +620,16 @@ export class TaigaClient {
|
|
|
615
620
|
productId?: string | undefined;
|
|
616
621
|
category?: string | undefined;
|
|
617
622
|
resolved?: boolean | undefined;
|
|
623
|
+
withTotal?: boolean | undefined;
|
|
618
624
|
limit?: number | undefined;
|
|
619
625
|
cursor?: string | undefined;
|
|
620
626
|
}) {
|
|
621
627
|
const { productId, ...rest } = opts;
|
|
622
628
|
const path = productId ? `/products/${seg(productId)}/inbox` : '/inbox';
|
|
623
|
-
return this.request<{ data: unknown[]; nextCursor: string | null }>(
|
|
629
|
+
return this.request<{ data: unknown[]; nextCursor: string | null; totalCount?: number }>(
|
|
630
|
+
'GET',
|
|
631
|
+
`${path}${query({ ...rest })}`
|
|
632
|
+
);
|
|
624
633
|
}
|
|
625
634
|
|
|
626
635
|
async updateInboxItem(
|
|
@@ -633,12 +642,16 @@ export class TaigaClient {
|
|
|
633
642
|
async listSetupTasks(opts: {
|
|
634
643
|
productId?: string | undefined;
|
|
635
644
|
openOnly?: boolean | undefined;
|
|
645
|
+
withTotal?: boolean | undefined;
|
|
636
646
|
limit?: number | undefined;
|
|
637
647
|
cursor?: string | undefined;
|
|
638
648
|
}) {
|
|
639
649
|
const { productId, ...rest } = opts;
|
|
640
650
|
const path = productId ? `/products/${seg(productId)}/setup-tasks` : '/setup-tasks';
|
|
641
|
-
return this.request<{ data: unknown[]; nextCursor: string | null }>(
|
|
651
|
+
return this.request<{ data: unknown[]; nextCursor: string | null; totalCount?: number }>(
|
|
652
|
+
'GET',
|
|
653
|
+
`${path}${query({ ...rest })}`
|
|
654
|
+
);
|
|
642
655
|
}
|
|
643
656
|
|
|
644
657
|
// ---------------------------------------------------------------------------
|
|
@@ -65,6 +65,7 @@ const ALL_TOOL_NAMES = [
|
|
|
65
65
|
'taiga_list_backlog_items',
|
|
66
66
|
'taiga_list_builds',
|
|
67
67
|
'taiga_list_documents',
|
|
68
|
+
'taiga_list_environments',
|
|
68
69
|
'taiga_list_factories',
|
|
69
70
|
'taiga_list_factory_documents',
|
|
70
71
|
'taiga_list_findings',
|
|
@@ -446,6 +447,7 @@ describe('MCP server integration', () => {
|
|
|
446
447
|
'taiga_list_action_required',
|
|
447
448
|
'taiga_list_setup_tasks',
|
|
448
449
|
'taiga_get_deployments',
|
|
450
|
+
'taiga_list_environments',
|
|
449
451
|
'taiga_get_delivery_metrics',
|
|
450
452
|
'taiga_get_monitoring',
|
|
451
453
|
'taiga_list_product_repositories',
|
|
@@ -1973,8 +1975,11 @@ describe('MCP server integration', () => {
|
|
|
1973
1975
|
instructionsVersion: 2,
|
|
1974
1976
|
});
|
|
1975
1977
|
expect(bundle.documents.origin).toBe('plan');
|
|
1976
|
-
|
|
1977
|
-
expect(bundle.documents.specification).
|
|
1978
|
+
// The outline by default (PRO-2778): each section id with its size, no text.
|
|
1979
|
+
expect(bundle.documents.specification).toEqual({
|
|
1980
|
+
version: spec.version,
|
|
1981
|
+
sections: [{ id: 'vision', characters: 'A web app'.length }],
|
|
1982
|
+
});
|
|
1978
1983
|
expect(bundle.documents.architecture).toBeNull();
|
|
1979
1984
|
expect(bundle.documents.notes).toEqual([
|
|
1980
1985
|
'No published plan architecture; taiga_list_product_documents shows what the product has.',
|
|
@@ -2141,17 +2146,20 @@ describe('MCP server integration', () => {
|
|
|
2141
2146
|
expect(bundle.backlog.truncated).toBe(true);
|
|
2142
2147
|
});
|
|
2143
2148
|
|
|
2144
|
-
it('should carry the two documents as
|
|
2149
|
+
it('should carry the two documents as their outline when asked for it', async () => {
|
|
2145
2150
|
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
|
|
2146
2151
|
|
|
2147
2152
|
const bundle = parseToolResult(
|
|
2148
2153
|
await client.callTool({
|
|
2149
2154
|
name: 'taiga_get_product_context',
|
|
2150
|
-
arguments: { productId: PRODUCT_ID,
|
|
2155
|
+
arguments: { productId: PRODUCT_ID, documents: 'outline' },
|
|
2151
2156
|
})
|
|
2152
2157
|
);
|
|
2153
2158
|
|
|
2154
|
-
expect(bundle.documents.specification).toEqual({
|
|
2159
|
+
expect(bundle.documents.specification).toEqual({
|
|
2160
|
+
version: 3,
|
|
2161
|
+
sections: [{ id: 'vision', characters: 'A web app'.length }],
|
|
2162
|
+
});
|
|
2155
2163
|
expect(bundle.documents.architecture).toBeNull();
|
|
2156
2164
|
});
|
|
2157
2165
|
|
|
@@ -2169,7 +2177,7 @@ describe('MCP server integration', () => {
|
|
|
2169
2177
|
const bundle = parseToolResult(
|
|
2170
2178
|
await client.callTool({
|
|
2171
2179
|
name: 'taiga_get_product_context',
|
|
2172
|
-
arguments: { productId: PRODUCT_ID,
|
|
2180
|
+
arguments: { productId: PRODUCT_ID, documents: 'outline' },
|
|
2173
2181
|
})
|
|
2174
2182
|
);
|
|
2175
2183
|
|
|
@@ -4717,7 +4725,10 @@ describe('MCP server integration', () => {
|
|
|
4717
4725
|
it('should carry the whole sections that fit the budget, skipping one that does not, name the rest and say how large it is', async () => {
|
|
4718
4726
|
const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(bigSpec) });
|
|
4719
4727
|
|
|
4720
|
-
const raw = await client.callTool({
|
|
4728
|
+
const raw = await client.callTool({
|
|
4729
|
+
name: 'taiga_get_product_context',
|
|
4730
|
+
arguments: { productId: PRODUCT_ID, documents: 'budget' },
|
|
4731
|
+
});
|
|
4721
4732
|
const bundle = parseToolResult(raw);
|
|
4722
4733
|
|
|
4723
4734
|
// The size it reports is the size of the text it hands over, the field included.
|
|
@@ -4732,7 +4743,7 @@ describe('MCP server integration', () => {
|
|
|
4732
4743
|
expect(bundle.characters).toBeLessThan(20_000);
|
|
4733
4744
|
});
|
|
4734
4745
|
|
|
4735
|
-
it('should never cut a section: one past the budget alone is named, and
|
|
4746
|
+
it('should never cut a section: one past the budget alone is named, and documents: full carries everything', async () => {
|
|
4736
4747
|
// The order a jsonb column hands back: shorter ids first, whatever order they were written in.
|
|
4737
4748
|
const huge = {
|
|
4738
4749
|
...bigSpec,
|
|
@@ -4741,12 +4752,15 @@ describe('MCP server integration', () => {
|
|
|
4741
4752
|
const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(huge) });
|
|
4742
4753
|
|
|
4743
4754
|
const cut = parseToolResult(
|
|
4744
|
-
await client.callTool({
|
|
4755
|
+
await client.callTool({
|
|
4756
|
+
name: 'taiga_get_product_context',
|
|
4757
|
+
arguments: { productId: PRODUCT_ID, documents: 'budget' },
|
|
4758
|
+
})
|
|
4745
4759
|
).documents.specification;
|
|
4746
4760
|
const full = parseToolResult(
|
|
4747
4761
|
await client.callTool({
|
|
4748
4762
|
name: 'taiga_get_product_context',
|
|
4749
|
-
arguments: { productId: PRODUCT_ID,
|
|
4763
|
+
arguments: { productId: PRODUCT_ID, documents: 'full' },
|
|
4750
4764
|
})
|
|
4751
4765
|
).documents.specification;
|
|
4752
4766
|
|
|
@@ -4756,7 +4770,7 @@ describe('MCP server integration', () => {
|
|
|
4756
4770
|
expect(full).not.toHaveProperty('omittedSections');
|
|
4757
4771
|
});
|
|
4758
4772
|
|
|
4759
|
-
it("should leave each section's citations out of the bundle and say so, while
|
|
4773
|
+
it("should leave each section's citations out of the budget bundle and say so, while documents: full keeps them", async () => {
|
|
4760
4774
|
const cited = {
|
|
4761
4775
|
...bigSpec,
|
|
4762
4776
|
data: { sections: { overview: { ...section('text'), citations: [{ path: 'src/a.rs', lines: '1-9' }] } } },
|
|
@@ -4764,12 +4778,15 @@ describe('MCP server integration', () => {
|
|
|
4764
4778
|
const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(cited) });
|
|
4765
4779
|
|
|
4766
4780
|
const lean = parseToolResult(
|
|
4767
|
-
await client.callTool({
|
|
4781
|
+
await client.callTool({
|
|
4782
|
+
name: 'taiga_get_product_context',
|
|
4783
|
+
arguments: { productId: PRODUCT_ID, documents: 'budget' },
|
|
4784
|
+
})
|
|
4768
4785
|
).documents;
|
|
4769
4786
|
const full = parseToolResult(
|
|
4770
4787
|
await client.callTool({
|
|
4771
4788
|
name: 'taiga_get_product_context',
|
|
4772
|
-
arguments: { productId: PRODUCT_ID,
|
|
4789
|
+
arguments: { productId: PRODUCT_ID, documents: 'full' },
|
|
4773
4790
|
})
|
|
4774
4791
|
).documents;
|
|
4775
4792
|
|
|
@@ -4795,10 +4812,37 @@ describe('MCP server integration', () => {
|
|
|
4795
4812
|
// The document's own order, whatever order they were asked in.
|
|
4796
4813
|
expect(Object.keys(doc.data.sections)).toEqual(['flows', 'risks']);
|
|
4797
4814
|
expect(doc.omittedSections.map((s: { id: string }) => s.id)).toEqual(['overview', 'features']);
|
|
4798
|
-
|
|
4799
|
-
expect(
|
|
4800
|
-
|
|
4815
|
+
// One shape whatever matched (PRO-2778): none right still answers, listing every real id with its size.
|
|
4816
|
+
expect(miss.isError).toBeFalsy();
|
|
4817
|
+
const missed = parseToolResult(miss);
|
|
4818
|
+
expect(missed.data.sections).toEqual({});
|
|
4819
|
+
expect(missed.unknownSections).toEqual(['nope']);
|
|
4820
|
+
expect(missed.omittedSections.map((s: { id: string }) => s.id)).toEqual([
|
|
4821
|
+
'overview',
|
|
4822
|
+
'features',
|
|
4823
|
+
'flows',
|
|
4824
|
+
'risks',
|
|
4825
|
+
]);
|
|
4826
|
+
});
|
|
4827
|
+
|
|
4828
|
+
it('should read a product document as its outline with sections: []', async () => {
|
|
4829
|
+
const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(bigSpec) });
|
|
4830
|
+
|
|
4831
|
+
const outline = parseToolResult(
|
|
4832
|
+
await client.callTool({
|
|
4833
|
+
name: 'taiga_get_product_document',
|
|
4834
|
+
arguments: { productId: PRODUCT_ID, type: 'specification', origin: 'plan', sections: [] },
|
|
4835
|
+
})
|
|
4801
4836
|
);
|
|
4837
|
+
|
|
4838
|
+
expect(outline.data.sections).toEqual({});
|
|
4839
|
+
expect(outline.omittedSections).toEqual([
|
|
4840
|
+
{ id: 'overview', characters: 5_000 },
|
|
4841
|
+
{ id: 'features', characters: expect.any(Number) },
|
|
4842
|
+
{ id: 'flows', characters: 5_000 },
|
|
4843
|
+
{ id: 'risks', characters: expect.any(Number) },
|
|
4844
|
+
]);
|
|
4845
|
+
expect(outline).not.toHaveProperty('unknownSections');
|
|
4802
4846
|
});
|
|
4803
4847
|
});
|
|
4804
4848
|
|
|
@@ -5886,21 +5930,25 @@ describe('MCP server integration', () => {
|
|
|
5886
5930
|
})
|
|
5887
5931
|
);
|
|
5888
5932
|
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5933
|
+
// data, as every list carries its rows (PRO-2778), not a bare array.
|
|
5934
|
+
expect(linked).toEqual({
|
|
5935
|
+
data: [
|
|
5936
|
+
{
|
|
5937
|
+
id: LINK_ID,
|
|
5938
|
+
provider: 'github',
|
|
5939
|
+
repositoryName: 'acme/billing',
|
|
5940
|
+
repositoryUrl: 'https://github.com/acme/billing',
|
|
5941
|
+
repositoryDefaultBranch: 'main',
|
|
5942
|
+
baseBranch: null,
|
|
5943
|
+
effectiveBranch: 'main',
|
|
5944
|
+
label: null,
|
|
5945
|
+
buildable: true,
|
|
5946
|
+
isManaged: false,
|
|
5947
|
+
},
|
|
5948
|
+
],
|
|
5949
|
+
truncated: false,
|
|
5950
|
+
});
|
|
5951
|
+
expect(branches.data).toHaveLength(2);
|
|
5904
5952
|
expect(found.accounts).toEqual([{ id: INSTALL_ID, accountLogin: 'acme', accountType: 'Organization' }]);
|
|
5905
5953
|
expect(found.repositories).toEqual({
|
|
5906
5954
|
data: [
|
|
@@ -5969,12 +6017,71 @@ describe('MCP server integration', () => {
|
|
|
5969
6017
|
);
|
|
5970
6018
|
// The limit holds across accounts, and the answer says it was cut.
|
|
5971
6019
|
expect(one.repositories.data).toHaveLength(1);
|
|
5972
|
-
expect(one.more).toMatch(/
|
|
6020
|
+
expect(one.more).toMatch(/name an account/);
|
|
5973
6021
|
expect(both.repositories.data.map((r: { fullName: string }) => r.fullName)).toEqual([
|
|
5974
6022
|
'acme/billing',
|
|
5975
6023
|
'acme-labs/billing-poc',
|
|
5976
6024
|
]);
|
|
5977
|
-
expect(both.more).toMatch(/
|
|
6025
|
+
expect(both.more).toMatch(/name an account/);
|
|
6026
|
+
});
|
|
6027
|
+
|
|
6028
|
+
// PRO-2778: the first account's page filled the answer and hid the others.
|
|
6029
|
+
it('should take rows from each account in turn, and read one account by its login', async () => {
|
|
6030
|
+
const three = [1, 2, 3].map((n) => ghRepo(`0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4d0${n}`, `acme/repo-${n}`));
|
|
6031
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
6032
|
+
capabilities: ['vcs:read'],
|
|
6033
|
+
routes: {
|
|
6034
|
+
'GET /v1/github/installations': {
|
|
6035
|
+
data: [
|
|
6036
|
+
{ id: INSTALL_ID, accountLogin: 'acme', accountType: 'Organization' },
|
|
6037
|
+
{ id: INSTALL_ID_2, accountLogin: 'taiga-sandbox', accountType: 'Organization' },
|
|
6038
|
+
],
|
|
6039
|
+
nextCursor: null,
|
|
6040
|
+
},
|
|
6041
|
+
[`GET /v1/github/installations/${INSTALL_ID}/repositories`]: { data: three, nextCursor: null },
|
|
6042
|
+
[`GET /v1/github/installations/${INSTALL_ID_2}/repositories`]: {
|
|
6043
|
+
data: [ghRepo(LINK_ID, 'taiga-sandbox/greenfield')],
|
|
6044
|
+
nextCursor: null,
|
|
6045
|
+
},
|
|
6046
|
+
},
|
|
6047
|
+
});
|
|
6048
|
+
|
|
6049
|
+
const spread = parseToolResult(
|
|
6050
|
+
await client.callTool({ name: 'taiga_list_linkable_repositories', arguments: { limit: 2 } })
|
|
6051
|
+
);
|
|
6052
|
+
expect(spread.repositories.data.map((r: { fullName: string }) => r.fullName)).toEqual([
|
|
6053
|
+
'acme/repo-1',
|
|
6054
|
+
'taiga-sandbox/greenfield',
|
|
6055
|
+
]);
|
|
6056
|
+
|
|
6057
|
+
const before = calls(fetchSpy, 'GET').length;
|
|
6058
|
+
const sandbox = parseToolResult(
|
|
6059
|
+
await client.callTool({ name: 'taiga_list_linkable_repositories', arguments: { account: 'Taiga-Sandbox' } })
|
|
6060
|
+
);
|
|
6061
|
+
expect(sandbox.repositories.data.map((r: { fullName: string }) => r.fullName)).toEqual([
|
|
6062
|
+
'taiga-sandbox/greenfield',
|
|
6063
|
+
]);
|
|
6064
|
+
// Only the installations and that one account were read.
|
|
6065
|
+
const urls = calls(fetchSpy, 'GET')
|
|
6066
|
+
.slice(before)
|
|
6067
|
+
.map((u) => u.pathname);
|
|
6068
|
+
expect(urls.some((u) => u.includes(`/installations/${INSTALL_ID}/`))).toBe(false);
|
|
6069
|
+
|
|
6070
|
+
// owner/repo names its owner, a GitHub-length repository name included.
|
|
6071
|
+
const byRepo = parseToolResult(
|
|
6072
|
+
await client.callTool({
|
|
6073
|
+
name: 'taiga_list_linkable_repositories',
|
|
6074
|
+
arguments: { account: `taiga-sandbox/${'r'.repeat(100)}` },
|
|
6075
|
+
})
|
|
6076
|
+
);
|
|
6077
|
+
expect(byRepo.repositories.data).toHaveLength(1);
|
|
6078
|
+
|
|
6079
|
+
const missing = await client.callTool({
|
|
6080
|
+
name: 'taiga_list_linkable_repositories',
|
|
6081
|
+
arguments: { account: 'nope' },
|
|
6082
|
+
});
|
|
6083
|
+
expect(missing.isError).toBe(true);
|
|
6084
|
+
expect(parseToolResult(missing).details.accounts).toHaveLength(2);
|
|
5978
6085
|
});
|
|
5979
6086
|
});
|
|
5980
6087
|
|
|
@@ -6342,10 +6449,12 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6342
6449
|
name: 'taiga_get_document',
|
|
6343
6450
|
arguments: { type: 'policy_security', sections: ['secrets'] },
|
|
6344
6451
|
});
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
});
|
|
6452
|
+
// One shape whatever matched (PRO-2778): every real id with its size, the miss named.
|
|
6453
|
+
expect(none.isError).toBeFalsy();
|
|
6454
|
+
const missed = parseToolResult(none);
|
|
6455
|
+
expect(missed.data.sections).toEqual({});
|
|
6456
|
+
expect(missed.unknownSections).toEqual(['secrets']);
|
|
6457
|
+
expect(missed.omittedSections.map((s: { id: string }) => s.id)).toEqual(['summary', 'scope']);
|
|
6349
6458
|
});
|
|
6350
6459
|
|
|
6351
6460
|
it('says what a product in setup waits for, and warns of a half turned off with nothing said about it', async () => {
|
|
@@ -6533,3 +6642,261 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6533
6642
|
});
|
|
6534
6643
|
});
|
|
6535
6644
|
});
|
|
6645
|
+
|
|
6646
|
+
describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
6647
|
+
const ENV_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a80';
|
|
6648
|
+
|
|
6649
|
+
beforeEach(() => {
|
|
6650
|
+
vi.restoreAllMocks();
|
|
6651
|
+
});
|
|
6652
|
+
|
|
6653
|
+
it('says how many need a person on the first page, and asks for the count only there', async () => {
|
|
6654
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
6655
|
+
capabilities: ALL_CAPABILITIES,
|
|
6656
|
+
routes: {
|
|
6657
|
+
'GET /v1/inbox': { data: [], nextCursor: 'c2', totalCount: 42 },
|
|
6658
|
+
'GET /v1/setup-tasks': { data: [], nextCursor: null, totalCount: 3 },
|
|
6659
|
+
},
|
|
6660
|
+
});
|
|
6661
|
+
|
|
6662
|
+
const inbox = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
|
|
6663
|
+
const tasks = parseToolResult(await client.callTool({ name: 'taiga_list_setup_tasks', arguments: {} }));
|
|
6664
|
+
expect(inbox.totalCount).toBe(42);
|
|
6665
|
+
expect(tasks.totalCount).toBe(3);
|
|
6666
|
+
expect(sentUrl(fetchSpy, '/v1/inbox').searchParams.get('withTotal')).toBe('true');
|
|
6667
|
+
expect(sentUrl(fetchSpy, '/v1/setup-tasks').searchParams.get('withTotal')).toBe('true');
|
|
6668
|
+
|
|
6669
|
+
fetchSpy.mockClear();
|
|
6670
|
+
await client.callTool({ name: 'taiga_list_action_required', arguments: { cursor: 'c2' } });
|
|
6671
|
+
expect(sentUrl(fetchSpy, '/v1/inbox').searchParams.has('withTotal')).toBe(false);
|
|
6672
|
+
});
|
|
6673
|
+
|
|
6674
|
+
it('carries who cancelled a failed plan and when, and leaves the item as it was when the history is refused', async () => {
|
|
6675
|
+
const failed = { id: ITEM_ID, title: 'Login', planningStatus: 'failed', planFailureCause: 'cancelled' };
|
|
6676
|
+
const history = {
|
|
6677
|
+
data: [
|
|
6678
|
+
{
|
|
6679
|
+
kind: 'turn',
|
|
6680
|
+
trigger: 'plan_failed',
|
|
6681
|
+
actor: { type: 'agent', id: null, name: null },
|
|
6682
|
+
occurredAt: '2026-10-01T00:00:00.000Z',
|
|
6683
|
+
data: { cause: 'agent_error' },
|
|
6684
|
+
},
|
|
6685
|
+
{
|
|
6686
|
+
kind: 'audit',
|
|
6687
|
+
action: 'backlog.moved',
|
|
6688
|
+
actor: { type: 'user', id: 'u1', name: 'Ada' },
|
|
6689
|
+
occurredAt: '2026-10-02T00:00:00.000Z',
|
|
6690
|
+
data: null,
|
|
6691
|
+
},
|
|
6692
|
+
{
|
|
6693
|
+
kind: 'turn',
|
|
6694
|
+
trigger: 'plan_failed',
|
|
6695
|
+
actor: { type: 'user', id: 'u1', name: 'Ada' },
|
|
6696
|
+
occurredAt: '2026-10-03T00:00:00.000Z',
|
|
6697
|
+
data: { cause: 'cancelled' },
|
|
6698
|
+
},
|
|
6699
|
+
],
|
|
6700
|
+
};
|
|
6701
|
+
const { client } = await bootMcpPair({
|
|
6702
|
+
capabilities: ALL_CAPABILITIES,
|
|
6703
|
+
routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: failed }, [`GET /v1/backlog/${ITEM_ID}/activity`]: history },
|
|
6704
|
+
});
|
|
6705
|
+
|
|
6706
|
+
const item = parseToolResult(
|
|
6707
|
+
await client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } })
|
|
6708
|
+
);
|
|
6709
|
+
expect(item.planFailure).toEqual({
|
|
6710
|
+
cause: 'cancelled',
|
|
6711
|
+
meaning: 'A person stopped the planning (by says who). Ask before planning it again.',
|
|
6712
|
+
at: '2026-10-03T00:00:00.000Z',
|
|
6713
|
+
by: 'Ada',
|
|
6714
|
+
});
|
|
6715
|
+
|
|
6716
|
+
const refused = await bootMcpPair({
|
|
6717
|
+
capabilities: ALL_CAPABILITIES,
|
|
6718
|
+
routes: {
|
|
6719
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: failed },
|
|
6720
|
+
[`GET /v1/backlog/${ITEM_ID}/activity`]: refusal(403, 'FORBIDDEN', 'No'),
|
|
6721
|
+
},
|
|
6722
|
+
});
|
|
6723
|
+
const bare = parseToolResult(
|
|
6724
|
+
await refused.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } })
|
|
6725
|
+
);
|
|
6726
|
+
expect(bare).toEqual(failed);
|
|
6727
|
+
|
|
6728
|
+
// A history whose last failure disagrees with the item is an earlier one: the cause stays, who and when do not.
|
|
6729
|
+
const stale = await bootMcpPair({
|
|
6730
|
+
capabilities: ALL_CAPABILITIES,
|
|
6731
|
+
routes: {
|
|
6732
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: { ...failed, planFailureCause: 'worker_lost' } },
|
|
6733
|
+
[`GET /v1/backlog/${ITEM_ID}/activity`]: history,
|
|
6734
|
+
},
|
|
6735
|
+
});
|
|
6736
|
+
const staleItem = parseToolResult(
|
|
6737
|
+
await stale.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } })
|
|
6738
|
+
);
|
|
6739
|
+
expect(staleItem.planFailure).toEqual({
|
|
6740
|
+
cause: 'worker_lost',
|
|
6741
|
+
meaning: 'Nobody was running the planning any more (the worker stopped). Planning again is the usual next step.',
|
|
6742
|
+
at: null,
|
|
6743
|
+
by: null,
|
|
6744
|
+
});
|
|
6745
|
+
|
|
6746
|
+
// A cancel before the latest plan_started is an earlier attempt's, same cause or not.
|
|
6747
|
+
const retried = await bootMcpPair({
|
|
6748
|
+
capabilities: ALL_CAPABILITIES,
|
|
6749
|
+
routes: {
|
|
6750
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: failed },
|
|
6751
|
+
[`GET /v1/backlog/${ITEM_ID}/activity`]: {
|
|
6752
|
+
data: [
|
|
6753
|
+
...history.data,
|
|
6754
|
+
{
|
|
6755
|
+
kind: 'turn',
|
|
6756
|
+
trigger: 'plan_started',
|
|
6757
|
+
actor: { type: 'user', id: 'u2', name: 'Bo' },
|
|
6758
|
+
occurredAt: '2026-10-04T00:00:00.000Z',
|
|
6759
|
+
data: null,
|
|
6760
|
+
},
|
|
6761
|
+
],
|
|
6762
|
+
},
|
|
6763
|
+
},
|
|
6764
|
+
});
|
|
6765
|
+
expect(
|
|
6766
|
+
parseToolResult(await retried.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } }))
|
|
6767
|
+
).toEqual(failed);
|
|
6768
|
+
|
|
6769
|
+
// No failure in the history, or no history reachable: the item as it is.
|
|
6770
|
+
const silent = await bootMcpPair({
|
|
6771
|
+
capabilities: ALL_CAPABILITIES,
|
|
6772
|
+
routes: {
|
|
6773
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: failed },
|
|
6774
|
+
[`GET /v1/backlog/${ITEM_ID}/activity`]: { data: [history.data[1]] },
|
|
6775
|
+
},
|
|
6776
|
+
});
|
|
6777
|
+
expect(
|
|
6778
|
+
parseToolResult(await silent.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } }))
|
|
6779
|
+
).toEqual(failed);
|
|
6780
|
+
const offline = await bootMcpPair({
|
|
6781
|
+
capabilities: ALL_CAPABILITIES,
|
|
6782
|
+
routes: {
|
|
6783
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: failed },
|
|
6784
|
+
[`GET /v1/backlog/${ITEM_ID}/activity`]: () =>
|
|
6785
|
+
Promise.reject(new TypeError('fetch failed', { cause: 'ECONNRESET' })),
|
|
6786
|
+
},
|
|
6787
|
+
});
|
|
6788
|
+
expect(
|
|
6789
|
+
parseToolResult(await offline.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } }))
|
|
6790
|
+
).toEqual(failed);
|
|
6791
|
+
|
|
6792
|
+
// A plan that did not fail reads no history.
|
|
6793
|
+
const planned = await bootMcpPair({
|
|
6794
|
+
capabilities: ALL_CAPABILITIES,
|
|
6795
|
+
routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: { ...failed, planningStatus: 'planned' } } },
|
|
6796
|
+
});
|
|
6797
|
+
await planned.client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } });
|
|
6798
|
+
expect(planned.fetchSpy.mock.calls.some((c: unknown[]) => String(c[0]).includes('/activity'))).toBe(false);
|
|
6799
|
+
});
|
|
6800
|
+
|
|
6801
|
+
it("lists a product's environments, and refuses a window the pages do not offer before asking the API", async () => {
|
|
6802
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
6803
|
+
capabilities: ALL_CAPABILITIES,
|
|
6804
|
+
routes: {
|
|
6805
|
+
[`GET /v1/products/${PRODUCT_ID}/environments`]: {
|
|
6806
|
+
data: [
|
|
6807
|
+
{
|
|
6808
|
+
id: ENV_ID,
|
|
6809
|
+
name: 'Production',
|
|
6810
|
+
slug: 'prod',
|
|
6811
|
+
type: 'production',
|
|
6812
|
+
ordinal: 2,
|
|
6813
|
+
status: 'active',
|
|
6814
|
+
isProtected: true,
|
|
6815
|
+
url: 'https://x.test',
|
|
6816
|
+
secret: 'no',
|
|
6817
|
+
},
|
|
6818
|
+
],
|
|
6819
|
+
nextCursor: null,
|
|
6820
|
+
},
|
|
6821
|
+
},
|
|
6822
|
+
});
|
|
6823
|
+
|
|
6824
|
+
const envs = parseToolResult(
|
|
6825
|
+
await client.callTool({ name: 'taiga_list_environments', arguments: { productId: PRODUCT_ID } })
|
|
6826
|
+
);
|
|
6827
|
+
expect(envs.data).toEqual([
|
|
6828
|
+
{
|
|
6829
|
+
id: ENV_ID,
|
|
6830
|
+
name: 'Production',
|
|
6831
|
+
slug: 'prod',
|
|
6832
|
+
type: 'production',
|
|
6833
|
+
ordinal: 2,
|
|
6834
|
+
status: 'active',
|
|
6835
|
+
isProtected: true,
|
|
6836
|
+
url: 'https://x.test',
|
|
6837
|
+
},
|
|
6838
|
+
]);
|
|
6839
|
+
|
|
6840
|
+
const odd = await client.callTool({
|
|
6841
|
+
name: 'taiga_get_delivery_metrics',
|
|
6842
|
+
arguments: { productId: PRODUCT_ID, environmentId: ENV_ID, windowDays: 45 },
|
|
6843
|
+
});
|
|
6844
|
+
expect(odd.isError).toBe(true);
|
|
6845
|
+
expect(fetchSpy.mock.calls.some((c: unknown[]) => String(c[0]).includes('/insights'))).toBe(false);
|
|
6846
|
+
const { tools } = await client.listTools();
|
|
6847
|
+
const metrics = tools.find((t) => t.name === 'taiga_get_delivery_metrics')!;
|
|
6848
|
+
const properties = metrics.inputSchema.properties as Record<string, { enum?: unknown }>;
|
|
6849
|
+
expect(properties.windowDays!.enum).toEqual([30, 60, 90]);
|
|
6850
|
+
});
|
|
6851
|
+
|
|
6852
|
+
it('opens every writing tool with what it does, and leaves the reads as they were', async () => {
|
|
6853
|
+
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
6854
|
+
const { tools } = await client.listTools();
|
|
6855
|
+
|
|
6856
|
+
for (const tool of tools) {
|
|
6857
|
+
const notes = tool.annotations ?? {};
|
|
6858
|
+
const description = tool.description ?? '';
|
|
6859
|
+
if (notes.readOnlyHint) expect(description, tool.name).not.toMatch(/^(Writes\.|Acts on paid)/);
|
|
6860
|
+
else if (notes.destructiveHint)
|
|
6861
|
+
expect(description, tool.name).toMatch(/^Acts on paid or shared work: ask the person first\. /);
|
|
6862
|
+
else expect(description, tool.name).toMatch(/^Writes\. /);
|
|
6863
|
+
}
|
|
6864
|
+
expect(tools.find((t) => t.name === 'taiga_queue_backlog_item')!.description).toMatch(/^Acts on paid/);
|
|
6865
|
+
expect(tools.find((t) => t.name === 'taiga_create_factory')!.description).toMatch(/^Writes\. /);
|
|
6866
|
+
});
|
|
6867
|
+
|
|
6868
|
+
it("carries each breached control's findings by tier", async () => {
|
|
6869
|
+
const tiers = { act_now: 0, urgent: 0, elevated: 0, routine: 19 };
|
|
6870
|
+
const { client } = await bootMcpPair({
|
|
6871
|
+
capabilities: ALL_CAPABILITIES,
|
|
6872
|
+
routes: {
|
|
6873
|
+
[`GET /v1/products/${PRODUCT_ID}/maintenance/policies`]: {
|
|
6874
|
+
data: {
|
|
6875
|
+
productId: PRODUCT_ID,
|
|
6876
|
+
hasPolicies: true,
|
|
6877
|
+
controls: [
|
|
6878
|
+
{
|
|
6879
|
+
controlId: 'SEC-3.4',
|
|
6880
|
+
policyType: 'policy_security',
|
|
6881
|
+
text: 'No secrets in code',
|
|
6882
|
+
state: 'breached',
|
|
6883
|
+
findingCount: 19,
|
|
6884
|
+
findingsByTier: tiers,
|
|
6885
|
+
divergenceCount: 0,
|
|
6886
|
+
workItems: [],
|
|
6887
|
+
plannedCount: 0,
|
|
6888
|
+
},
|
|
6889
|
+
],
|
|
6890
|
+
divergences: [],
|
|
6891
|
+
resolvedDivergences: [],
|
|
6892
|
+
},
|
|
6893
|
+
},
|
|
6894
|
+
},
|
|
6895
|
+
});
|
|
6896
|
+
|
|
6897
|
+
const standing = parseToolResult(
|
|
6898
|
+
await client.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID } })
|
|
6899
|
+
);
|
|
6900
|
+
expect(standing.controls[0].findingsByTier).toEqual(tiers);
|
|
6901
|
+
});
|
|
6902
|
+
});
|
package/src/tools/autonomy.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
|
|
|
29
29
|
'product has room. autoMerge: false makes pull requests wait for a person to merge them; null follows ' +
|
|
30
30
|
"the organization and factory above (autonomous merge needs the organization's switch on, and a factory " +
|
|
31
31
|
'can turn it off for everything under it; neither can be opened from here). Defaults, not rules: an ' +
|
|
32
|
-
'initiative can decide otherwise for itself. Changes how work proceeds
|
|
32
|
+
'initiative can decide otherwise for itself. Changes how work proceeds.',
|
|
33
33
|
inputSchema: z.object({
|
|
34
34
|
productId,
|
|
35
35
|
autoBuild: z.boolean().optional().describe('Build landed plans without a review by default'),
|
|
@@ -70,7 +70,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
|
|
|
70
70
|
"repository's own rules pass (required checks, reviews, a code reviewer you made Taiga wait for); false " +
|
|
71
71
|
"makes it wait for a person; null follows the product's default. The final word within what the " +
|
|
72
72
|
'organization and factories allow: where either has turned autonomous merge off, true changes nothing. ' +
|
|
73
|
-
'Taiga merges through your rules, never around them.
|
|
73
|
+
'Taiga merges through your rules, never around them.',
|
|
74
74
|
inputSchema: z.object({
|
|
75
75
|
itemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it'),
|
|
76
76
|
autoMerge: z
|