sap-ai-dev-toolkit 0.5.0 → 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.
Files changed (38) hide show
  1. package/.github/agents/abap-developer.agent.md +1 -1
  2. package/.github/agents/abap-runtime-debugger.agent.md +1 -1
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
  4. package/.github/agents/rap-service-developer.agent.md +1 -1
  5. package/.github/agents/sap-solution-architect.agent.md +1 -1
  6. package/.github/skills/abap-debugging/SKILL.md +1 -1
  7. package/.github/skills/abap-development/SKILL.md +1 -1
  8. package/.github/skills/abap-runtime-analysis/SKILL.md +1 -1
  9. package/.github/skills/abap-testing-quality/SKILL.md +1 -1
  10. package/.github/skills/cds-development/SKILL.md +1 -1
  11. package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
  12. package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
  13. package/.github/skills/hana-cloud-native-development/SKILL.md +1 -1
  14. package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
  15. package/.github/skills/rap-development/SKILL.md +1 -1
  16. package/.github/skills/rap-service-delivery/SKILL.md +1 -1
  17. package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
  18. package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
  19. package/.github/skills/sap-transport-release/SKILL.md +1 -1
  20. package/README.md +17 -11
  21. package/package.json +1 -1
  22. package/scripts/postinstall.mjs +3 -2
  23. package/src/bas-destination-relay.mjs +33 -19
  24. package/src/bas-discovery.mjs +15 -5
  25. package/src/launcher.mjs +19 -13
  26. package/src/mcp-config.mjs +22 -6
  27. package/src/mcp-proxy.mjs +185 -51
  28. package/src/setup.mjs +3 -2
  29. package/test/bas-destination-relay.test.mjs +53 -12
  30. package/test/cf-runtime.test.mjs +2 -2
  31. package/test/discovery.test.mjs +25 -0
  32. package/test/fixtures/fake-vsp.mjs +3 -0
  33. package/test/launcher.test.mjs +15 -13
  34. package/test/live-s4h.test.mjs +13 -11
  35. package/test/mcp-config-cf.test.mjs +80 -12
  36. package/test/mcp-proxy.test.mjs +226 -31
  37. package/test/setup-cf.test.mjs +7 -7
  38. package/tools.md +4 -2
