@taiga-ai/mcp-server 0.6.0-rc.393 → 0.7.0-dev.395
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 +1151 -128
- package/package.json +1 -1
- package/src/client.test.ts +19 -0
- package/src/client.ts +11 -3
- package/src/config.test.ts +1 -1
- package/src/config.ts +4 -1
- package/src/index.ts +3 -30
- package/src/server.integration.test.ts +644 -9
- package/src/tools/catalogue.test.ts +109 -0
- package/src/tools/catalogue.ts +165 -0
- package/src/tools/documents.ts +115 -35
- package/src/tools/instructions.ts +65 -0
- package/src/tools/products.ts +234 -43
- package/src/tools/sections.test.ts +34 -1
- package/src/tools/sections.ts +23 -1
- package/src/tools/summarize.ts +3 -34
- package/taiga-ai-mcp-server-0.7.0-dev.395.tgz +0 -0
- package/src/tools/inputs.test.ts +0 -19
- package/src/tools/inputs.ts +0 -13
- package/taiga-ai-mcp-server-0.6.0-rc.393.tgz +0 -0
|
@@ -88,11 +88,11 @@ function sentBody(fetchSpy: ReturnType<typeof vi.fn>, method: string, pathPart:
|
|
|
88
88
|
|
|
89
89
|
function makeFetchRouter(routes: Record<string, unknown>) {
|
|
90
90
|
return vi.fn().mockImplementation(async (url: string, init: RequestInit) => {
|
|
91
|
-
const path = new URL(url)
|
|
91
|
+
const { pathname: path, search } = new URL(url);
|
|
92
92
|
const method = init?.method ?? 'GET';
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const body = routes[
|
|
93
|
+
// Most specific first: a route keyed with its query string (the two
|
|
94
|
+
// document timelines share a path) wins over one keyed by path alone.
|
|
95
|
+
const body = routes[`${method} ${path}${search}`] ?? routes[`${method} ${path}`] ?? routes[path];
|
|
96
96
|
if (body !== undefined) {
|
|
97
97
|
return {
|
|
98
98
|
ok: true,
|
|
@@ -370,17 +370,33 @@ describe('MCP server integration', () => {
|
|
|
370
370
|
expect(parseToolResult(result)).toEqual(factories);
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
-
it('should
|
|
374
|
-
const
|
|
373
|
+
it('should list company documents as summaries without content or authorship', async () => {
|
|
374
|
+
const doc = {
|
|
375
|
+
...policyDocument(),
|
|
376
|
+
description: 'Controls for code and infrastructure',
|
|
377
|
+
hasDraft: true,
|
|
378
|
+
createdBy: 'user-1',
|
|
379
|
+
updatedAt: '2026-10-01T00:00:00.000Z',
|
|
380
|
+
};
|
|
375
381
|
|
|
376
382
|
const { client } = await bootMcpPair({
|
|
377
383
|
capabilities: ALL_CAPABILITIES,
|
|
378
|
-
routes: { 'GET /v1/tenants/current/documents': { data:
|
|
384
|
+
routes: { 'GET /v1/tenants/current/documents': { data: [doc], nextCursor: null } },
|
|
379
385
|
});
|
|
380
386
|
|
|
381
387
|
const result = await client.callTool({ name: 'taiga_list_documents', arguments: {} });
|
|
382
388
|
|
|
383
|
-
expect(parseToolResult(result)).toEqual(
|
|
389
|
+
expect(parseToolResult(result)).toEqual([
|
|
390
|
+
{
|
|
391
|
+
type: 'policy_security',
|
|
392
|
+
name: 'Security Policy',
|
|
393
|
+
description: 'Controls for code and infrastructure',
|
|
394
|
+
version: 3,
|
|
395
|
+
isDraft: false,
|
|
396
|
+
hasDraft: true,
|
|
397
|
+
updatedAt: '2026-10-01T00:00:00.000Z',
|
|
398
|
+
},
|
|
399
|
+
]);
|
|
384
400
|
});
|
|
385
401
|
|
|
386
402
|
it('should replace one section of the existing draft and keep the rest', async () => {
|
|
@@ -628,7 +644,7 @@ describe('MCP server integration', () => {
|
|
|
628
644
|
|
|
629
645
|
const result = await client.callTool({
|
|
630
646
|
name: 'taiga_update_document',
|
|
631
|
-
arguments: { type: '
|
|
647
|
+
arguments: { type: 'policy_security', sectionId: 'summary', content: 'x' },
|
|
632
648
|
});
|
|
633
649
|
|
|
634
650
|
expect(result.isError).toBe(true);
|
|
@@ -1040,6 +1056,625 @@ describe('MCP server integration', () => {
|
|
|
1040
1056
|
});
|
|
1041
1057
|
});
|
|
1042
1058
|
|
|
1059
|
+
// -------------------------------------------------------------------------
|
|
1060
|
+
// Plan and reality (PRO-2570): the two document timelines, summaries that
|
|
1061
|
+
// say which is which, and misses that say what does exist.
|
|
1062
|
+
// -------------------------------------------------------------------------
|
|
1063
|
+
|
|
1064
|
+
describe('plan and reality documents', () => {
|
|
1065
|
+
const planSpec = {
|
|
1066
|
+
id: 'doc-spec',
|
|
1067
|
+
type: 'specification',
|
|
1068
|
+
version: 2,
|
|
1069
|
+
isDraft: false,
|
|
1070
|
+
hasDraft: false,
|
|
1071
|
+
stage: 'planning',
|
|
1072
|
+
origin: 'plan',
|
|
1073
|
+
dataKeyCount: 7,
|
|
1074
|
+
derivedFrom: { sources: [{ type: 'business_goals', version: 1 }] },
|
|
1075
|
+
outdated: { sources: [{ type: 'business_goals', from: 1, to: 2 }] },
|
|
1076
|
+
createdBy: 'user-1',
|
|
1077
|
+
reviewedAt: '2026-09-30T00:00:00.000Z',
|
|
1078
|
+
reviewedByName: 'Reviewer',
|
|
1079
|
+
updatedAt: '2026-10-01T00:00:00.000Z',
|
|
1080
|
+
};
|
|
1081
|
+
const realityArchitecture = {
|
|
1082
|
+
...planSpec,
|
|
1083
|
+
id: 'doc-arch',
|
|
1084
|
+
type: 'architecture',
|
|
1085
|
+
version: 1,
|
|
1086
|
+
origin: 'reality',
|
|
1087
|
+
outdated: undefined,
|
|
1088
|
+
};
|
|
1089
|
+
const planList = `GET /v1/products/${PRODUCT_ID}/documents?origin=plan&limit=100`;
|
|
1090
|
+
const realityList = `GET /v1/products/${PRODUCT_ID}/documents?origin=reality&limit=100`;
|
|
1091
|
+
const productRoute = `GET /v1/products/${PRODUCT_ID}`;
|
|
1092
|
+
const greenfield = { data: { id: PRODUCT_ID, name: 'Web', slug: 'web', settings: {} } };
|
|
1093
|
+
const brownfield = {
|
|
1094
|
+
data: { id: PRODUCT_ID, name: 'Legacy', slug: 'legacy', settings: { projectType: 'brownfield' } },
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
it('should list both timelines as summaries and explain the empty one', async () => {
|
|
1098
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
1099
|
+
capabilities: ['products:read'],
|
|
1100
|
+
routes: {
|
|
1101
|
+
[productRoute]: greenfield,
|
|
1102
|
+
[planList]: { data: [planSpec], nextCursor: null },
|
|
1103
|
+
[realityList]: { data: [], nextCursor: null },
|
|
1104
|
+
},
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
const result = await client.callTool({
|
|
1108
|
+
name: 'taiga_list_product_documents',
|
|
1109
|
+
arguments: { productId: PRODUCT_ID },
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
expect(result.isError).toBeFalsy();
|
|
1113
|
+
const parsed = parseToolResult(result);
|
|
1114
|
+
expect(parsed.projectType).toBe('greenfield');
|
|
1115
|
+
expect(parsed.defaultOrigin).toBe('plan');
|
|
1116
|
+
expect(parsed.plan).toEqual([
|
|
1117
|
+
{
|
|
1118
|
+
type: 'specification',
|
|
1119
|
+
name: 'Specification',
|
|
1120
|
+
origin: 'plan',
|
|
1121
|
+
version: 2,
|
|
1122
|
+
isDraft: false,
|
|
1123
|
+
hasDraft: false,
|
|
1124
|
+
stage: 'planning',
|
|
1125
|
+
reviewedAt: '2026-09-30T00:00:00.000Z',
|
|
1126
|
+
updatedAt: '2026-10-01T00:00:00.000Z',
|
|
1127
|
+
outdated: true,
|
|
1128
|
+
},
|
|
1129
|
+
]);
|
|
1130
|
+
expect(parsed.reality).toEqual([]);
|
|
1131
|
+
expect(parsed.notes).toHaveLength(1);
|
|
1132
|
+
expect(parsed.notes[0]).toMatch(/No reality documents yet.*merged pull request/);
|
|
1133
|
+
const origins = fetchSpy.mock.calls
|
|
1134
|
+
.map((c: unknown[]) => new URL(c[0] as string))
|
|
1135
|
+
.filter((u: URL) => u.pathname.endsWith('/documents'))
|
|
1136
|
+
.map((u: URL) => u.searchParams.get('origin'))
|
|
1137
|
+
.sort();
|
|
1138
|
+
expect(origins).toEqual(['plan', 'reality']);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it('should read the asked timeline and drop provenance from what it returns', async () => {
|
|
1142
|
+
const doc = {
|
|
1143
|
+
...realityArchitecture,
|
|
1144
|
+
data: {
|
|
1145
|
+
sections: { overview: { source: 'derived', priority: 'core', content: 'As built', derivedFrom: ['x'] } },
|
|
1146
|
+
},
|
|
1147
|
+
};
|
|
1148
|
+
const { client } = await bootMcpPair({
|
|
1149
|
+
capabilities: ['products:read'],
|
|
1150
|
+
routes: { [`GET /v1/products/${PRODUCT_ID}/documents/architecture?origin=reality`]: { data: doc } },
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
const result = await client.callTool({
|
|
1154
|
+
name: 'taiga_get_product_document',
|
|
1155
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture', origin: 'reality' },
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
expect(result.isError).toBeFalsy();
|
|
1159
|
+
const parsed = parseToolResult(result);
|
|
1160
|
+
expect(parsed).not.toHaveProperty('derivedFrom');
|
|
1161
|
+
expect(parsed).not.toHaveProperty('outdated');
|
|
1162
|
+
expect(parsed.data.sections.overview).toEqual({ source: 'derived', priority: 'core', content: 'As built' });
|
|
1163
|
+
expect(parsed.origin).toBe('reality');
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
it('should read the plan by default for a greenfield product', async () => {
|
|
1167
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
1168
|
+
capabilities: ['products:read'],
|
|
1169
|
+
routes: {
|
|
1170
|
+
[productRoute]: greenfield,
|
|
1171
|
+
[`GET /v1/products/${PRODUCT_ID}/documents/specification?origin=plan`]: { data: planSpec },
|
|
1172
|
+
},
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
const result = await client.callTool({
|
|
1176
|
+
name: 'taiga_get_product_document',
|
|
1177
|
+
arguments: { productId: PRODUCT_ID, type: 'specification' },
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
expect(result.isError).toBeFalsy();
|
|
1181
|
+
expect(sentUrl(fetchSpy, '/documents/specification').searchParams.get('origin')).toBe('plan');
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
it('should say which timeline has the document when the asked one does not', async () => {
|
|
1185
|
+
const { client } = await bootMcpPair({
|
|
1186
|
+
capabilities: ['products:read'],
|
|
1187
|
+
routes: {
|
|
1188
|
+
[productRoute]: greenfield,
|
|
1189
|
+
[planList]: { data: [planSpec], nextCursor: null },
|
|
1190
|
+
[realityList]: { data: [realityArchitecture], nextCursor: null },
|
|
1191
|
+
},
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
const result = await client.callTool({
|
|
1195
|
+
name: 'taiga_get_product_document',
|
|
1196
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture' },
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
expect(result.isError).toBe(true);
|
|
1200
|
+
const { error, message } = parseToolResult(result);
|
|
1201
|
+
expect(error).toBe('NOT_FOUND');
|
|
1202
|
+
expect(message).toContain('No published plan Architecture (architecture)');
|
|
1203
|
+
expect(message).toContain("It is published on the reality side: call again with origin 'reality'");
|
|
1204
|
+
expect(message).toContain('Plan documents: specification (v2)');
|
|
1205
|
+
expect(message).toContain('Reality documents: architecture (v1)');
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it('should explain an empty reality timeline when a reality read misses', async () => {
|
|
1209
|
+
const { client } = await bootMcpPair({
|
|
1210
|
+
capabilities: ['products:read'],
|
|
1211
|
+
routes: {
|
|
1212
|
+
[productRoute]: greenfield,
|
|
1213
|
+
[planList]: { data: [planSpec], nextCursor: null },
|
|
1214
|
+
[realityList]: { data: [], nextCursor: null },
|
|
1215
|
+
},
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
const result = await client.callTool({
|
|
1219
|
+
name: 'taiga_get_product_document',
|
|
1220
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture', origin: 'reality' },
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
expect(parseToolResult(result).message).toMatch(/No reality documents yet.*merged pull request/);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it('should read reality by default for an imported product, whose plan side is empty by design', async () => {
|
|
1227
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
1228
|
+
capabilities: ['products:read'],
|
|
1229
|
+
routes: {
|
|
1230
|
+
[productRoute]: brownfield,
|
|
1231
|
+
[`GET /v1/products/${PRODUCT_ID}/documents/architecture?origin=reality`]: { data: realityArchitecture },
|
|
1232
|
+
[planList]: { data: [], nextCursor: null },
|
|
1233
|
+
[realityList]: { data: [realityArchitecture], nextCursor: null },
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
const read = await client.callTool({
|
|
1238
|
+
name: 'taiga_get_product_document',
|
|
1239
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture' },
|
|
1240
|
+
});
|
|
1241
|
+
const list = await client.callTool({
|
|
1242
|
+
name: 'taiga_list_product_documents',
|
|
1243
|
+
arguments: { productId: PRODUCT_ID },
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
expect(read.isError).toBeFalsy();
|
|
1247
|
+
expect(sentUrl(fetchSpy, '/documents/architecture').searchParams.get('origin')).toBe('reality');
|
|
1248
|
+
const parsed = parseToolResult(list);
|
|
1249
|
+
expect(parsed.projectType).toBe('brownfield');
|
|
1250
|
+
expect(parsed.defaultOrigin).toBe('reality');
|
|
1251
|
+
expect(parsed.notes).toEqual([
|
|
1252
|
+
expect.stringMatching(/imported.*no discovery chain.*Look & Feel, Product Instructions/),
|
|
1253
|
+
]);
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it('should read product instructions from the plan side even on an imported product', async () => {
|
|
1257
|
+
const instructions = {
|
|
1258
|
+
...planSpec,
|
|
1259
|
+
type: 'product_instructions',
|
|
1260
|
+
data: { sections: { instructions: { content: 'x' } } },
|
|
1261
|
+
};
|
|
1262
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
1263
|
+
capabilities: ['products:read'],
|
|
1264
|
+
routes: {
|
|
1265
|
+
[productRoute]: brownfield,
|
|
1266
|
+
[`GET /v1/products/${PRODUCT_ID}/documents/product_instructions?origin=plan`]: { data: instructions },
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
const result = await client.callTool({
|
|
1271
|
+
name: 'taiga_get_product_document',
|
|
1272
|
+
arguments: { productId: PRODUCT_ID, type: 'product_instructions' },
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
expect(result.isError).toBeFalsy();
|
|
1276
|
+
expect(sentUrl(fetchSpy, '/documents/product_instructions').searchParams.get('origin')).toBe('plan');
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it('should not call a draft-only row published when a read misses', async () => {
|
|
1280
|
+
const planDraft = { ...planSpec, isDraft: true, version: null };
|
|
1281
|
+
const { client } = await bootMcpPair({
|
|
1282
|
+
capabilities: ['products:read'],
|
|
1283
|
+
routes: {
|
|
1284
|
+
[productRoute]: greenfield,
|
|
1285
|
+
[planList]: { data: [planDraft], nextCursor: null },
|
|
1286
|
+
[realityList]: { data: [], nextCursor: null },
|
|
1287
|
+
},
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
const result = await client.callTool({
|
|
1291
|
+
name: 'taiga_get_product_document',
|
|
1292
|
+
arguments: { productId: PRODUCT_ID, type: 'specification', origin: 'plan' },
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
const { message } = parseToolResult(result);
|
|
1296
|
+
expect(message).toContain('A draft of it exists on the plan side, unpublished.');
|
|
1297
|
+
expect(message).toContain('Plan documents: specification (draft).');
|
|
1298
|
+
expect(message).not.toContain('call again with origin');
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
it('should report the API failing when neither side can be listed', async () => {
|
|
1302
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
1303
|
+
capabilities: ['products:read'],
|
|
1304
|
+
routes: { [productRoute]: greenfield },
|
|
1305
|
+
});
|
|
1306
|
+
const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
|
|
1307
|
+
fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
|
|
1308
|
+
if (!new URL(url).pathname.endsWith('/documents')) return router(url, init);
|
|
1309
|
+
const body = { error: 'Too Many Requests', code: 'RATE_LIMITED', message: 'Slow down' };
|
|
1310
|
+
return { ok: false, status: 429, json: async () => body, text: async () => JSON.stringify(body) };
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
const result = await client.callTool({
|
|
1314
|
+
name: 'taiga_get_product_document',
|
|
1315
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture' },
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
expect(parseToolResult(result)).toEqual({ error: 'RATE_LIMITED', message: 'Slow down' });
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it('should report a product the key cannot see as a product miss, not a document miss', async () => {
|
|
1322
|
+
// No product route: GET /products/:id answers the API's 404.
|
|
1323
|
+
const { client } = await bootMcpPair({ capabilities: ['products:read'] });
|
|
1324
|
+
|
|
1325
|
+
const withOrigin = await client.callTool({
|
|
1326
|
+
name: 'taiga_get_product_document',
|
|
1327
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture', origin: 'plan' },
|
|
1328
|
+
});
|
|
1329
|
+
const withoutOrigin = await client.callTool({
|
|
1330
|
+
name: 'taiga_get_product_document',
|
|
1331
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture' },
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
for (const result of [withOrigin, withoutOrigin]) {
|
|
1335
|
+
expect(result.isError).toBe(true);
|
|
1336
|
+
expect(parseToolResult(result)).toEqual({
|
|
1337
|
+
error: 'NOT_FOUND',
|
|
1338
|
+
message: 'The requested resource does not exist',
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
it('should name a draft on the other side too', async () => {
|
|
1344
|
+
const planDraft = { ...planSpec, type: 'architecture', isDraft: true, version: null };
|
|
1345
|
+
const { client } = await bootMcpPair({
|
|
1346
|
+
capabilities: ['products:read'],
|
|
1347
|
+
routes: {
|
|
1348
|
+
[productRoute]: greenfield,
|
|
1349
|
+
[planList]: { data: [planDraft], nextCursor: null },
|
|
1350
|
+
[realityList]: { data: [], nextCursor: null },
|
|
1351
|
+
},
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
const result = await client.callTool({
|
|
1355
|
+
name: 'taiga_get_product_document',
|
|
1356
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture', origin: 'reality' },
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
expect(parseToolResult(result).message).toContain('A draft of it exists on the plan side, unpublished.');
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
it.each([
|
|
1363
|
+
['pending', /import has not started/],
|
|
1364
|
+
['analyzing', /import is running/],
|
|
1365
|
+
['generating_docs', /import is running/],
|
|
1366
|
+
['cancelled', /import was cancelled/],
|
|
1367
|
+
['failed', /import failed/],
|
|
1368
|
+
['complete', /none have been published/],
|
|
1369
|
+
])('should say where an import at %s left an empty reality side', async (importStatus, expected) => {
|
|
1370
|
+
const { client } = await bootMcpPair({
|
|
1371
|
+
capabilities: ['products:read'],
|
|
1372
|
+
routes: {
|
|
1373
|
+
[productRoute]: { data: { ...brownfield.data, settings: { projectType: 'brownfield', importStatus } } },
|
|
1374
|
+
[planList]: { data: [], nextCursor: null },
|
|
1375
|
+
[realityList]: { data: [], nextCursor: null },
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
const result = await client.callTool({
|
|
1380
|
+
name: 'taiga_list_product_documents',
|
|
1381
|
+
arguments: { productId: PRODUCT_ID },
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
expect(parseToolResult(result).notes[1]).toMatch(expected);
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
it('should read the product once when origin is omitted and the read misses', async () => {
|
|
1388
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
1389
|
+
capabilities: ['products:read'],
|
|
1390
|
+
routes: {
|
|
1391
|
+
[productRoute]: greenfield,
|
|
1392
|
+
[planList]: { data: [], nextCursor: null },
|
|
1393
|
+
[realityList]: { data: [], nextCursor: null },
|
|
1394
|
+
},
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
await client.callTool({
|
|
1398
|
+
name: 'taiga_get_product_document',
|
|
1399
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture' },
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
const productReads = fetchSpy.mock.calls.filter(
|
|
1403
|
+
(c: unknown[]) => new URL(c[0] as string).pathname === `/v1/products/${PRODUCT_ID}`
|
|
1404
|
+
);
|
|
1405
|
+
expect(productReads).toHaveLength(1);
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
it('should mark a current plan document as not outdated, and leave reality rows without the fact', async () => {
|
|
1409
|
+
const { client } = await bootMcpPair({
|
|
1410
|
+
capabilities: ['products:read'],
|
|
1411
|
+
routes: {
|
|
1412
|
+
[productRoute]: greenfield,
|
|
1413
|
+
[planList]: { data: [{ ...planSpec, outdated: null }], nextCursor: null },
|
|
1414
|
+
[realityList]: { data: [realityArchitecture], nextCursor: null },
|
|
1415
|
+
},
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
const parsed = parseToolResult(
|
|
1419
|
+
await client.callTool({ name: 'taiga_list_product_documents', arguments: { productId: PRODUCT_ID } })
|
|
1420
|
+
);
|
|
1421
|
+
|
|
1422
|
+
expect(parsed.plan[0].outdated).toBe(false);
|
|
1423
|
+
expect(parsed.reality[0]).not.toHaveProperty('outdated');
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
it('should give a plan draft no staleness fact: it has nothing published to be outdated', async () => {
|
|
1427
|
+
const planDraft = { ...planSpec, isDraft: true, version: null, outdated: null };
|
|
1428
|
+
const { client } = await bootMcpPair({
|
|
1429
|
+
capabilities: ['products:read'],
|
|
1430
|
+
routes: {
|
|
1431
|
+
[productRoute]: greenfield,
|
|
1432
|
+
[planList]: { data: [planDraft], nextCursor: null },
|
|
1433
|
+
[realityList]: { data: [], nextCursor: null },
|
|
1434
|
+
},
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
const parsed = parseToolResult(
|
|
1438
|
+
await client.callTool({ name: 'taiga_list_product_documents', arguments: { productId: PRODUCT_ID } })
|
|
1439
|
+
);
|
|
1440
|
+
|
|
1441
|
+
expect(parsed.plan[0]).not.toHaveProperty('outdated');
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it('should keep the side that answered when the other cannot be listed', async () => {
|
|
1445
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
1446
|
+
capabilities: ['products:read'],
|
|
1447
|
+
routes: { [productRoute]: greenfield, [planList]: { data: [planSpec], nextCursor: null } },
|
|
1448
|
+
});
|
|
1449
|
+
const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
|
|
1450
|
+
fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
|
|
1451
|
+
if (new URL(url).searchParams.get('origin') !== 'reality') return router(url, init);
|
|
1452
|
+
const body = { error: 'Internal Server Error', code: 'INTERNAL', message: 'boom' };
|
|
1453
|
+
return { ok: false, status: 500, json: async () => body, text: async () => JSON.stringify(body) };
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
const list = parseToolResult(
|
|
1457
|
+
await client.callTool({ name: 'taiga_list_product_documents', arguments: { productId: PRODUCT_ID } })
|
|
1458
|
+
);
|
|
1459
|
+
const miss = parseToolResult(
|
|
1460
|
+
await client.callTool({
|
|
1461
|
+
name: 'taiga_get_product_document',
|
|
1462
|
+
arguments: { productId: PRODUCT_ID, type: 'architecture', origin: 'plan' },
|
|
1463
|
+
})
|
|
1464
|
+
);
|
|
1465
|
+
|
|
1466
|
+
expect(list.plan).toHaveLength(1);
|
|
1467
|
+
expect(list.reality).toBeNull();
|
|
1468
|
+
expect(list).not.toHaveProperty('unlistable');
|
|
1469
|
+
expect(list.notes).toEqual(['The reality side could not be listed (500 INTERNAL).']);
|
|
1470
|
+
expect(miss.message).toContain('Plan documents: specification (v2).');
|
|
1471
|
+
expect(miss.message).toContain('The reality side could not be listed (500 INTERNAL).');
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
it('should refuse a document type outside the catalogue before any request', async () => {
|
|
1475
|
+
const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
|
|
1476
|
+
const before = fetchSpy.mock.calls.length;
|
|
1477
|
+
|
|
1478
|
+
const result = await client.callTool({
|
|
1479
|
+
name: 'taiga_get_product_document',
|
|
1480
|
+
arguments: { productId: PRODUCT_ID, type: 'policy_ip' },
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
expect(result.isError).toBe(true);
|
|
1484
|
+
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
1485
|
+
});
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
describe('misses that say what exists', () => {
|
|
1489
|
+
it('should name the draft and the published documents when a company document is not published', async () => {
|
|
1490
|
+
const { client } = await bootMcpPair({
|
|
1491
|
+
capabilities: ['tenant:documents:read'],
|
|
1492
|
+
routes: {
|
|
1493
|
+
'GET /v1/tenants/current/documents/policy_ops/draft': {
|
|
1494
|
+
data: policyDocument({ type: 'policy_ops', isDraft: true }),
|
|
1495
|
+
},
|
|
1496
|
+
'GET /v1/tenants/current/documents': {
|
|
1497
|
+
data: [policyDocument(), policyDocument({ type: 'tone_of_voice', isDraft: true, version: null })],
|
|
1498
|
+
nextCursor: null,
|
|
1499
|
+
},
|
|
1500
|
+
},
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
const result = await client.callTool({ name: 'taiga_get_document', arguments: { type: 'policy_ops' } });
|
|
1504
|
+
|
|
1505
|
+
expect(result.isError).toBe(true);
|
|
1506
|
+
expect(parseToolResult(result)).toEqual({
|
|
1507
|
+
error: 'NOT_FOUND',
|
|
1508
|
+
message:
|
|
1509
|
+
'No published policy_ops. A draft exists; taiga_publish_document publishes it once the person asks. ' +
|
|
1510
|
+
'Published documents: policy_security (v3).',
|
|
1511
|
+
});
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it('should keep the published list when only the draft lookup behind the explanation fails', async () => {
|
|
1515
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
1516
|
+
capabilities: ['tenant:documents:read'],
|
|
1517
|
+
routes: { 'GET /v1/tenants/current/documents': { data: [policyDocument()], nextCursor: null } },
|
|
1518
|
+
});
|
|
1519
|
+
const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
|
|
1520
|
+
fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
|
|
1521
|
+
if (!new URL(url).pathname.endsWith('/draft')) return router(url, init);
|
|
1522
|
+
const body = { error: 'Internal Server Error', code: 'INTERNAL', message: 'boom' };
|
|
1523
|
+
return { ok: false, status: 500, json: async () => body, text: async () => JSON.stringify(body) };
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
const result = await client.callTool({ name: 'taiga_get_document', arguments: { type: 'policy_ops' } });
|
|
1527
|
+
|
|
1528
|
+
expect(parseToolResult(result)).toEqual({
|
|
1529
|
+
error: 'NOT_FOUND',
|
|
1530
|
+
message:
|
|
1531
|
+
'No published policy_ops. The draft could not be checked (500 INTERNAL). Published documents: policy_security (v3).',
|
|
1532
|
+
});
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
it('should say when there is no draft either', async () => {
|
|
1536
|
+
const { client } = await bootMcpPair({
|
|
1537
|
+
capabilities: ['tenant:documents:read'],
|
|
1538
|
+
routes: {
|
|
1539
|
+
'GET /v1/tenants/current/documents/tone_of_voice/draft': { data: null },
|
|
1540
|
+
'GET /v1/tenants/current/documents': { data: [], nextCursor: null },
|
|
1541
|
+
},
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
const result = await client.callTool({ name: 'taiga_get_document', arguments: { type: 'tone_of_voice' } });
|
|
1545
|
+
|
|
1546
|
+
expect(parseToolResult(result).message).toBe(
|
|
1547
|
+
'No published tone_of_voice. There is no draft either. Nothing is published yet.'
|
|
1548
|
+
);
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
it('should tell a factory with no instructions how to get some', async () => {
|
|
1552
|
+
const { client } = await bootMcpPair({
|
|
1553
|
+
capabilities: ['factories:read'],
|
|
1554
|
+
routes: {
|
|
1555
|
+
[`GET /v1/factories/${FACTORY_ID}`]: { data: { id: FACTORY_ID, name: 'Platform' } },
|
|
1556
|
+
[`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions/draft`]: { data: null },
|
|
1557
|
+
},
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
const result = await client.callTool({
|
|
1561
|
+
name: 'taiga_get_factory_document',
|
|
1562
|
+
arguments: { factoryId: FACTORY_ID },
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
expect(result.isError).toBe(true);
|
|
1566
|
+
expect(parseToolResult(result).message).toBe(
|
|
1567
|
+
'This factory has no published factory_instructions yet. There is no draft either: write one with ' +
|
|
1568
|
+
'taiga_update_factory_document, then publish it when the person asks.'
|
|
1569
|
+
);
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
it('should report a nonexistent factory as such even when the draft route answers null for it', async () => {
|
|
1573
|
+
// A break-glass key skips the membership check, and the draft route
|
|
1574
|
+
// does not look the factory up: it answers 200 null for an id that
|
|
1575
|
+
// does not exist. The factory read is what says it does not.
|
|
1576
|
+
const { client } = await bootMcpPair({
|
|
1577
|
+
capabilities: ['factories:read'],
|
|
1578
|
+
routes: { [`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions/draft`]: { data: null } },
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
const result = await client.callTool({
|
|
1582
|
+
name: 'taiga_get_factory_document',
|
|
1583
|
+
arguments: { factoryId: FACTORY_ID },
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
expect(parseToolResult(result)).toEqual({ error: 'NOT_FOUND', message: 'The requested resource does not exist' });
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
it('should report a factory the key cannot see as a factory miss, not an unpublished document', async () => {
|
|
1590
|
+
// No routes at all: the document and the draft route both answer 404.
|
|
1591
|
+
const { client } = await bootMcpPair({ capabilities: ['factories:read'] });
|
|
1592
|
+
|
|
1593
|
+
const result = await client.callTool({
|
|
1594
|
+
name: 'taiga_get_factory_document',
|
|
1595
|
+
arguments: { factoryId: FACTORY_ID },
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
expect(parseToolResult(result)).toEqual({ error: 'NOT_FOUND', message: 'The requested resource does not exist' });
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
it('should point at the draft when a factory has one unpublished', async () => {
|
|
1602
|
+
const { client } = await bootMcpPair({
|
|
1603
|
+
capabilities: ['factories:read'],
|
|
1604
|
+
routes: {
|
|
1605
|
+
[`GET /v1/factories/${FACTORY_ID}`]: { data: { id: FACTORY_ID, name: 'Platform' } },
|
|
1606
|
+
[`GET /v1/factories/${FACTORY_ID}/documents/factory_instructions/draft`]: {
|
|
1607
|
+
data: { id: 'fd', type: 'factory_instructions', isDraft: true, data: { sections: {} } },
|
|
1608
|
+
},
|
|
1609
|
+
},
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
const result = await client.callTool({
|
|
1613
|
+
name: 'taiga_get_factory_document',
|
|
1614
|
+
arguments: { factoryId: FACTORY_ID },
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
expect(parseToolResult(result).message).toContain('A draft exists; taiga_publish_factory_document');
|
|
1618
|
+
});
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
describe('product by slug', () => {
|
|
1622
|
+
const product = { id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_ID, settings: {} };
|
|
1623
|
+
|
|
1624
|
+
it('should resolve a slug through the list filter and return the product in full', async () => {
|
|
1625
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
1626
|
+
capabilities: ['products:read'],
|
|
1627
|
+
routes: { 'GET /v1/products?slug=web&limit=1': { data: [product], nextCursor: null } },
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
const result = await client.callTool({ name: 'taiga_get_product', arguments: { slug: 'web' } });
|
|
1631
|
+
|
|
1632
|
+
expect(result.isError).toBeFalsy();
|
|
1633
|
+
expect(parseToolResult(result)).toEqual(product);
|
|
1634
|
+
expect(sentUrl(fetchSpy, '/v1/products').searchParams.get('slug')).toBe('web');
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
it('should say NOT_FOUND for a slug nobody visible has', async () => {
|
|
1638
|
+
const { client } = await bootMcpPair({
|
|
1639
|
+
capabilities: ['products:read'],
|
|
1640
|
+
routes: { 'GET /v1/products?slug=nope&limit=1': { data: [], nextCursor: null } },
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
const result = await client.callTool({ name: 'taiga_get_product', arguments: { slug: 'nope' } });
|
|
1644
|
+
|
|
1645
|
+
expect(result.isError).toBe(true);
|
|
1646
|
+
expect(parseToolResult(result)).toMatchObject({ error: 'NOT_FOUND', message: expect.stringContaining('"nope"') });
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
it('should refuse both identifiers at once rather than pick one', async () => {
|
|
1650
|
+
const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
|
|
1651
|
+
const before = fetchSpy.mock.calls.length;
|
|
1652
|
+
|
|
1653
|
+
const result = await client.callTool({
|
|
1654
|
+
name: 'taiga_get_product',
|
|
1655
|
+
arguments: { productId: PRODUCT_ID, slug: 'web' },
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
expect(result.isError).toBe(true);
|
|
1659
|
+
expect(parseToolResult(result)).toMatchObject({
|
|
1660
|
+
error: 'VALIDATION_FAILED',
|
|
1661
|
+
message: expect.stringContaining('not both'),
|
|
1662
|
+
});
|
|
1663
|
+
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
it('should ask for one of the two when neither is given', async () => {
|
|
1667
|
+
const { client, fetchSpy } = await bootMcpPair({ capabilities: ['products:read'] });
|
|
1668
|
+
const before = fetchSpy.mock.calls.length;
|
|
1669
|
+
|
|
1670
|
+
const result = await client.callTool({ name: 'taiga_get_product', arguments: {} });
|
|
1671
|
+
|
|
1672
|
+
expect(result.isError).toBe(true);
|
|
1673
|
+
expect(parseToolResult(result).error).toBe('VALIDATION_FAILED');
|
|
1674
|
+
expect(fetchSpy.mock.calls.length).toBe(before);
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1043
1678
|
describe('error handling', () => {
|
|
1044
1679
|
it('should surface API errors as structured MCP error content', async () => {
|
|
1045
1680
|
// Introspect succeeds, but all other endpoints fail
|