@taiga-ai/mcp-server 0.16.0-rc.431 → 0.17.0-dev.438
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 +820 -180
- package/package.json +1 -1
- package/scripts/prepare-publish.cjs +4 -3
- package/src/client.ts +1 -0
- package/src/server.integration.test.ts +374 -17
- package/src/tools/autonomy.ts +3 -3
- package/src/tools/backlogs.ts +68 -23
- package/src/tools/builds.ts +44 -10
- package/src/tools/catalogue.test.ts +11 -1
- package/src/tools/catalogue.ts +64 -3
- package/src/tools/context.ts +12 -2
- package/src/tools/documents.ts +17 -8
- package/src/tools/errors.ts +18 -9
- package/src/tools/hints.ts +71 -0
- package/src/tools/inbox.ts +16 -7
- package/src/tools/index.ts +8 -2
- package/src/tools/inputs.ts +16 -4
- package/src/tools/instructions.ts +40 -14
- package/src/tools/jobs.ts +2 -1
- package/src/tools/knowledge.ts +6 -6
- package/src/tools/maintenance.ts +32 -12
- package/src/tools/observe.ts +48 -7
- package/src/tools/products.ts +65 -40
- package/src/tools/prompts.ts +5 -3
- package/src/tools/repositories.ts +11 -14
- package/src/tools/sections.ts +24 -0
- package/src/tools/setup.ts +82 -15
- package/src/tools/summarize.ts +44 -0
- package/src/tools/wait.ts +4 -3
- package/taiga-ai-mcp-server-0.17.0-dev.438.tgz +0 -0
- package/taiga-ai-mcp-server-0.16.0-rc.431.tgz +0 -0
package/package.json
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* - Removes `private: true` so npm allows publishing
|
|
7
7
|
* - Sets publishConfig to public access
|
|
8
|
-
* - Strips workspace-only dependencies. `@taiga/types`
|
|
9
|
-
*
|
|
10
|
-
* dist/index.js
|
|
8
|
+
* - Strips workspace-only dependencies. `@taiga/types` and
|
|
9
|
+
* `@taiga/feature-flags` carry runtime code, but the rolldown build bundles
|
|
10
|
+
* both into dist/index.js, so neither is a real runtime dep. The `zod` that
|
|
11
|
+
* `@taiga/types` imports stays external: it is a declared dependency.
|
|
11
12
|
* - Drops the `types` entry points — the published artifact is a bundled CLI
|
|
12
13
|
* and ships no `.d.ts` (the build runs `tsc --noEmit`).
|
|
13
14
|
* - Marks the bin entry executable. The bundler emits dist/index.js with mode
|
package/src/client.ts
CHANGED
|
@@ -1527,7 +1527,8 @@ describe('MCP server integration', () => {
|
|
|
1527
1527
|
expect(result.isError).toBe(true);
|
|
1528
1528
|
expect(parseToolResult(result)).toEqual({
|
|
1529
1529
|
error: 'NOT_FOUND',
|
|
1530
|
-
message:
|
|
1530
|
+
message:
|
|
1531
|
+
'The requested resource does not exist. taiga_list_products lists the products the key can see; a name is not an ID (taiga_get_product takes a slug).',
|
|
1531
1532
|
});
|
|
1532
1533
|
}
|
|
1533
1534
|
});
|
|
@@ -1775,7 +1776,10 @@ describe('MCP server integration', () => {
|
|
|
1775
1776
|
arguments: { factoryId: FACTORY_ID },
|
|
1776
1777
|
});
|
|
1777
1778
|
|
|
1778
|
-
expect(parseToolResult(result)).toEqual({
|
|
1779
|
+
expect(parseToolResult(result)).toEqual({
|
|
1780
|
+
error: 'NOT_FOUND',
|
|
1781
|
+
message: 'The requested resource does not exist. taiga_list_factories lists the factories the key can see.',
|
|
1782
|
+
});
|
|
1779
1783
|
});
|
|
1780
1784
|
|
|
1781
1785
|
it('should report a factory the key cannot see as a factory miss, not an unpublished document', async () => {
|
|
@@ -1787,7 +1791,10 @@ describe('MCP server integration', () => {
|
|
|
1787
1791
|
arguments: { factoryId: FACTORY_ID },
|
|
1788
1792
|
});
|
|
1789
1793
|
|
|
1790
|
-
expect(parseToolResult(result)).toEqual({
|
|
1794
|
+
expect(parseToolResult(result)).toEqual({
|
|
1795
|
+
error: 'NOT_FOUND',
|
|
1796
|
+
message: 'The requested resource does not exist. taiga_list_factories lists the factories the key can see.',
|
|
1797
|
+
});
|
|
1791
1798
|
});
|
|
1792
1799
|
|
|
1793
1800
|
it('should point at the draft when a factory has one unpublished', async () => {
|
|
@@ -1849,7 +1856,7 @@ describe('MCP server integration', () => {
|
|
|
1849
1856
|
|
|
1850
1857
|
expect(result.isError).toBe(true);
|
|
1851
1858
|
expect(parseToolResult(result)).toMatchObject({
|
|
1852
|
-
error: '
|
|
1859
|
+
error: 'VALIDATION_ERROR',
|
|
1853
1860
|
message: expect.stringContaining('exactly once'),
|
|
1854
1861
|
});
|
|
1855
1862
|
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
@@ -1862,7 +1869,7 @@ describe('MCP server integration', () => {
|
|
|
1862
1869
|
const result = await client.callTool({ name: 'taiga_get_product', arguments: {} });
|
|
1863
1870
|
|
|
1864
1871
|
expect(result.isError).toBe(true);
|
|
1865
|
-
expect(parseToolResult(result).error).toBe('
|
|
1872
|
+
expect(parseToolResult(result).error).toBe('VALIDATION_ERROR');
|
|
1866
1873
|
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
1867
1874
|
});
|
|
1868
1875
|
});
|
|
@@ -1955,7 +1962,6 @@ describe('MCP server integration', () => {
|
|
|
1955
1962
|
slug: 'web',
|
|
1956
1963
|
status: 'active',
|
|
1957
1964
|
factoryId: FACTORY_ID,
|
|
1958
|
-
kind: 'application',
|
|
1959
1965
|
projectType: 'greenfield',
|
|
1960
1966
|
defaultOrigin: 'plan',
|
|
1961
1967
|
});
|
|
@@ -2292,7 +2298,7 @@ describe('MCP server integration', () => {
|
|
|
2292
2298
|
for (const args of [{}, { productId: PRODUCT_ID, slug: 'web' }]) {
|
|
2293
2299
|
const result = await client.callTool({ name: 'taiga_get_product_context', arguments: args });
|
|
2294
2300
|
expect(result.isError).toBe(true);
|
|
2295
|
-
expect(parseToolResult(result).error).toBe('
|
|
2301
|
+
expect(parseToolResult(result).error).toBe('VALIDATION_ERROR');
|
|
2296
2302
|
}
|
|
2297
2303
|
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
2298
2304
|
});
|
|
@@ -2677,7 +2683,7 @@ describe('MCP server integration', () => {
|
|
|
2677
2683
|
expect(failure).toMatchObject({ code: -32602 });
|
|
2678
2684
|
// A slash command with no argument lands here; the message says how to pass one.
|
|
2679
2685
|
expect(String((failure as Error).message)).toMatch(
|
|
2680
|
-
/^
|
|
2686
|
+
/^VALIDATION_ERROR: Name the product exactly once: slug or productId or remoteUrl\. Pass the slug as the first argument \(\/taiga_start_session <slug>/
|
|
2681
2687
|
);
|
|
2682
2688
|
});
|
|
2683
2689
|
|
|
@@ -3394,7 +3400,11 @@ describe('MCP server integration', () => {
|
|
|
3394
3400
|
|
|
3395
3401
|
const none = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: OTHER_ITEM } });
|
|
3396
3402
|
expect(none.isError).toBe(true);
|
|
3397
|
-
expect(outcomeOf(none)).toEqual({
|
|
3403
|
+
expect(outcomeOf(none)).toEqual({
|
|
3404
|
+
error: 'NOT_FOUND',
|
|
3405
|
+
message:
|
|
3406
|
+
'No active plan found. taiga_get_backlog_item says whether one is being generated (planningStatus planning) or failed, and taiga_plan_again asks for a new one.',
|
|
3407
|
+
});
|
|
3398
3408
|
});
|
|
3399
3409
|
|
|
3400
3410
|
it('should approve a plan by its ID and reject one with the comment', async () => {
|
|
@@ -3529,7 +3539,7 @@ describe('MCP server integration', () => {
|
|
|
3529
3539
|
const result = await client.callTool({ name: 'taiga_list_builds', arguments: { backlogItemId: ITEM_ID } });
|
|
3530
3540
|
|
|
3531
3541
|
expect(result.isError).toBe(true);
|
|
3532
|
-
expect(outcomeOf(result)).toMatchObject({ error: '
|
|
3542
|
+
expect(outcomeOf(result)).toMatchObject({ error: 'VALIDATION_ERROR' });
|
|
3533
3543
|
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
3534
3544
|
});
|
|
3535
3545
|
|
|
@@ -3650,7 +3660,7 @@ describe('MCP server integration', () => {
|
|
|
3650
3660
|
});
|
|
3651
3661
|
|
|
3652
3662
|
expect(result.isError).toBe(true);
|
|
3653
|
-
expect(outcomeOf(result)).toMatchObject({ error: '
|
|
3663
|
+
expect(outcomeOf(result)).toMatchObject({ error: 'VALIDATION_ERROR' });
|
|
3654
3664
|
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
3655
3665
|
});
|
|
3656
3666
|
|
|
@@ -5033,8 +5043,10 @@ describe('MCP server integration', () => {
|
|
|
5033
5043
|
expect(summary.controls.map((c: { controlId: string }) => c.controlId)).toEqual(['SEC-3.4']);
|
|
5034
5044
|
expect(summary.violations[0]).toMatchObject({ id: VIOLATION_ID, title: 'Plaintext' });
|
|
5035
5045
|
expect(summary.violations[0]).not.toHaveProperty('evidence');
|
|
5036
|
-
expect(summary.resolvedViolations).toBe(2);
|
|
5046
|
+
expect(summary.summary.resolvedViolations).toBe(2);
|
|
5037
5047
|
expect(full.divergences[0].evidence).toBe('long evidence');
|
|
5048
|
+
// The counts lead the full answer too.
|
|
5049
|
+
expect(Object.keys(full)[0]).toBe('summary');
|
|
5038
5050
|
});
|
|
5039
5051
|
|
|
5040
5052
|
it('should fix a control or one violation through its own route', async () => {
|
|
@@ -6007,7 +6019,11 @@ describe('MCP server integration', () => {
|
|
|
6007
6019
|
expect(finish.isError).toBe(true);
|
|
6008
6020
|
expect(parseToolResult(finish)).toEqual({
|
|
6009
6021
|
error: 'CONFLICT',
|
|
6010
|
-
message:
|
|
6022
|
+
message:
|
|
6023
|
+
'Every required document must be published before setup can be completed. An imported product gets ' +
|
|
6024
|
+
'them from its import (taiga_start_import); a product started from scratch gets the specification from ' +
|
|
6025
|
+
'the Discovery conversation in the web and the rest from taiga_generate_product_documents. A draft is ' +
|
|
6026
|
+
'published with taiga_publish_product_document.',
|
|
6011
6027
|
details: { reason: 'documents_incomplete', missingDocuments: ['threat_model', 'risk_register'] },
|
|
6012
6028
|
});
|
|
6013
6029
|
expect(parseToolResult(move)).toMatchObject({ error: 'CONFLICT', details: { reason: 'work_in_flight' } });
|
|
@@ -6050,18 +6066,18 @@ describe('MCP server integration', () => {
|
|
|
6050
6066
|
});
|
|
6051
6067
|
|
|
6052
6068
|
expect(parseToolResult(unknown)).toMatchObject({ error: 'NOT_FOUND', details: { accounts: expect.any(Array) } });
|
|
6053
|
-
expect(parseToolResult(cursor).error).toBe('
|
|
6069
|
+
expect(parseToolResult(cursor).error).toBe('VALIDATION_ERROR');
|
|
6054
6070
|
const pagedSearch = await client.callTool({
|
|
6055
6071
|
name: 'taiga_list_linkable_repositories',
|
|
6056
6072
|
arguments: { installationId: INSTALL_ID, query: 'bill', cursor: 'abc' },
|
|
6057
6073
|
});
|
|
6058
|
-
expect(parseToolResult(pagedSearch).error).toBe('
|
|
6059
|
-
expect(parseToolResult(notes).error).toBe('
|
|
6074
|
+
expect(parseToolResult(pagedSearch).error).toBe('VALIDATION_ERROR');
|
|
6075
|
+
expect(parseToolResult(notes).error).toBe('VALIDATION_ERROR');
|
|
6060
6076
|
const updateNotes = await client.callTool({
|
|
6061
6077
|
name: 'taiga_update_product',
|
|
6062
6078
|
arguments: { productId: PRODUCT_ID, ownsInfrastructure: true, ownsDeployment: true, deploymentNotes: 'x' },
|
|
6063
6079
|
});
|
|
6064
|
-
expect(parseToolResult(updateNotes).error).toBe('
|
|
6080
|
+
expect(parseToolResult(updateNotes).error).toBe('VALIDATION_ERROR');
|
|
6065
6081
|
const asked = fetchSpy.mock.calls.map((c: unknown[]) => new URL(c[0] as string).pathname);
|
|
6066
6082
|
expect(asked.filter((p: string) => p.includes('/github/installations/'))).toEqual([]);
|
|
6067
6083
|
expect(asked).not.toContain('/v1/products');
|
|
@@ -6176,3 +6192,344 @@ describe('MCP server integration', () => {
|
|
|
6176
6192
|
});
|
|
6177
6193
|
});
|
|
6178
6194
|
});
|
|
6195
|
+
|
|
6196
|
+
// ---------------------------------------------------------------------------
|
|
6197
|
+
// What a fresh agent's review of the server found it needed (PRO-2759): a
|
|
6198
|
+
// miss that says where the right IDs are, a search by name, what a product
|
|
6199
|
+
// in setup waits for, an empty answer that says why, and a run that says
|
|
6200
|
+
// where it stopped.
|
|
6201
|
+
// ---------------------------------------------------------------------------
|
|
6202
|
+
|
|
6203
|
+
describe('what the agent review found (PRO-2759)', () => {
|
|
6204
|
+
const ENV_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a70';
|
|
6205
|
+
const LINK_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a71';
|
|
6206
|
+
|
|
6207
|
+
beforeEach(() => {
|
|
6208
|
+
vi.restoreAllMocks();
|
|
6209
|
+
});
|
|
6210
|
+
|
|
6211
|
+
it("adds to the API's 404 where the right IDs are, one hint per ID the call named, the product last", async () => {
|
|
6212
|
+
const { client } = await bootMcpPair({
|
|
6213
|
+
capabilities: ALL_CAPABILITIES,
|
|
6214
|
+
routes: {
|
|
6215
|
+
[`GET /v1/backlog/${ITEM_ID}`]: refusal(404, 'NOT_FOUND', 'Backlog item not found'),
|
|
6216
|
+
[`GET /v1/products/${PRODUCT_ID}/repositories/${LINK_ID}/branches`]: refusal(
|
|
6217
|
+
404,
|
|
6218
|
+
'NOT_FOUND',
|
|
6219
|
+
'Repository link not found'
|
|
6220
|
+
),
|
|
6221
|
+
},
|
|
6222
|
+
});
|
|
6223
|
+
|
|
6224
|
+
const item = await client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } });
|
|
6225
|
+
expect(parseToolResult(item)).toEqual({
|
|
6226
|
+
error: 'NOT_FOUND',
|
|
6227
|
+
message:
|
|
6228
|
+
"Backlog item not found. taiga_list_backlog_items lists a product's initiatives with their IDs; a run or " +
|
|
6229
|
+
'plan ID is not one.',
|
|
6230
|
+
});
|
|
6231
|
+
|
|
6232
|
+
const branches = await client.callTool({
|
|
6233
|
+
name: 'taiga_list_repository_branches',
|
|
6234
|
+
arguments: { productId: PRODUCT_ID, repositoryId: LINK_ID },
|
|
6235
|
+
});
|
|
6236
|
+
expect(parseToolResult(branches).message).toBe(
|
|
6237
|
+
"Repository link not found. taiga_list_product_repositories lists the product's repository links; a GitHub " +
|
|
6238
|
+
'repository id is not a link ID. taiga_list_products lists the products the key can see; a name is not ' +
|
|
6239
|
+
'an ID (taiga_get_product takes a slug).'
|
|
6240
|
+
);
|
|
6241
|
+
});
|
|
6242
|
+
|
|
6243
|
+
it('sends a mistyped initiative on taiga_get_plan to the initiative list, and only a missing plan to planning', async () => {
|
|
6244
|
+
const OTHER = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a72';
|
|
6245
|
+
const { client } = await bootMcpPair({
|
|
6246
|
+
capabilities: ALL_CAPABILITIES,
|
|
6247
|
+
routes: {
|
|
6248
|
+
[`GET /v1/backlog/${ITEM_ID}/plans/active`]: refusal(404, 'NOT_FOUND', 'No active plan found'),
|
|
6249
|
+
[`GET /v1/backlog/${OTHER}/plans/active`]: refusal(404, 'NOT_FOUND', 'Backlog item not found'),
|
|
6250
|
+
},
|
|
6251
|
+
});
|
|
6252
|
+
|
|
6253
|
+
const noPlan = parseToolResult(await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: ITEM_ID } }));
|
|
6254
|
+
const noItem = parseToolResult(await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: OTHER } }));
|
|
6255
|
+
|
|
6256
|
+
expect(noPlan.message).toContain('taiga_plan_again asks for a new one');
|
|
6257
|
+
expect(noItem.message).toBe(
|
|
6258
|
+
"Backlog item not found. taiga_list_backlog_items lists a product's initiatives with their IDs; a run or " +
|
|
6259
|
+
'plan ID is not one.'
|
|
6260
|
+
);
|
|
6261
|
+
});
|
|
6262
|
+
|
|
6263
|
+
it('says a run that failed before any step started stopped while setting up, not at a step or the PR', async () => {
|
|
6264
|
+
const { client } = await bootMcpPair({
|
|
6265
|
+
capabilities: ALL_CAPABILITIES,
|
|
6266
|
+
routes: {
|
|
6267
|
+
[`GET /v1/builds/${BUILD_ID}`]: { data: { id: BUILD_ID, status: 'failed', hardeningResult: null } },
|
|
6268
|
+
[`GET /v1/builds/${BUILD_ID}/steps`]: { data: [{ stepOrder: 1, title: 'Add the route', status: 'planned' }] },
|
|
6269
|
+
},
|
|
6270
|
+
});
|
|
6271
|
+
|
|
6272
|
+
const result = parseToolResult(
|
|
6273
|
+
await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
|
|
6274
|
+
);
|
|
6275
|
+
|
|
6276
|
+
expect(result.stoppedIn).toMatch(/^before the first step started/);
|
|
6277
|
+
});
|
|
6278
|
+
|
|
6279
|
+
it('says where a product ID comes from when a name is passed for one', async () => {
|
|
6280
|
+
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
6281
|
+
|
|
6282
|
+
const result = await client.callTool({ name: 'taiga_list_backlog_items', arguments: { productId: 'Web shop' } });
|
|
6283
|
+
|
|
6284
|
+
expect(result.isError).toBe(true);
|
|
6285
|
+
expect((result.content as { text: string }[])[0]!.text).toContain(
|
|
6286
|
+
'A product ID is a UUID, not a name: taiga_list_products (query finds one by name)'
|
|
6287
|
+
);
|
|
6288
|
+
});
|
|
6289
|
+
|
|
6290
|
+
it('finds a product by part of its name across pages, and refuses a cursor with a search', async () => {
|
|
6291
|
+
const page1 = [
|
|
6292
|
+
{ id: PRODUCT_ID, name: 'Web shop', slug: 'web-shop', status: 'active', factoryId: FACTORY_ID, kind: null },
|
|
6293
|
+
{ id: 'p-2', name: 'Billing', slug: 'billing', status: 'active', factoryId: FACTORY_ID, kind: null },
|
|
6294
|
+
];
|
|
6295
|
+
const page2 = [{ id: PRODUCT_ID_2, name: 'Shop admin', slug: 'admin', status: 'draft', factoryId: FACTORY_ID }];
|
|
6296
|
+
const { client } = await bootMcpPair({
|
|
6297
|
+
capabilities: ALL_CAPABILITIES,
|
|
6298
|
+
routes: {
|
|
6299
|
+
'/v1/products': (url: string) => {
|
|
6300
|
+
const second = new URL(url).searchParams.get('cursor') === 'c2';
|
|
6301
|
+
const body = second ? { data: page2, nextCursor: null } : { data: page1, nextCursor: 'c2' };
|
|
6302
|
+
return {
|
|
6303
|
+
ok: true,
|
|
6304
|
+
status: 200,
|
|
6305
|
+
json: () => Promise.resolve(body),
|
|
6306
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
6307
|
+
};
|
|
6308
|
+
},
|
|
6309
|
+
},
|
|
6310
|
+
});
|
|
6311
|
+
|
|
6312
|
+
const found = await client.callTool({ name: 'taiga_list_products', arguments: { query: 'SHOP' } });
|
|
6313
|
+
expect(parseToolResult(found)).toEqual({
|
|
6314
|
+
data: [
|
|
6315
|
+
{ id: PRODUCT_ID, name: 'Web shop', slug: 'web-shop', status: 'active', factoryId: FACTORY_ID },
|
|
6316
|
+
{ id: PRODUCT_ID_2, name: 'Shop admin', slug: 'admin', status: 'draft', factoryId: FACTORY_ID },
|
|
6317
|
+
],
|
|
6318
|
+
nextCursor: null,
|
|
6319
|
+
});
|
|
6320
|
+
|
|
6321
|
+
const paged = await client.callTool({ name: 'taiga_list_products', arguments: { query: 'shop', cursor: 'c2' } });
|
|
6322
|
+
expect(parseToolResult(paged).error).toBe('VALIDATION_ERROR');
|
|
6323
|
+
});
|
|
6324
|
+
|
|
6325
|
+
it('reads a policy by section, and keeps the sections that exist when one asked for does not', async () => {
|
|
6326
|
+
const { client } = await bootMcpPair({
|
|
6327
|
+
capabilities: ALL_CAPABILITIES,
|
|
6328
|
+
routes: { [`GET /v1/tenants/current/documents/policy_security`]: { data: policyDocument() } },
|
|
6329
|
+
});
|
|
6330
|
+
|
|
6331
|
+
const result = await client.callTool({
|
|
6332
|
+
name: 'taiga_get_document',
|
|
6333
|
+
arguments: { type: 'policy_security', sections: ['scope', 'secrets'] },
|
|
6334
|
+
});
|
|
6335
|
+
|
|
6336
|
+
const doc = parseToolResult(result);
|
|
6337
|
+
expect(Object.keys(doc.data.sections)).toEqual(['scope']);
|
|
6338
|
+
expect(doc.omittedSections).toEqual([{ id: 'summary', characters: 'Old summary'.length }]);
|
|
6339
|
+
expect(doc.unknownSections).toEqual(['secrets']);
|
|
6340
|
+
|
|
6341
|
+
const none = await client.callTool({
|
|
6342
|
+
name: 'taiga_get_document',
|
|
6343
|
+
arguments: { type: 'policy_security', sections: ['secrets'] },
|
|
6344
|
+
});
|
|
6345
|
+
expect(parseToolResult(none)).toEqual({
|
|
6346
|
+
error: 'NOT_FOUND',
|
|
6347
|
+
message: 'The policy_security has no section secrets; its sections are summary, scope.',
|
|
6348
|
+
});
|
|
6349
|
+
});
|
|
6350
|
+
|
|
6351
|
+
it('says what a product in setup waits for, and warns of a half turned off with nothing said about it', async () => {
|
|
6352
|
+
const draft = {
|
|
6353
|
+
id: PRODUCT_ID,
|
|
6354
|
+
name: 'Web',
|
|
6355
|
+
slug: 'web',
|
|
6356
|
+
status: 'draft',
|
|
6357
|
+
factoryId: FACTORY_ID,
|
|
6358
|
+
discoveryStep: 'context',
|
|
6359
|
+
settings: { projectType: 'brownfield', importStatus: 'pending' },
|
|
6360
|
+
ownsInfrastructure: false,
|
|
6361
|
+
deploymentNotes: null,
|
|
6362
|
+
};
|
|
6363
|
+
const { client } = await bootMcpPair({
|
|
6364
|
+
capabilities: ALL_CAPABILITIES,
|
|
6365
|
+
routes: {
|
|
6366
|
+
[`GET /v1/products/${PRODUCT_ID}`]: { data: draft },
|
|
6367
|
+
[`PATCH /v1/products/${PRODUCT_ID}`]: { data: draft },
|
|
6368
|
+
},
|
|
6369
|
+
});
|
|
6370
|
+
|
|
6371
|
+
const product = parseToolResult(
|
|
6372
|
+
await client.callTool({ name: 'taiga_get_product', arguments: { productId: PRODUCT_ID } })
|
|
6373
|
+
);
|
|
6374
|
+
expect(product.nextStep).toBe(
|
|
6375
|
+
'Link its repository if it has none (taiga_list_linkable_repositories, taiga_link_repository), then ' +
|
|
6376
|
+
'taiga_start_import: the import derives the documents from the code and runs by itself.'
|
|
6377
|
+
);
|
|
6378
|
+
|
|
6379
|
+
const updated = parseToolResult(
|
|
6380
|
+
await client.callTool({
|
|
6381
|
+
name: 'taiga_update_product',
|
|
6382
|
+
arguments: { productId: PRODUCT_ID, ownsInfrastructure: false },
|
|
6383
|
+
})
|
|
6384
|
+
);
|
|
6385
|
+
expect(updated.nextStep).toBe(product.nextStep);
|
|
6386
|
+
expect(updated.warnings).toEqual([expect.stringContaining('has no deploymentNotes')]);
|
|
6387
|
+
});
|
|
6388
|
+
|
|
6389
|
+
it("reads the step of a product the write's answer leaves it out of, and says nothing rather than guess", async () => {
|
|
6390
|
+
const written = { id: PRODUCT_ID, name: 'Web', slug: 'web', status: 'draft', factoryId: FACTORY_ID, settings: {} };
|
|
6391
|
+
const { client } = await bootMcpPair({
|
|
6392
|
+
capabilities: ALL_CAPABILITIES,
|
|
6393
|
+
routes: {
|
|
6394
|
+
[`PATCH /v1/products/${PRODUCT_ID}`]: { data: written },
|
|
6395
|
+
[`GET /v1/products/${PRODUCT_ID}`]: { data: { ...written, discoveryStep: 'documents' } },
|
|
6396
|
+
[`PATCH /v1/products/${PRODUCT_ID_2}`]: { data: { ...written, id: PRODUCT_ID_2 } },
|
|
6397
|
+
},
|
|
6398
|
+
});
|
|
6399
|
+
|
|
6400
|
+
const read = parseToolResult(
|
|
6401
|
+
await client.callTool({ name: 'taiga_update_product', arguments: { productId: PRODUCT_ID, name: 'Web' } })
|
|
6402
|
+
);
|
|
6403
|
+
// The product's read is unreachable here (the mock answers 404): no nextStep, not the conversation's.
|
|
6404
|
+
const unread = parseToolResult(
|
|
6405
|
+
await client.callTool({ name: 'taiga_update_product', arguments: { productId: PRODUCT_ID_2, name: 'Web' } })
|
|
6406
|
+
);
|
|
6407
|
+
|
|
6408
|
+
expect(read.nextStep).toMatch(/^The specification is done/);
|
|
6409
|
+
expect(unread).not.toHaveProperty('nextStep');
|
|
6410
|
+
});
|
|
6411
|
+
|
|
6412
|
+
it('says nothing of where a merged run stopped, whatever its stale status says', async () => {
|
|
6413
|
+
const { client } = await bootMcpPair({
|
|
6414
|
+
capabilities: ALL_CAPABILITIES,
|
|
6415
|
+
routes: {
|
|
6416
|
+
[`GET /v1/builds/${BUILD_ID}`]: {
|
|
6417
|
+
data: { id: BUILD_ID, status: 'failed', prMerged: true, hardeningResult: null },
|
|
6418
|
+
},
|
|
6419
|
+
[`GET /v1/builds/${BUILD_ID}/steps`]: { data: [{ stepOrder: 1, title: 'Add the route', status: 'completed' }] },
|
|
6420
|
+
},
|
|
6421
|
+
});
|
|
6422
|
+
|
|
6423
|
+
const result = parseToolResult(
|
|
6424
|
+
await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
|
|
6425
|
+
);
|
|
6426
|
+
|
|
6427
|
+
expect(result).not.toHaveProperty('stoppedIn');
|
|
6428
|
+
});
|
|
6429
|
+
|
|
6430
|
+
it('lists the setup tasks still to do by default, each naming its product', async () => {
|
|
6431
|
+
const task = {
|
|
6432
|
+
id: 't-1',
|
|
6433
|
+
productId: PRODUCT_ID,
|
|
6434
|
+
productName: 'Web',
|
|
6435
|
+
title: 'Add STRIPE_KEY',
|
|
6436
|
+
inboxItemStatus: 'open',
|
|
6437
|
+
};
|
|
6438
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
6439
|
+
capabilities: ALL_CAPABILITIES,
|
|
6440
|
+
routes: { '/v1/setup-tasks': { data: [task], nextCursor: null } },
|
|
6441
|
+
});
|
|
6442
|
+
|
|
6443
|
+
const open = parseToolResult(await client.callTool({ name: 'taiga_list_setup_tasks', arguments: {} }));
|
|
6444
|
+
await client.callTool({ name: 'taiga_list_setup_tasks', arguments: { includeDone: true } });
|
|
6445
|
+
|
|
6446
|
+
expect(open.data[0]).toMatchObject({ productName: 'Web' });
|
|
6447
|
+
expect(sentUrl(fetchSpy, '/setup-tasks').searchParams.get('openOnly')).toBe('true');
|
|
6448
|
+
const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string).filter((u) => u.includes('/setup-tasks'));
|
|
6449
|
+
expect(urls[1]).not.toContain('openOnly');
|
|
6450
|
+
});
|
|
6451
|
+
|
|
6452
|
+
it('says where a failed run stopped: in hardening, when every step completed', async () => {
|
|
6453
|
+
const run = { id: BUILD_ID, status: 'failed', failedReason: 'agent_stalled', hardeningResult: null };
|
|
6454
|
+
const { client } = await bootMcpPair({
|
|
6455
|
+
capabilities: ALL_CAPABILITIES,
|
|
6456
|
+
routes: {
|
|
6457
|
+
[`GET /v1/builds/${BUILD_ID}`]: { data: run },
|
|
6458
|
+
[`GET /v1/builds/${BUILD_ID}/steps`]: { data: [{ stepOrder: 1, title: 'Add the route', status: 'completed' }] },
|
|
6459
|
+
},
|
|
6460
|
+
});
|
|
6461
|
+
|
|
6462
|
+
const result = parseToolResult(
|
|
6463
|
+
await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
|
|
6464
|
+
);
|
|
6465
|
+
|
|
6466
|
+
expect(result.stoppedIn).toMatch(/^after the last step, in hardening/);
|
|
6467
|
+
});
|
|
6468
|
+
|
|
6469
|
+
it('says why an empty deployments answer is empty, and refuses an environment that is not the product’s', async () => {
|
|
6470
|
+
const { client } = await bootMcpPair({
|
|
6471
|
+
capabilities: ALL_CAPABILITIES,
|
|
6472
|
+
routes: {
|
|
6473
|
+
[`GET /v1/products/${PRODUCT_ID}/environments`]: { data: [], nextCursor: null },
|
|
6474
|
+
[`GET /v1/products/${PRODUCT_ID}/deployments/current`]: { data: { current: [], deploying: [] } },
|
|
6475
|
+
[`GET /v1/products/${PRODUCT_ID}/deployments`]: { data: [], nextCursor: null },
|
|
6476
|
+
[`GET /v1/products/${PRODUCT_ID}/deployments/sync-status`]: { data: {} },
|
|
6477
|
+
[`GET /v1/products/${PRODUCT_ID}/monitoring/health`]: { data: [] },
|
|
6478
|
+
},
|
|
6479
|
+
});
|
|
6480
|
+
|
|
6481
|
+
const deployments = parseToolResult(
|
|
6482
|
+
await client.callTool({ name: 'taiga_get_deployments', arguments: { productId: PRODUCT_ID } })
|
|
6483
|
+
);
|
|
6484
|
+
expect(deployments.note).toMatch(/^This product has no environments yet/);
|
|
6485
|
+
|
|
6486
|
+
const health = parseToolResult(
|
|
6487
|
+
await client.callTool({ name: 'taiga_get_monitoring', arguments: { productId: PRODUCT_ID } })
|
|
6488
|
+
);
|
|
6489
|
+
expect(health.note).toMatch(/^No environment is monitored/);
|
|
6490
|
+
|
|
6491
|
+
// Every signal answers 404, as the API does for another product's environment.
|
|
6492
|
+
const wrong = parseToolResult(
|
|
6493
|
+
await client.callTool({
|
|
6494
|
+
name: 'taiga_get_monitoring',
|
|
6495
|
+
arguments: { productId: PRODUCT_ID, environmentId: ENV_ID },
|
|
6496
|
+
})
|
|
6497
|
+
);
|
|
6498
|
+
expect(wrong.error).toBe('NOT_FOUND');
|
|
6499
|
+
});
|
|
6500
|
+
|
|
6501
|
+
it('leads the policy standing with its counts', async () => {
|
|
6502
|
+
const { client } = await bootMcpPair({
|
|
6503
|
+
capabilities: ALL_CAPABILITIES,
|
|
6504
|
+
routes: {
|
|
6505
|
+
[`GET /v1/products/${PRODUCT_ID}/maintenance/policies`]: {
|
|
6506
|
+
data: {
|
|
6507
|
+
productId: PRODUCT_ID,
|
|
6508
|
+
hasPolicies: true,
|
|
6509
|
+
controls: [
|
|
6510
|
+
{ controlId: 'SEC-5.1', divergenceCount: 2 },
|
|
6511
|
+
{ controlId: 'SEC-1.1', divergenceCount: 0 },
|
|
6512
|
+
],
|
|
6513
|
+
divergences: [
|
|
6514
|
+
{ id: 'v1', controlId: 'SEC-5.1', severity: 'high' },
|
|
6515
|
+
{ id: 'v2', controlId: 'SEC-5.1', severity: 'critical' },
|
|
6516
|
+
],
|
|
6517
|
+
resolvedDivergences: [{}],
|
|
6518
|
+
},
|
|
6519
|
+
},
|
|
6520
|
+
},
|
|
6521
|
+
});
|
|
6522
|
+
|
|
6523
|
+
const standing = parseToolResult(
|
|
6524
|
+
await client.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID } })
|
|
6525
|
+
);
|
|
6526
|
+
|
|
6527
|
+
expect(Object.keys(standing)[0]).toBe('summary');
|
|
6528
|
+
expect(standing.summary).toEqual({
|
|
6529
|
+
controls: 1,
|
|
6530
|
+
violations: 2,
|
|
6531
|
+
bySeverity: { high: 1, critical: 1 },
|
|
6532
|
+
resolvedViolations: 1,
|
|
6533
|
+
});
|
|
6534
|
+
});
|
|
6535
|
+
});
|
package/src/tools/autonomy.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import type { TaigaClient } from '../client.js';
|
|
4
4
|
import { OPERATE_SETTING } from './annotations.js';
|
|
5
5
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
6
|
-
import { productId } from './inputs.js';
|
|
6
|
+
import { idInput, productId } from './inputs.js';
|
|
7
7
|
import { pickFields } from './summarize.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -44,7 +44,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
|
|
|
44
44
|
(params) =>
|
|
45
45
|
runTool(async () => {
|
|
46
46
|
if (params.autoBuild === undefined && params.autoMerge === undefined) {
|
|
47
|
-
return toolError('
|
|
47
|
+
return toolError('VALIDATION_ERROR', 'Set autoBuild, autoMerge or both.');
|
|
48
48
|
}
|
|
49
49
|
// `true` and `null` mean the same at the product (the scopes above
|
|
50
50
|
// decide), and the web writes null to allow; so does this.
|
|
@@ -72,7 +72,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
|
|
|
72
72
|
'organization and factories allow: where either has turned autonomous merge off, true changes nothing. ' +
|
|
73
73
|
'Taiga merges through your rules, never around them. Ask the person first.',
|
|
74
74
|
inputSchema: z.object({
|
|
75
|
-
itemId:
|
|
75
|
+
itemId: idInput('backlog item', 'taiga_list_backlog_items (query finds one by title) gives it'),
|
|
76
76
|
autoMerge: z
|
|
77
77
|
.boolean()
|
|
78
78
|
.nullable()
|