sap-ai-dev-toolkit 0.4.5 → 0.5.5
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/.github/agents/abap-developer.agent.md +1 -1
- package/.github/agents/abap-runtime-debugger.agent.md +1 -1
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-debugging/SKILL.md +1 -1
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +1 -1
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +1 -1
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +21 -14
- package/package.json +1 -1
- package/scripts/postinstall.mjs +3 -2
- package/src/bas-destination-relay.mjs +48 -22
- package/src/bas-discovery.mjs +16 -6
- package/src/launcher.mjs +24 -14
- package/src/mcp-config.mjs +36 -7
- package/src/mcp-proxy.mjs +185 -51
- package/src/setup.mjs +3 -2
- package/test/bas-destination-relay.test.mjs +93 -14
- package/test/cf-runtime.test.mjs +2 -2
- package/test/discovery.test.mjs +45 -0
- package/test/fixtures/fake-vsp.mjs +6 -3
- package/test/launcher.test.mjs +15 -13
- package/test/live-s4h.test.mjs +13 -11
- package/test/mcp-config-cf.test.mjs +127 -15
- package/test/mcp-proxy.test.mjs +232 -33
- package/test/setup-cf.test.mjs +7 -7
- package/tools.md +4 -2
package/test/mcp-proxy.test.mjs
CHANGED
|
@@ -5,9 +5,12 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { once } from 'node:events';
|
|
8
|
-
import { childArguments, MCPProxy, PUBLIC_VSP_TOOLS } from '../src/mcp-proxy.mjs';
|
|
8
|
+
import { childArguments, MCPProxy, PUBLIC_VSP_TOOLS, snakeCaseName, TOOL_NAME_PREFIX } from '../src/mcp-proxy.mjs';
|
|
9
|
+
const MCP_LOG_LEVELS = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];
|
|
9
10
|
import { PassThrough } from 'node:stream';
|
|
10
11
|
|
|
12
|
+
const publicName = (destination, name) => `${TOOL_NAME_PREFIX}${destination}_${snakeCaseName(name)}`;
|
|
13
|
+
|
|
11
14
|
const fixture = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
|
|
12
15
|
const HIDDEN_VSP_TOOLS = [
|
|
13
16
|
'AnalyzeABAPCode', 'DebuggerListen', 'DeleteBreakpoint', 'DeleteObject', 'ExecuteABAP', 'ReleaseTransport',
|
|
@@ -79,7 +82,7 @@ test('restarts a crashed VSP child and retries the tool call once', async t => {
|
|
|
79
82
|
const exit = once(originalChild.process, 'exit');
|
|
80
83
|
originalChild.process.kill('SIGKILL');
|
|
81
84
|
await exit;
|
|
82
|
-
const response = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: '
|
|
85
|
+
const response = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_alpha_get_system_info', arguments: {} } });
|
|
83
86
|
assert.equal(response?.error, undefined, `self-healing must recover the call: ${JSON.stringify(response?.error)}`);
|
|
84
87
|
assert.notEqual(proxy.children[0].child, originalChild, 'a replacement child must have been spawned');
|
|
85
88
|
const events = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
|
|
@@ -141,37 +144,42 @@ test('merges paged tools and routes calls to the selected child', async t => {
|
|
|
141
144
|
|
|
142
145
|
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
143
146
|
assert.equal(initialized.result.serverInfo.name, 'alpha');
|
|
147
|
+
assert.equal(initialized.result.capabilities.tools.listChanged, true);
|
|
144
148
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
145
149
|
const names = new Set(listed.result.tools.map(tool => tool.name));
|
|
146
|
-
const queryTool = listed.result.tools.find(tool => tool.name === '
|
|
150
|
+
const queryTool = listed.result.tools.find(tool => tool.name === 'sap_system_beta_run_query');
|
|
147
151
|
assert.deepEqual(queryTool.inputSchema, {
|
|
148
152
|
type: 'object',
|
|
149
153
|
properties: { sql_query: { type: 'string' }, max_rows: { type: 'number' }, all_rows: { type: 'boolean' } },
|
|
150
154
|
required: ['sql_query']
|
|
151
155
|
});
|
|
152
156
|
assert.equal(queryTool.description, 'RunQuery [destination: beta]');
|
|
153
|
-
const lintTool = listed.result.tools.find(tool => tool.name === '
|
|
157
|
+
const lintTool = listed.result.tools.find(tool => tool.name === 'sap_system_alpha_lint_abap');
|
|
154
158
|
assert.deepEqual(Object.keys(lintTool.inputSchema.properties).sort(), ['config', 'files']);
|
|
155
159
|
assert.deepEqual(lintTool.inputSchema.required, ['files']);
|
|
156
160
|
assert.deepEqual(lintTool.inputSchema.properties.files.items.required, ['filename', 'source']);
|
|
157
161
|
|
|
158
162
|
const localWorkflowTools = [
|
|
159
|
-
'
|
|
160
|
-
'
|
|
163
|
+
'lint_abap', 'get_application_log', 'prepare_abap_change_set', 'apply_abap_change_set',
|
|
164
|
+
'check_transport_readiness', 'plan_abap_cloud_migration', 'generate_rap_regression_suite', 'run_rap_regression_suite'
|
|
161
165
|
];
|
|
162
166
|
for (const destination of ['alpha', 'beta']) {
|
|
163
167
|
for (const toolName of PUBLIC_VSP_TOOLS) {
|
|
164
|
-
assert.ok(names.has(`${destination}
|
|
168
|
+
assert.ok(names.has(`${TOOL_NAME_PREFIX}${destination}_${snakeCaseName(toolName)}`), `${destination} exposes curated ${toolName}`);
|
|
165
169
|
}
|
|
166
170
|
for (const toolName of localWorkflowTools) {
|
|
167
|
-
assert.ok(names.has(`${destination}
|
|
171
|
+
assert.ok(names.has(`${TOOL_NAME_PREFIX}${destination}_${toolName}`), `${destination} exposes local ${toolName}`);
|
|
168
172
|
}
|
|
169
173
|
for (const hiddenTool of HIDDEN_VSP_TOOLS) {
|
|
170
|
-
assert.equal(names.has(`${destination}
|
|
174
|
+
assert.equal(names.has(`${TOOL_NAME_PREFIX}${destination}_${snakeCaseName(hiddenTool)}`), false, `${destination} hides ${hiddenTool}`);
|
|
171
175
|
}
|
|
172
176
|
}
|
|
173
|
-
assert.equal(listed.result.tools.filter(tool => tool.name.startsWith('
|
|
177
|
+
assert.equal(listed.result.tools.filter(tool => tool.name.startsWith('sap_system_alpha_')).length, PUBLIC_VSP_TOOLS.size + localWorkflowTools.length);
|
|
174
178
|
assert.equal(PUBLIC_VSP_TOOLS.size + localWorkflowTools.length, 59);
|
|
179
|
+
for (const tool of listed.result.tools) {
|
|
180
|
+
assert.match(tool.name, /^[a-z0-9_-]+$/, `public tool names must be lowercase: ${tool.name}`);
|
|
181
|
+
assert.ok(tool.name.length <= 128, `public tool names must stay within the MCP limit: ${tool.name}`);
|
|
182
|
+
}
|
|
175
183
|
|
|
176
184
|
const requiredArguments = {
|
|
177
185
|
GetFeatures: [],
|
|
@@ -185,18 +193,18 @@ test('merges paged tools and routes calls to the selected child', async t => {
|
|
|
185
193
|
GetApplicationLog: []
|
|
186
194
|
};
|
|
187
195
|
for (const [toolName, required] of Object.entries(requiredArguments)) {
|
|
188
|
-
const tool = listed.result.tools.find(entry => entry.name ===
|
|
196
|
+
const tool = listed.result.tools.find(entry => entry.name === publicName('alpha', toolName));
|
|
189
197
|
assert.deepEqual(tool.inputSchema.required, required);
|
|
190
198
|
}
|
|
191
|
-
const applicationLog = listed.result.tools.find(entry => entry.name === '
|
|
199
|
+
const applicationLog = listed.result.tools.find(entry => entry.name === 'sap_system_alpha_get_application_log');
|
|
192
200
|
assert.deepEqual(Object.keys(applicationLog.inputSchema.properties).sort(), [
|
|
193
201
|
'from', 'max_results', 'messages', 'object', 'program', 'subobject', 'to', 'user'
|
|
194
202
|
]);
|
|
195
203
|
|
|
196
|
-
const read = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: '
|
|
197
|
-
const write = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: '
|
|
198
|
-
const query = await proxy.handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: '
|
|
199
|
-
const system = await proxy.handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: '
|
|
204
|
+
const read = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_beta_get_source', arguments: { object: 'ZREAD' } } });
|
|
205
|
+
const write = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'sap_system_alpha_write_source', arguments: { source: 'WRITE' } } });
|
|
206
|
+
const query = await proxy.handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'sap_system_beta_run_query', arguments: { sql_query: 'SELECT * FROM T000' } } });
|
|
207
|
+
const system = await proxy.handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'sap_system_alpha_get_system_info', arguments: {} } });
|
|
200
208
|
assert.equal(read.result.content[0].text, 'beta:GetSource');
|
|
201
209
|
assert.equal(write.result.content[0].text, 'alpha:WriteSource');
|
|
202
210
|
assert.equal(query.result.content[0].text, 'beta:RunQuery');
|
|
@@ -231,7 +239,7 @@ test('merges paged tools and routes calls to the selected child', async t => {
|
|
|
231
239
|
jsonrpc: '2.0',
|
|
232
240
|
id: 7 + index,
|
|
233
241
|
method: 'tools/call',
|
|
234
|
-
params: { name:
|
|
242
|
+
params: { name: publicName('alpha', name), arguments: arguments_ }
|
|
235
243
|
});
|
|
236
244
|
assert.equal(response.result.content[0].text, name === 'GetApplicationLog' ? 'alpha:SAP' : `alpha:${name}`);
|
|
237
245
|
}
|
|
@@ -275,16 +283,20 @@ test('merges paged tools and routes calls to the selected child', async t => {
|
|
|
275
283
|
test('routes tools/call using the exact name advertised for a BAS destination', async t => {
|
|
276
284
|
const directory = await mkdtemp(join(tmpdir(), 'bas-namespaced-tool-roundtrip-'));
|
|
277
285
|
const log = join(directory, 'children.log');
|
|
286
|
+
const logs = [];
|
|
278
287
|
const proxy = new MCPProxy({
|
|
279
288
|
binary: fixture,
|
|
280
289
|
destinations: [{ name: 'ActionS4D_100', url: 'http://ActionS4D_100.dest', client: '100' }],
|
|
281
290
|
env: { ...process.env, SAP_AI_DEV_TOOLKIT_MODE: 'expert', FAKE_LOG: log },
|
|
282
|
-
log:
|
|
291
|
+
log: message => logs.push(message)
|
|
283
292
|
});
|
|
284
293
|
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
285
|
-
|
|
294
|
+
proxy.start();
|
|
295
|
+
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
296
|
+
assert.equal(initialized.error, undefined, `${JSON.stringify(initialized.error)} ${logs.join(' | ')}`);
|
|
286
297
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
287
|
-
|
|
298
|
+
assert.equal(listed.error, undefined, JSON.stringify(listed.error));
|
|
299
|
+
const tool = listed.result.tools.find(candidate => candidate.name === 'sap_system_actions4d-100_write_source');
|
|
288
300
|
assert.ok(tool, 'tools/list must publish the normalized destination namespace');
|
|
289
301
|
const called = await proxy.handle({
|
|
290
302
|
jsonrpc: '2.0',
|
|
@@ -303,13 +315,13 @@ test('runs LintABAP locally and returns parser findings', async t => {
|
|
|
303
315
|
|
|
304
316
|
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
305
317
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
306
|
-
assert.ok(listed.result.tools.some(tool => tool.name === '
|
|
318
|
+
assert.ok(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_lint_abap'));
|
|
307
319
|
const called = await proxy.handle({
|
|
308
320
|
jsonrpc: '2.0',
|
|
309
321
|
id: 3,
|
|
310
322
|
method: 'tools/call',
|
|
311
323
|
params: {
|
|
312
|
-
name: '
|
|
324
|
+
name: 'sap_system_alpha_lint_abap',
|
|
313
325
|
arguments: { files: [{ filename: 'zparser_error.prog.abap', source: 'blah blah.' }] }
|
|
314
326
|
}
|
|
315
327
|
});
|
|
@@ -329,18 +341,20 @@ test('exposes local lint when a destination tool listing fails', async t => {
|
|
|
329
341
|
const { directory, proxy } = await fixtureProxy();
|
|
330
342
|
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
331
343
|
|
|
332
|
-
|
|
344
|
+
// Patch before initialize so the post-initialize cache warm sees the failure.
|
|
345
|
+
await proxy.starting;
|
|
333
346
|
const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
|
|
334
347
|
alpha.child.listTools = async () => { throw new Error('fixture tools/list failure'); };
|
|
348
|
+
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
335
349
|
|
|
336
350
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
337
|
-
assert.ok(listed.result.tools.some(tool => tool.name === '
|
|
338
|
-
assert.equal(listed.result.tools.some(tool => tool.name === '
|
|
351
|
+
assert.ok(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_lint_abap'));
|
|
352
|
+
assert.equal(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_get_source'), false);
|
|
339
353
|
const called = await proxy.handle({
|
|
340
354
|
jsonrpc: '2.0',
|
|
341
355
|
id: 3,
|
|
342
356
|
method: 'tools/call',
|
|
343
|
-
params: { name: '
|
|
357
|
+
params: { name: 'sap_system_alpha_lint_abap', arguments: { files: [{ filename: 'zparser_error.prog.abap', source: 'blah blah.' }] } }
|
|
344
358
|
});
|
|
345
359
|
assert.equal(called.result.isError, false);
|
|
346
360
|
});
|
|
@@ -351,15 +365,15 @@ test('returns JSON-RPC errors for unknown namespaces and logs child failures', a
|
|
|
351
365
|
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
352
366
|
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
353
367
|
await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
354
|
-
const unknown = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: '
|
|
368
|
+
const unknown = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_missing_get_source' } });
|
|
355
369
|
assert.equal(unknown.error.code, -32602);
|
|
356
|
-
const remoteError = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: '
|
|
370
|
+
const remoteError = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: { object: 'RPC_ERROR' } } });
|
|
357
371
|
assert.equal(remoteError.error.code, -32042);
|
|
358
372
|
assert.ok(logs.some(message => message.includes('backend connection refused') && message.includes('password=[redacted]') && !message.includes('must-not-log')));
|
|
359
|
-
const toolError = await proxy.handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: '
|
|
373
|
+
const toolError = await proxy.handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'sap_system_beta_get_source', arguments: { object: 'TOOL_ERROR' } } });
|
|
360
374
|
assert.equal(toolError.result.isError, true);
|
|
361
375
|
assert.ok(logs.some(message => message.includes('backend timeout') && message.includes('token=[redacted]') && !message.includes('must-not-log')));
|
|
362
|
-
const success = await proxy.handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: '
|
|
376
|
+
const success = await proxy.handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'sap_system_alpha_write_source', arguments: { source: 'must-not-log' } } });
|
|
363
377
|
assert.equal(success.result.content[0].text, 'alpha:WriteSource');
|
|
364
378
|
assert.ok(logs.some(message => /\[alpha\] tools\/call WriteSource completed in \d+ms/.test(message)));
|
|
365
379
|
assert.equal(logs.some(message => message.includes('must-not-log')), false);
|
|
@@ -368,7 +382,7 @@ test('returns JSON-RPC errors for unknown namespaces and logs child failures', a
|
|
|
368
382
|
await new Promise(resolve => setTimeout(resolve, 25));
|
|
369
383
|
// A crashed child no longer fails the call: self-healing restarts it and
|
|
370
384
|
// retries once, so the request goes through to a fresh child.
|
|
371
|
-
const healed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: '
|
|
385
|
+
const healed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'sap_system_alpha_get_source' } });
|
|
372
386
|
assert.equal(healed?.error, undefined, `call must heal after child crash: ${JSON.stringify(healed?.error)}`);
|
|
373
387
|
assert.equal(healed.result.content[0].text, 'alpha:GetSource');
|
|
374
388
|
assert.ok(logs.some(message => message.includes('alpha') && message.includes('tools/call GetSource failed')));
|
|
@@ -383,7 +397,7 @@ test('stdin EOF shuts down every child process and forwards child logs', async t
|
|
|
383
397
|
input.end([
|
|
384
398
|
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
|
|
385
399
|
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
|
|
386
|
-
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: '
|
|
400
|
+
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: { emitNotification: true } } }
|
|
387
401
|
].map(message => JSON.stringify(message)).join('\n') + '\n');
|
|
388
402
|
await serving;
|
|
389
403
|
assert.ok(output.some(message => message.method === 'notifications/message' && message.params?.data === 'fixture log notification'));
|
|
@@ -392,6 +406,57 @@ test('stdin EOF shuts down every child process and forwards child logs', async t
|
|
|
392
406
|
assert.deepEqual([...new Set(entries.filter(entry => entry.event === 'term').map(entry => entry.destination))].sort(), ['alpha', 'beta']);
|
|
393
407
|
});
|
|
394
408
|
|
|
409
|
+
async function driveUntil(output, predicate, timeoutMs = 5000) {
|
|
410
|
+
const deadline = Date.now() + timeoutMs;
|
|
411
|
+
while (!output.some(predicate) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
test('streams lifecycle and tool-call events to the client as MCP log notifications', async t => {
|
|
415
|
+
const { directory, proxy } = await fixtureProxy();
|
|
416
|
+
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
417
|
+
const input = new PassThrough();
|
|
418
|
+
const output = [];
|
|
419
|
+
const serving = proxy.serve(input, line => output.push(JSON.parse(line)));
|
|
420
|
+
input.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + '\n');
|
|
421
|
+
await driveUntil(output, message => message.id === 1);
|
|
422
|
+
// A real client lists tools before calling one; this also guarantees the
|
|
423
|
+
// merged namespace is built before the call.
|
|
424
|
+
input.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + '\n');
|
|
425
|
+
await driveUntil(output, message => message.id === 2);
|
|
426
|
+
input.write(JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: publicName('alpha', 'GetSource'), arguments: {} } }) + '\n');
|
|
427
|
+
await driveUntil(output, message => message.id === 3);
|
|
428
|
+
input.end();
|
|
429
|
+
await serving;
|
|
430
|
+
const logs = output.filter(message => message.method === 'notifications/message');
|
|
431
|
+
assert.ok(logs.length > 0, 'proxy events must reach the client as notifications/message');
|
|
432
|
+
assert.ok(logs.some(message => message.params.level === 'info' && String(message.params.data).includes('VSP MCP session initialized')), 'startup events buffered before initialize must flush');
|
|
433
|
+
assert.ok(logs.some(message => String(message.params.data).includes('tools/call GetSource started')), 'tool-call start must be logged');
|
|
434
|
+
assert.ok(logs.some(message => String(message.params.data).includes('tools/call GetSource completed')), 'tool-call completion must be logged');
|
|
435
|
+
assert.ok(logs.every(message => typeof message.params.logger === 'string' && MCP_LOG_LEVELS.includes(message.params.level)), 'every log notification carries a logger and a valid level');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('honors logging/setLevel and keeps writing stderr regardless of level', async t => {
|
|
439
|
+
const stderr = [];
|
|
440
|
+
const { directory, proxy } = await fixtureProxy(message => stderr.push(message));
|
|
441
|
+
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
442
|
+
const input = new PassThrough();
|
|
443
|
+
const output = [];
|
|
444
|
+
const serving = proxy.serve(input, line => output.push(JSON.parse(line)));
|
|
445
|
+
input.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + '\n');
|
|
446
|
+
await driveUntil(output, message => message.id === 1);
|
|
447
|
+
input.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + '\n');
|
|
448
|
+
await driveUntil(output, message => message.id === 2);
|
|
449
|
+
input.write(JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'logging/setLevel', params: { level: 'error' } }) + '\n');
|
|
450
|
+
await driveUntil(output, message => message.id === 3);
|
|
451
|
+
output.length = 0;
|
|
452
|
+
input.write(JSON.stringify({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: publicName('alpha', 'GetSource'), arguments: {} } }) + '\n');
|
|
453
|
+
await driveUntil(output, message => message.id === 4);
|
|
454
|
+
input.end();
|
|
455
|
+
await serving;
|
|
456
|
+
assert.equal(output.some(message => message.method === 'notifications/message'), false, 'info events must be suppressed at logging/setLevel=error');
|
|
457
|
+
assert.ok(stderr.some(message => message.includes('tools/call GetSource completed')), 'stderr keeps receiving every event');
|
|
458
|
+
});
|
|
459
|
+
|
|
395
460
|
|
|
396
461
|
test('keeps healthy children when one destination fails initialization', async t => {
|
|
397
462
|
const directory = await mkdtemp(join(tmpdir(), 'bas-vsp-test-'));
|
|
@@ -401,5 +466,139 @@ test('keeps healthy children when one destination fails initialization', async t
|
|
|
401
466
|
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
402
467
|
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
403
468
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
404
|
-
assert.ok(listed.result.tools.every(tool => tool.name.startsWith('
|
|
469
|
+
assert.ok(listed.result.tools.every(tool => tool.name.startsWith('sap_system_healthy_')));
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('converts upstream tool names to lowercase snake_case for chat binding', () => {
|
|
473
|
+
const cases = new Map([
|
|
474
|
+
['GetTableContents', 'get_table_contents'],
|
|
475
|
+
['RunATCCheck', 'run_atc_check'],
|
|
476
|
+
['GetCDSDependencies', 'get_cds_dependencies'],
|
|
477
|
+
['GetAPIReleaseState', 'get_api_release_state'],
|
|
478
|
+
['LintABAP', 'lint_abap'],
|
|
479
|
+
['GetApplicationLog', 'get_application_log'],
|
|
480
|
+
['PrepareABAPChangeSet', 'prepare_abap_change_set'],
|
|
481
|
+
['RunRAPRegressionSuite', 'run_rap_regression_suite'],
|
|
482
|
+
['ActivateMultiple', 'activate_multiple'],
|
|
483
|
+
['already_snake', 'already_snake'],
|
|
484
|
+
['lowercase', 'lowercase']
|
|
485
|
+
]);
|
|
486
|
+
for (const [input, expected] of cases) {
|
|
487
|
+
assert.equal(snakeCaseName(input), expected, `${input} must convert to ${expected}`);
|
|
488
|
+
assert.equal(snakeCaseName(snakeCaseName(input)), expected, `${input} conversion must be idempotent`);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('warms the tool cache once and serves repeat tools/list without child round trips', async t => {
|
|
493
|
+
const { directory, proxy } = await fixtureProxy();
|
|
494
|
+
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
495
|
+
await proxy.starting;
|
|
496
|
+
const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
|
|
497
|
+
let calls = 0;
|
|
498
|
+
const original = alpha.child.listTools.bind(alpha.child);
|
|
499
|
+
alpha.child.listTools = async () => {
|
|
500
|
+
calls += 1;
|
|
501
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
502
|
+
return original();
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
506
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
507
|
+
assert.equal(calls, 1, 'the post-initialize warm performs exactly one child fetch');
|
|
508
|
+
|
|
509
|
+
const first = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
510
|
+
assert.equal(calls, 1, 'a cached tools/list must not fetch from the child');
|
|
511
|
+
assert.ok(first.result.tools.some(tool => tool.name === 'sap_system_alpha_get_source'));
|
|
512
|
+
|
|
513
|
+
// Single-flight: two concurrent lists after a cache drop share one rebuild.
|
|
514
|
+
proxy.toolsCache = null;
|
|
515
|
+
alpha.child.toolsPromise = undefined;
|
|
516
|
+
const [second, third] = await Promise.all([
|
|
517
|
+
proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }),
|
|
518
|
+
proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} })
|
|
519
|
+
]);
|
|
520
|
+
assert.equal(calls, 2, 'concurrent tools/list must share a single child fetch');
|
|
521
|
+
assert.equal(second.result.tools.length, third.result.tools.length);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('refetches tool listings after a self-healing restart', async t => {
|
|
525
|
+
const { directory, proxy } = await fixtureProxy();
|
|
526
|
+
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
527
|
+
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
528
|
+
await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
529
|
+
|
|
530
|
+
const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
|
|
531
|
+
const exit = once(alpha.child.process, 'exit');
|
|
532
|
+
alpha.child.process.kill('SIGKILL');
|
|
533
|
+
await exit;
|
|
534
|
+
const healed = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: {} } });
|
|
535
|
+
assert.equal(healed?.error, undefined);
|
|
536
|
+
assert.equal(proxy.toolsCache, null, 'a replacement child must invalidate the merged cache');
|
|
537
|
+
const listed = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} });
|
|
538
|
+
assert.ok(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_get_source'));
|
|
539
|
+
assert.ok(proxy.toolsCache, 'the rebuilt listing must be cached again');
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test('upstream tools/list_changed notifications invalidate the cache', async t => {
|
|
543
|
+
const { directory, proxy } = await fixtureProxy();
|
|
544
|
+
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
545
|
+
await proxy.starting;
|
|
546
|
+
const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
|
|
547
|
+
let calls = 0;
|
|
548
|
+
const original = alpha.child.listTools.bind(alpha.child);
|
|
549
|
+
alpha.child.listTools = async () => { calls += 1; return original(); };
|
|
550
|
+
|
|
551
|
+
await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
552
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
553
|
+
assert.equal(calls, 1);
|
|
554
|
+
|
|
555
|
+
// The fixture emits notifications/tools/list_changed before replying, so
|
|
556
|
+
// both cache layers are cleared by the time the call resolves.
|
|
557
|
+
await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: { emitListChanged: true } } });
|
|
558
|
+
const listed = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} });
|
|
559
|
+
assert.equal(calls, 2, 'a list_changed notification must force a refetch');
|
|
560
|
+
assert.ok(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_get_source'));
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('emits tools/list_changed to the client after a self-healing restart', async t => {
|
|
564
|
+
const { directory, proxy } = await fixtureProxy();
|
|
565
|
+
t.after(async () => { await rm(directory, { recursive: true, force: true }); });
|
|
566
|
+
const input = new PassThrough();
|
|
567
|
+
const output = [];
|
|
568
|
+
const serving = proxy.serve(input, line => output.push(JSON.parse(line)));
|
|
569
|
+
input.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })}\n`);
|
|
570
|
+
input.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`);
|
|
571
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
572
|
+
|
|
573
|
+
const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
|
|
574
|
+
const exit = once(alpha.child.process, 'exit');
|
|
575
|
+
alpha.child.process.kill('SIGKILL');
|
|
576
|
+
await exit;
|
|
577
|
+
input.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: {} } })}\n`);
|
|
578
|
+
const deadline = Date.now() + 10000;
|
|
579
|
+
while (!output.some(message => message.id === 3) && Date.now() < deadline) {
|
|
580
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
581
|
+
}
|
|
582
|
+
input.end();
|
|
583
|
+
await serving;
|
|
584
|
+
assert.ok(output.some(message => message.id === 3 && !message.error), 'the healed call must succeed');
|
|
585
|
+
assert.ok(output.some(message => message.method === 'notifications/tools/list_changed'), 'the client must be told the tool surface changed');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('assembles children in destination order regardless of relay timing', async t => {
|
|
589
|
+
const directory = await mkdtemp(join(tmpdir(), 'bas-vsp-start-order-'));
|
|
590
|
+
const log = join(directory, 'children.log');
|
|
591
|
+
const destinations = ['zz-relay', 'aa-relay'].map(name => ({
|
|
592
|
+
name, url: `http://${name}.dest`, client: '001', authentication: 'BasicAuthentication', proxyType: 'Internet'
|
|
593
|
+
}));
|
|
594
|
+
const proxy = new MCPProxy({ binary: fixture, destinations, env: { ...process.env, FAKE_LOG: log }, log: () => {} });
|
|
595
|
+
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
596
|
+
proxy.start();
|
|
597
|
+
await proxy.starting;
|
|
598
|
+
assert.deepEqual(proxy.children.map(entry => entry.destination.name), ['zz-relay', 'aa-relay'], 'children keep destination order');
|
|
599
|
+
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
600
|
+
assert.equal(initialized.result.serverInfo.name, 'zz-relay');
|
|
601
|
+
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
602
|
+
assert.ok(listed.result.tools.some(tool => tool.name.startsWith('sap_system_zz-relay_')));
|
|
603
|
+
assert.ok(listed.result.tools.some(tool => tool.name.startsWith('sap_system_aa-relay_')));
|
|
405
604
|
});
|
package/test/setup-cf.test.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { installMcpConfig } from '../src/mcp-config.mjs';
|
|
7
|
+
import { generatedServerName, installMcpConfig } from '../src/mcp-config.mjs';
|
|
8
8
|
import { destinationTable } from '../scripts/postinstall.mjs';
|
|
9
9
|
import { readCredentials, resolveCredentialsPath, storeDestinationCredentials } from '../src/credentials-store.mjs';
|
|
10
10
|
import { credentialKeyForDestination } from '../src/credential-overrides.mjs';
|
|
@@ -185,8 +185,8 @@ test('imports selected CF destinations, passes managed key references, and remov
|
|
|
185
185
|
instanceName: 'old-destination-service', keyName: 'old-key'
|
|
186
186
|
}]);
|
|
187
187
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf8'));
|
|
188
|
-
assert.deepEqual(Object.keys(config.servers), ['cf
|
|
189
|
-
assert.equal(config.servers['cf
|
|
188
|
+
assert.deepEqual(Object.keys(config.servers), ['cf-space-one-new-instance-new-destination']);
|
|
189
|
+
assert.equal(config.servers['cf-space-one-new-instance-new-destination'].env.BAS_CF_DESTINATION_KEY, 'new-key');
|
|
190
190
|
const calls = (await readFile(fixture.callLog, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
|
|
191
191
|
assert.deepEqual(calls.filter(args => args[0] === 'delete-service-key'), [[
|
|
192
192
|
'delete-service-key', '-f', '--wait', 'old-destination-service', 'old-key'
|
|
@@ -401,7 +401,7 @@ test('no selectable records leave MCP config unchanged and remove only new keys'
|
|
|
401
401
|
assert.equal(result.selectionSent, false, result.stdout);
|
|
402
402
|
assert.match(result.stdout, /No Destination service instance exists/);
|
|
403
403
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf8'));
|
|
404
|
-
assert.deepEqual(Object.keys(config.servers), [previous.serverName]);
|
|
404
|
+
assert.deepEqual(Object.keys(config.servers), [generatedServerName(previous.serverName)]);
|
|
405
405
|
const calls = (await readFile(fixture.callLog, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
|
|
406
406
|
assert.deepEqual(calls.filter(args => args[0] === 'delete-service-key'), [[
|
|
407
407
|
'delete-service-key', '-f', '--wait', 'new-destination-service', 'new-key'
|
|
@@ -433,7 +433,7 @@ test('failed config writes remove only unreferenced keys created by that attempt
|
|
|
433
433
|
assert.equal(result.code, 1, `${result.stdout}\n${result.stderr}`);
|
|
434
434
|
assert.match(`${result.stdout}\n${result.stderr}`, /MCP config writing failed: simulated config write failure/);
|
|
435
435
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf8'));
|
|
436
|
-
assert.deepEqual(Object.keys(config.servers), [previous.serverName]);
|
|
436
|
+
assert.deepEqual(Object.keys(config.servers), [generatedServerName(previous.serverName)]);
|
|
437
437
|
const calls = (await readFile(fixture.callLog, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
|
|
438
438
|
assert.deepEqual(calls.filter(args => args[0] === 'delete-service-key'), [[
|
|
439
439
|
'delete-service-key', '-f', '--wait', 'new-destination-service', 'new-key'
|
|
@@ -450,7 +450,7 @@ test('preserves old CF keys referenced by an unrelated remaining MCP entry', asy
|
|
|
450
450
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf8'));
|
|
451
451
|
config.servers.customServer = {
|
|
452
452
|
command: 'custom-launcher',
|
|
453
|
-
env: { ...config.servers[previous.serverName].env, SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: undefined }
|
|
453
|
+
env: { ...config.servers[generatedServerName(previous.serverName)].env, SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: undefined }
|
|
454
454
|
};
|
|
455
455
|
await writeFile(fixture.configPath, JSON.stringify(config));
|
|
456
456
|
fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([{
|
|
@@ -478,7 +478,7 @@ test('setup report labels CF source and matches registration by generated server
|
|
|
478
478
|
available: false,
|
|
479
479
|
error: 'https://user:secret@backend.example/path token=SECRET'
|
|
480
480
|
}
|
|
481
|
-
}], new Set(['cf
|
|
481
|
+
}], new Set(['cf-space-one-destination-instance-friendly-destination']));
|
|
482
482
|
const row = report.split('\n').find(line => line.includes('friendly-destination'));
|
|
483
483
|
assert.match(report, /Destination.*Source/);
|
|
484
484
|
assert.match(row, /CF destination-service/);
|
package/tools.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
The proxy no longer advertises every VSP tool. To keep the developer-lifecycle MCP surface manageable, each generated one-destination server exposes a cherry-picked set of VSP tools plus local workflow tools (currently 59 tools when all curated VSP capabilities are registered).
|
|
4
4
|
|
|
5
|
+
The names below are the logical tool names. Publicly, every tool is exposed as `sap_system_<destination-slug>_<tool>` in lowercase (for example `GetTableContents` on destination `DEMO_ABAP` is `sap_system_demo-abap_get_table_contents`): BAS/VS Code chat tool references bind only lowercase identifiers, so mixed-case names never reach the model.
|
|
6
|
+
|
|
5
7
|
Hidden upstream VSP tools are not directly callable through the proxy. Local workflow tools may still call hidden or non-advertised upstream operations internally when they are required for a bounded workflow.
|
|
6
8
|
|
|
7
9
|
## Public VSP tools
|
|
@@ -64,7 +66,7 @@ Broad/destructive surfaces such as object deletion, SQL trace, arbitrary SAP rou
|
|
|
64
66
|
|
|
65
67
|
## Destination-scoped workflow tools
|
|
66
68
|
|
|
67
|
-
The proxy adds local multi-step tools to each destination when their required VSP capabilities are available:
|
|
69
|
+
The proxy adds local multi-step tools to each destination when their required VSP capabilities are available. Like the VSP tools above, they are exposed publicly in lowercase snake_case (`lint_abap`, `get_application_log`, `prepare_abap_change_set`, and so on):
|
|
68
70
|
|
|
69
71
|
- `LintABAP` — local in-memory ABAP linting.
|
|
70
72
|
- `GetApplicationLog` — bounded convenience mapping to the upstream SAP application-log route when available.
|
|
@@ -73,7 +75,7 @@ The proxy adds local multi-step tools to each destination when their required VS
|
|
|
73
75
|
- `PlanABAPCloudMigration` — batch SAP API release-state checks and prioritize recognized unreleased results.
|
|
74
76
|
- `GenerateRAPRegressionSuite` and `RunRAPRegressionSuite` — create and run reusable OData GET-only checks under the selected service root.
|
|
75
77
|
|
|
76
|
-
`sap-ai-dev-toolkit --doctor` checks destination probing, VSP startup, `
|
|
78
|
+
`sap-ai-dev-toolkit --doctor` checks destination probing, VSP startup, the `get_system_info` probe, and MCP tool listing. `sap-ai-dev-toolkit --demo` runs the sample tools and OData fixtures without contacting SAP; demo writes and transport creation remain in memory until that process exits.
|
|
77
79
|
|
|
78
80
|
## Separate optional HANA Cloud inspector
|
|
79
81
|
|