@@ -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: 'alpha__GetSystemInfo', arguments: {} } });
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 === 'beta__RunQuery');
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 === 'alpha__LintABAP');
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
- 'LintABAP', 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet',
160
- 'CheckTransportReadiness', 'PlanABAPCloudMigration', 'GenerateRAPRegressionSuite', 'RunRAPRegressionSuite'
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}__${toolName}`), `${destination} exposes curated ${toolName}`);
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}__${toolName}`), `${destination} exposes local ${toolName}`);
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}__${hiddenTool}`), false, `${destination} hides ${hiddenTool}`);
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('alpha__')).length, PUBLIC_VSP_TOOLS.size + localWorkflowTools.length);
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 === `alpha__${toolName}`);
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 === 'alpha__GetApplicationLog');
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: 'beta__GetSource', arguments: { object: 'ZREAD' } } });
197
- const write = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'alpha__WriteSource', arguments: { source: 'WRITE' } } });
198
- const query = await proxy.handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'beta__RunQuery', arguments: { sql_query: 'SELECT * FROM T000' } } });
199
- const system = await proxy.handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'alpha__GetSystemInfo', arguments: {} } });
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: `alpha__${name}`, arguments: arguments_ }
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
  }
@@ -288,7 +296,7 @@ test('routes tools/call using the exact name advertised for a BAS destination',
288
296
  assert.equal(initialized.error, undefined, `${JSON.stringify(initialized.error)} ${logs.join(' | ')}`);
289
297
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
290
298
  assert.equal(listed.error, undefined, JSON.stringify(listed.error));
291
- const tool = listed.result.tools.find(candidate => candidate.name === 'actions4d-100__WriteSource');
299
+ const tool = listed.result.tools.find(candidate => candidate.name === 'sap_system_actions4d-100_write_source');
292
300
  assert.ok(tool, 'tools/list must publish the normalized destination namespace');
293
301
  const called = await proxy.handle({
294
302
  jsonrpc: '2.0',
@@ -307,13 +315,13 @@ test('runs LintABAP locally and returns parser findings', async t => {
307
315
 
308
316
  await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
309
317
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
310
- assert.ok(listed.result.tools.some(tool => tool.name === 'alpha__LintABAP'));
318
+ assert.ok(listed.result.tools.some(tool => tool.name === 'sap_system_alpha_lint_abap'));
311
319
  const called = await proxy.handle({
312
320
  jsonrpc: '2.0',
313
321
  id: 3,
314
322
  method: 'tools/call',
315
323
  params: {
316
- name: 'alpha__LintABAP',
324
+ name: 'sap_system_alpha_lint_abap',
317
325
  arguments: { files: [{ filename: 'zparser_error.prog.abap', source: 'blah blah.' }] }
318
326
  }
319
327
  });
@@ -333,18 +341,20 @@ test('exposes local lint when a destination tool listing fails', async t => {
333
341
  const { directory, proxy } = await fixtureProxy();
334
342
  t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
335
343
 
336
- await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
344
+ // Patch before initialize so the post-initialize cache warm sees the failure.
345
+ await proxy.starting;
337
346
  const alpha = proxy.children.find(entry => entry.destination.name === 'alpha');
338
347
  alpha.child.listTools = async () => { throw new Error('fixture tools/list failure'); };
348
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
339
349
 
340
350
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
341
- assert.ok(listed.result.tools.some(tool => tool.name === 'alpha__LintABAP'));
342
- assert.equal(listed.result.tools.some(tool => tool.name === 'alpha__GetSource'), false);
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);
343
353
  const called = await proxy.handle({
344
354
  jsonrpc: '2.0',
345
355
  id: 3,
346
356
  method: 'tools/call',
347
- params: { name: 'alpha__LintABAP', arguments: { files: [{ filename: 'zparser_error.prog.abap', source: 'blah blah.' }] } }
357
+ params: { name: 'sap_system_alpha_lint_abap', arguments: { files: [{ filename: 'zparser_error.prog.abap', source: 'blah blah.' }] } }
348
358
  });
349
359
  assert.equal(called.result.isError, false);
350
360
  });
@@ -355,15 +365,15 @@ test('returns JSON-RPC errors for unknown namespaces and logs child failures', a
355
365
  t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
356
366
  await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
357
367
  await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
358
- const unknown = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'missing__GetSource' } });
368
+ const unknown = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_missing_get_source' } });
359
369
  assert.equal(unknown.error.code, -32602);
360
- const remoteError = await proxy.handle({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'alpha__GetSource', arguments: { object: 'RPC_ERROR' } } });
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' } } });
361
371
  assert.equal(remoteError.error.code, -32042);
362
372
  assert.ok(logs.some(message => message.includes('backend connection refused') && message.includes('password=[redacted]') && !message.includes('must-not-log')));
363
- const toolError = await proxy.handle({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'beta__GetSource', arguments: { object: 'TOOL_ERROR' } } });
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' } } });
364
374
  assert.equal(toolError.result.isError, true);
365
375
  assert.ok(logs.some(message => message.includes('backend timeout') && message.includes('token=[redacted]') && !message.includes('must-not-log')));
366
- const success = await proxy.handle({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'alpha__WriteSource', arguments: { source: 'must-not-log' } } });
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' } } });
367
377
  assert.equal(success.result.content[0].text, 'alpha:WriteSource');
368
378
  assert.ok(logs.some(message => /\[alpha\] tools\/call WriteSource completed in \d+ms/.test(message)));
369
379
  assert.equal(logs.some(message => message.includes('must-not-log')), false);
@@ -372,7 +382,7 @@ test('returns JSON-RPC errors for unknown namespaces and logs child failures', a
372
382
  await new Promise(resolve => setTimeout(resolve, 25));
373
383
  // A crashed child no longer fails the call: self-healing restarts it and
374
384
  // retries once, so the request goes through to a fresh child.
375
- const healed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'alpha__GetSource' } });
385
+ const healed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'sap_system_alpha_get_source' } });
376
386
  assert.equal(healed?.error, undefined, `call must heal after child crash: ${JSON.stringify(healed?.error)}`);
377
387
  assert.equal(healed.result.content[0].text, 'alpha:GetSource');
378
388
  assert.ok(logs.some(message => message.includes('alpha') && message.includes('tools/call GetSource failed')));
@@ -387,7 +397,7 @@ test('stdin EOF shuts down every child process and forwards child logs', async t
387
397
  input.end([
388
398
  { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
389
399
  { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
390
- { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'alpha__GetSource', arguments: { emitNotification: true } } }
400
+ { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_alpha_get_source', arguments: { emitNotification: true } } }
391
401
  ].map(message => JSON.stringify(message)).join('\n') + '\n');
392
402
  await serving;
393
403
  assert.ok(output.some(message => message.method === 'notifications/message' && message.params?.data === 'fixture log notification'));
@@ -396,6 +406,57 @@ test('stdin EOF shuts down every child process and forwards child logs', async t
396
406
  assert.deepEqual([...new Set(entries.filter(entry => entry.event === 'term').map(entry => entry.destination))].sort(), ['alpha', 'beta']);
397
407
  });
398
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
+
399
460
 
400
461
  test('keeps healthy children when one destination fails initialization', async t => {
401
462
  const directory = await mkdtemp(join(tmpdir(), 'bas-vsp-test-'));
@@ -405,5 +466,139 @@ test('keeps healthy children when one destination fails initialization', async t
405
466
  t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
406
467
  await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
407
468
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
408
- assert.ok(listed.result.tools.every(tool => tool.name.startsWith('healthy__')));
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_')));
409
604
  });
@@ -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: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');
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:space-one:destination-instance:friendly-destination']));
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, `GetSystemInfo`, 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.
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