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
package/src/launcher.mjs CHANGED
@@ -3,9 +3,9 @@ import { spawn } from 'node:child_process';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { discoverDestinations, remediation, statusRows } from './bas-discovery.mjs';
6
+ import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
7
7
  import { findBinary } from './binary.mjs';
8
- import { MCPProxy } from './mcp-proxy.mjs';
8
+ import { MCPProxy, TOOL_NAME_PREFIX } from './mcp-proxy.mjs';
9
9
  import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
10
10
  import { runSetup } from './setup.mjs';
11
11
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
@@ -17,6 +17,9 @@ const runtimeEnv = withBrandedEnvironment(process.env);
17
17
 
18
18
  function hasFlag(name) { return process.argv.slice(2).includes(name); }
19
19
  function json(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
20
+ // Timestamped stderr lines so MCP server output shows when each event
21
+ // happened, not just that it did.
22
+ function logLine(message) { console.error(`[${new Date().toISOString()}] ${message}`); }
20
23
 
21
24
  function usage() {
22
25
  return [
@@ -108,10 +111,11 @@ async function runDoctor(destinations) {
108
111
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
109
112
  if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
110
113
  const tools = listed.result?.tools || [];
111
- const localNames = new Set(['LintABAP', 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration', 'GenerateRAPRegressionSuite', 'RunRAPRegressionSuite']);
112
- const upstreamCount = tools.filter(tool => !localNames.has(tool.name.slice(tool.name.indexOf('__') + 2))).length;
114
+ const toolPrefix = `${TOOL_NAME_PREFIX}${slugifyDestination(destination.name)}_`;
115
+ const localSegments = new Set(['lint_abap', 'get_application_log', 'prepare_abap_change_set', 'apply_abap_change_set', 'check_transport_readiness', 'plan_abap_cloud_migration', 'generate_rap_regression_suite', 'run_rap_regression_suite']);
116
+ const upstreamCount = tools.filter(tool => tool.name.startsWith(toolPrefix) && !localSegments.has(tool.name.slice(toolPrefix.length))).length;
113
117
  checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} VSP tools and ${tools.length} total MCP tools returned; chat-picker binding is host-managed`));
114
- const systemInfo = tools.find(tool => tool.name.endsWith('__GetSystemInfo'));
118
+ const systemInfo = tools.find(tool => tool.name === `${toolPrefix}get_system_info`);
115
119
  if (!systemInfo) {
116
120
  checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
117
121
  } else {
@@ -120,7 +124,7 @@ async function runDoctor(destinations) {
120
124
  let session;
121
125
  let preflightError;
122
126
  try {
123
- session = await relayedDestination.relay.probeCsrfSession('/sap/bc/adt/datapreview/freestyle');
127
+ session = await relayedDestination.relay.probeCsrfSession();
124
128
  } catch (error) {
125
129
  preflightError = redactText(error.message || error).slice(0, 250);
126
130
  }
@@ -258,7 +262,7 @@ async function main() {
258
262
  let proxy;
259
263
  try {
260
264
  const binary = await binaryOrError();
261
- proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: message => console.error(message) });
265
+ proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine });
262
266
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
263
267
  process.once('SIGINT', () => shutdown('SIGINT'));
264
268
  process.once('SIGTERM', () => shutdown('SIGTERM'));
@@ -283,19 +287,21 @@ async function main() {
283
287
  }
284
288
 
285
289
  const discovered = await discoverDestinations({ env: runtimeEnv });
286
- for (const destination of discovered) console.error(probeDiagnostic(destination));
290
+ for (const destination of discovered) logLine(probeDiagnostic(destination));
287
291
  const destinations = await enrichWithStoredCredentials(discovered, runtimeEnv);
288
292
  if (!destinations.length) {
289
- console.error('[sap-ai-dev] destination discovery returned no named BAS destinations');
293
+ logLine('[sap-ai-dev] destination discovery returned no named BAS destinations');
290
294
  throw new Error(remediation);
291
295
  }
292
296
  const binary = await binaryOrError();
297
+ logLine(`[sap-ai-dev] sap-ai-dev-toolkit v${pkg.version} (node ${process.version}, pid ${process.pid})`);
298
+ logLine(`[sap-ai-dev] VSP binary: ${binary}`);
293
299
  for (const destination of destinations) {
294
- if (destination.credentials?.mode === 'bas-tunnel') console.error(`[sap-ai-dev] ${destination.name}: Basic credentials enabled through the BAS proxy tunnel`);
295
- else if (destination.credentials?.host) console.error(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
300
+ if (destination.credentials?.mode === 'bas-tunnel') logLine(`[sap-ai-dev] ${destination.name}: Basic credentials enabled through the BAS proxy tunnel`);
301
+ else if (destination.credentials?.host) logLine(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
296
302
  }
297
- console.error(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
298
- const proxy = new MCPProxy({ binary, destinations, env: process.env, log: message => console.error(message) });
303
+ logLine(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
304
+ const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine });
299
305
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
300
306
  process.once('SIGINT', () => shutdown('SIGINT'));
301
307
  process.once('SIGTERM', () => shutdown('SIGTERM'));
@@ -4,6 +4,7 @@ import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/pr
4
4
  import { homedir } from 'node:os';
5
5
  import { basename, delimiter, dirname, join, resolve } from 'node:path';
6
6
  import { brandedEnvValue } from './branding.mjs';
7
+ import { slugifyDestination } from './bas-discovery.mjs';
7
8
 
8
9
  const LEGACY_MCP_SERVER_PREFIXES = ['sapAiDev_', 'basVspMcp_'];
9
10
  const COMPANION_SERVER_KIND = 'sap-development-companion';
@@ -95,8 +96,10 @@ export async function resolveMcpConfigPath(env = process.env) {
95
96
  return candidates[0];
96
97
  }
97
98
 
99
+ // Server entry names are lowercase slugs: VS Code/BAS derive chat tool
100
+ // references from the server name and only bind lowercase identifiers.
98
101
  export function generatedServerName(destinationName) {
99
- return String(destinationName);
102
+ return slugifyDestination(destinationName);
100
103
  }
101
104
 
102
105
  export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SERVERS.map(server => server.id), { packageVersions = {}, packageManager = 'npx' } = {}) {
@@ -128,7 +131,7 @@ export function buildMcpEntries(destinations, env = process.env) {
128
131
  for (const destination of selected) {
129
132
  const isCf = destination.source === 'cloud-foundry';
130
133
  const name = generatedServerName(isCf ? destination.serverName : destination.name);
131
- if (Object.hasOwn(entries, name)) throw new Error(`Duplicate MCP destination server name: ${name}`);
134
+ if (Object.hasOwn(entries, name)) throw new Error(`Duplicate MCP destination server name: ${name} (two destination names normalize to the same lowercase server name)`);
132
135
  const entryEnv = {
133
136
  H2O_URL: String(h2oUrl),
134
137
  SAP_ALLOW_TRANSPORTABLE_EDITS: 'true'
@@ -339,10 +342,18 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
339
342
  for (const [serverName, entry] of Object.entries(config.servers)) {
340
343
  if (!isManagedBasDestinationEntry(entry)) continue;
341
344
  const destinationName = destinationValue(entry.env);
342
- if (generatedServerName(destinationName) !== serverName) continue;
345
+ const expectedName = generatedServerName(destinationName);
343
346
  const destination = basDestinations.get(destinationName);
344
347
  if (!destination) continue;
345
- const expected = buildMcpEntries([destination], env)[serverName];
348
+ if (expectedName !== serverName) {
349
+ // Legacy mixed-case entries migrate to the normalized lowercase key.
350
+ // A user-owned entry on the target key blocks the rename; merging is
351
+ // only safe onto another managed entry for the same destination.
352
+ const target = config.servers[expectedName];
353
+ const targetMergable = isManagedBasDestinationEntry(target) && destinationValue(target.env) === destinationName;
354
+ if (Object.hasOwn(config.servers, expectedName) && !targetMergable) continue;
355
+ }
356
+ const expected = buildMcpEntries([destination], env)[expectedName];
346
357
  const nextEnvironment = { ...entry.env, ...expected.env };
347
358
  if (Object.hasOwn(entry.env, 'SAP_ALLOW_TRANSPORTABLE_EDITS')) {
348
359
  nextEnvironment.SAP_ALLOW_TRANSPORTABLE_EDITS = entry.env.SAP_ALLOW_TRANSPORTABLE_EDITS;
@@ -359,8 +370,13 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
359
370
  } else {
360
371
  nextEntry.command = localCommand;
361
372
  }
362
- if (JSON.stringify(nextEntry) === JSON.stringify(entry)) continue;
363
- config.servers[serverName] = nextEntry;
373
+ if (expectedName === serverName) {
374
+ if (JSON.stringify(nextEntry) === JSON.stringify(entry)) continue;
375
+ config.servers[serverName] = nextEntry;
376
+ } else {
377
+ delete config.servers[serverName];
378
+ config.servers[expectedName] = nextEntry;
379
+ }
364
380
  repaired += 1;
365
381
  }
366
382
 
package/src/mcp-proxy.mjs CHANGED
@@ -8,6 +8,9 @@ import { createBasDestinationRelay } from './bas-destination-relay.mjs';
8
8
 
9
9
  const JSONRPC = '2.0';
10
10
  const FORWARDED_METHODS = new Set(['ping', 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'completion/complete', 'logging/setLevel']);
11
+ // MCP log levels in spec order; a notification is emitted when its level is
12
+ // at or above the client's logging/setLevel choice (default info).
13
+ const MCP_LOG_LEVELS = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];
11
14
  const APPLICATION_LOG_PARAMETERS = [
12
15
  'program',
13
16
  'user',
@@ -94,6 +97,25 @@ function exposeVspTool(tool) {
94
97
  return PUBLIC_VSP_TOOLS.has(tool?.name);
95
98
  }
96
99
 
100
+ // Public tool names are lowercase snake_case. BAS/VS Code chat tool references
101
+ // are lowercase-only, so mixed-case names never bind in the tools picker even
102
+ // when the server itself starts and lists tools.
103
+ export function snakeCaseName(value) {
104
+ return String(value)
105
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
106
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
107
+ .toLowerCase();
108
+ }
109
+
110
+ // Every generated tool is prefixed with the destination it targets:
111
+ // sap_system_<destination-slug>_<tool>, for example
112
+ // sap_system_actions4d_get_table_contents.
113
+ export const TOOL_NAME_PREFIX = 'sap_system_';
114
+
115
+ function publicToolSegment(name) {
116
+ return snakeCaseName(name) || String(name).toLowerCase();
117
+ }
118
+
97
119
  function applicationLogArguments(arguments_ = {}) {
98
120
  const filters = arguments_ && typeof arguments_ === 'object' && !Array.isArray(arguments_) ? arguments_ : {};
99
121
  const params = { type: 'application_log' };
@@ -200,6 +222,9 @@ class Child {
200
222
  onMessage(message) {
201
223
  if (message.id === undefined || message.id === null) {
202
224
  if (message.method?.startsWith('notifications/')) {
225
+ // An upstream tool-surface change invalidates this child's cached
226
+ // listing; the proxy also drops its merged cache when forwarding.
227
+ if (message.method === 'notifications/tools/list_changed') this.toolsPromise = undefined;
203
228
  try { this.options.onNotification?.(message); }
204
229
  catch (error) { this.options.log?.(`[${this.destination.name}] child notification forwarding failed: ${diagnosticText(error.message)}`); }
205
230
  }
@@ -215,6 +240,8 @@ class Child {
215
240
  fail(error) {
216
241
  if (this.exited) return;
217
242
  this.exited = true;
243
+ // A dead child must not keep serving its last successful tool listing.
244
+ this.toolsPromise = undefined;
218
245
  for (const pending of this.pending.values()) pending.reject(error);
219
246
  this.pending.clear();
220
247
  }
@@ -251,6 +278,19 @@ class Child {
251
278
  return tools;
252
279
  }
253
280
 
281
+ // The VSP tool surface is fixed for the process lifetime (--mode is a
282
+ // launch argument), so listings are memoized per child. The memo is
283
+ // cleared on failure, on child exit, and on upstream list_changed.
284
+ listToolsCached() {
285
+ if (!this.toolsPromise) {
286
+ this.toolsPromise = this.listTools().catch(error => {
287
+ this.toolsPromise = undefined;
288
+ throw error;
289
+ });
290
+ }
291
+ return this.toolsPromise;
292
+ }
293
+
254
294
  async close() {
255
295
  if (this.exited) return;
256
296
  this.closing = true;
@@ -285,18 +325,41 @@ export class MCPProxy {
285
325
  this.clientInitialized = false;
286
326
  this.pendingNotifications = [];
287
327
  this.shuttingDown = false;
288
- this.usedSlugs = new Map();
328
+ this.toolsCache = null;
329
+ this.mergedToolsPromise = undefined;
330
+ this.mcpLogLevel = 'info';
331
+ }
332
+
333
+ mcpLogEnabled(level) {
334
+ return MCP_LOG_LEVELS.indexOf(level) >= MCP_LOG_LEVELS.indexOf(this.mcpLogLevel);
335
+ }
336
+
337
+ // Every observable proxy event lands on stderr and, as a standard
338
+ // notifications/message log event, in the host's MCP server output channel
339
+ // (VS Code/BAS and Claude Code both render these). Events raised before the
340
+ // client initializes are buffered so the channel still shows the full
341
+ // startup picture once it opens.
342
+ eventSink(message, level = 'info', logger = 'sap-ai-dev-toolkit') {
343
+ this.log(message);
344
+ if (!this.mcpLogEnabled(level)) return;
345
+ const notification = { jsonrpc: JSONRPC, method: 'notifications/message', params: { level, logger, data: message } };
346
+ if (this.clientInitialized) this.output(JSON.stringify(notification));
347
+ else this.pendingNotifications.push(notification);
289
348
  }
290
349
 
291
350
  start() {
292
351
  if (this.started) return this;
293
352
  this.started = true;
294
353
  this.starting = (async () => {
295
- for (const originalDestination of this.destinations) {
354
+ // Destinations start concurrently (relay bind + child spawn); children
355
+ // are assembled in destination order, which keeps serverInfo's
356
+ // children[0] fallback and merged slug numbering deterministic.
357
+ const started = await Promise.all(this.destinations.map(originalDestination => (async () => {
296
358
  let destination = originalDestination;
359
+ let relay;
297
360
  try {
298
361
  if (useBasDestinationRelay(originalDestination, this.env)) {
299
- const relay = createBasDestinationRelay(originalDestination, { env: this.env, log: this.log });
362
+ relay = createBasDestinationRelay(originalDestination, { env: this.env, log: message => this.eventSink(message) });
300
363
  const originalClose = originalDestination.close;
301
364
  const relayUrl = await relay.ready;
302
365
  destination = {
@@ -311,27 +374,32 @@ export class MCPProxy {
311
374
  await relay.close();
312
375
  }
313
376
  };
314
- this.log(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
377
+ this.eventSink(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
315
378
  }
316
- this.log(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
317
- this.children.push({
379
+ this.eventSink(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
380
+ return {
318
381
  destination,
319
382
  child: new Child(this.binary, destination, {
320
383
  env: this.env,
321
384
  spawn: this.spawn,
322
385
  args: this.childArgs?.(destination),
323
- log: this.log,
386
+ log: message => this.eventSink(message),
324
387
  onNotification: notification => {
388
+ if (notification.method === 'notifications/tools/list_changed') this.toolsCache = null;
325
389
  if (this.clientInitialized) this.output(JSON.stringify(notification));
326
390
  else this.pendingNotifications.push(notification);
327
391
  }
328
392
  })
329
- });
393
+ };
330
394
  } catch (error) {
331
- this.log(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`);
395
+ this.eventSink(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`, 'error');
396
+ // A relay whose ready promise rejected still owns dispatchers.
397
+ if (relay && destination === originalDestination) await relay.close().catch(() => {});
332
398
  void closeDestinationRoute(destination);
399
+ return null;
333
400
  }
334
- }
401
+ })()));
402
+ this.children = started.filter(Boolean);
335
403
  if (!this.children.length) {
336
404
  this.started = false;
337
405
  for (const destination of this.destinations) void closeDestinationRoute(destination);
@@ -353,68 +421,123 @@ export class MCPProxy {
353
421
  async restartChild(entry) {
354
422
  if (this.shuttingDown) throw new Error('MCP proxy is shutting down');
355
423
  const name = entry.destination.name;
356
- this.log(`[${name}] VSP child crashed; self-healing restart in progress`);
424
+ this.eventSink(`[${name}] VSP child crashed; self-healing restart in progress`, 'warning');
357
425
  await entry.child.close().catch(() => {});
358
426
  const child = new Child(this.binary, entry.destination, {
359
427
  env: this.env,
360
428
  spawn: this.spawn,
361
429
  args: this.childArgs?.(entry.destination),
362
- log: this.log,
430
+ log: message => this.eventSink(message),
363
431
  onNotification: notification => {
432
+ if (notification.method === 'notifications/tools/list_changed') this.toolsCache = null;
364
433
  if (this.clientInitialized) this.output(JSON.stringify(notification));
365
434
  else this.pendingNotifications.push(notification);
366
435
  }
367
436
  });
368
437
  entry.child = child;
369
438
  entry.server = await child.initialize(this.lastInitializeParams || {});
370
- await child.listTools().catch(() => {});
371
- this.log(`[${name}] VSP child self-healing restart complete`);
439
+ this.toolsCache = null;
440
+ await child.listToolsCached().catch(() => {});
441
+ this.eventSink(`[${name}] VSP child self-healing restart complete`);
442
+ this.emitToolsListChanged();
372
443
  return entry;
373
444
  }
374
445
 
446
+ emitToolsListChanged() {
447
+ // Emitted only after the replacement child's tool surface is warm so a
448
+ // client re-list sees fresh data; buffered until the client initialized.
449
+ const notification = { jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' };
450
+ if (this.clientInitialized) this.output(JSON.stringify(notification));
451
+ else this.pendingNotifications.push(notification);
452
+ }
453
+
375
454
  async initializeChildren(params) {
376
455
  await this.starting;
377
456
  this.lastInitializeParams = params;
378
- const healthy = [];
379
- for (const entry of this.children) {
380
- this.log(`[${entry.destination.name}] initializing VSP MCP session`);
457
+ // Children initialize concurrently; healthy entries keep destination
458
+ // order so tool naming stays deterministic.
459
+ const initialized = await Promise.all(this.children.map(async entry => {
460
+ const startedAt = Date.now();
461
+ this.eventSink(`[${entry.destination.name}] initializing VSP MCP session`);
381
462
  try {
382
463
  entry.server = await entry.child.initialize(params);
383
- this.log(`[${entry.destination.name}] VSP MCP session initialized`);
384
- healthy.push(entry);
464
+ this.eventSink(`[${entry.destination.name}] VSP MCP session initialized (${Date.now() - startedAt}ms)`);
465
+ return entry;
385
466
  } catch (error) {
386
- this.log(`[${entry.destination.name}] initialization failed: ${diagnosticText(error.message)}`);
467
+ this.eventSink(`[${entry.destination.name}] initialization failed: ${diagnosticText(error.message)}`, 'error');
387
468
  await entry.child.close();
388
469
  await closeDestinationRoute(entry.destination);
470
+ return null;
389
471
  }
390
- }
472
+ }));
473
+ const healthy = initialized.filter(Boolean);
391
474
  this.children = healthy;
392
475
  if (!healthy.length) throw new Error('No destination child initialized successfully');
393
476
  this.initialized = true;
477
+ // Warm the merged tool cache so the client's first tools/list after
478
+ // initialize resolves without a child round trip.
479
+ const warmedAt = Date.now();
480
+ void this.mergedTools()
481
+ .then(tools => this.eventSink(`[MCP] tools cache warmed (${tools.length} tools, ${Date.now() - warmedAt}ms)`))
482
+ .catch(() => {});
394
483
  return healthy;
395
484
  }
396
485
 
397
- async mergedTools() {
398
- this.namespace.clear();
399
- this.usedSlugs.clear();
486
+ toolsCacheValid() {
487
+ const cached = this.toolsCache;
488
+ if (!cached) return false;
489
+ if (cached.children.length !== this.children.length) return false;
490
+ return this.children.every((entry, index) => entry.child === cached.children[index] && !entry.child.exited);
491
+ }
492
+
493
+ mergedTools() {
494
+ if (this.mergedToolsPromise) return this.mergedToolsPromise;
495
+ if (this.toolsCacheValid()) return Promise.resolve([...this.toolsCache.tools]);
496
+ const childrenAtBuild = this.children.map(entry => entry.child);
497
+ this.mergedToolsPromise = this.buildMergedTools()
498
+ .then(tools => {
499
+ // Cache only when the child set is unchanged; a restart that landed
500
+ // mid-build invalidates the result and the next list rebuilds.
501
+ if (!this.shuttingDown && this.children.length === childrenAtBuild.length
502
+ && this.children.every((entry, index) => entry.child === childrenAtBuild[index])) {
503
+ this.toolsCache = { children: childrenAtBuild, tools };
504
+ }
505
+ return [...tools];
506
+ })
507
+ .finally(() => { this.mergedToolsPromise = undefined; });
508
+ return this.mergedToolsPromise;
509
+ }
510
+
511
+ async buildMergedTools() {
512
+ const namespace = new Map();
513
+ const usedSlugs = new Map();
400
514
  const merged = [];
515
+ const publish = (name, mapping, definition) => {
516
+ // A duplicate public name must not shadow the first registration: the
517
+ // namespace map would keep the first handler while advertising both.
518
+ if (namespace.has(name)) {
519
+ this.eventSink(`[MCP] skipped duplicate public tool name: ${name}`, 'warning');
520
+ return;
521
+ }
522
+ namespace.set(name, mapping);
523
+ merged.push(definition);
524
+ };
401
525
  for (const entry of this.children) {
402
- const slug = slugifyDestination(entry.destination.name, this.usedSlugs);
403
- const lintName = `${slug}__${ABAP_LINT_TOOL.name}`;
404
- this.namespace.set(lintName, { handler: runABAPLint });
405
- merged.push({ ...ABAP_LINT_TOOL, name: lintName });
526
+ const slug = slugifyDestination(entry.destination.name, usedSlugs);
527
+ const publicName = name => `${TOOL_NAME_PREFIX}${slug}_${publicToolSegment(name)}`;
528
+ const lintName = publicName(ABAP_LINT_TOOL.name);
529
+ publish(lintName, { handler: runABAPLint }, { ...ABAP_LINT_TOOL, name: lintName });
406
530
  try {
407
- const upstreamTools = await entry.child.listTools();
531
+ const upstreamTools = await entry.child.listToolsCached();
408
532
  for (const tool of upstreamTools) {
409
533
  if (tool.name === 'SAP') {
410
- const applicationLogName = `${slug}__GetApplicationLog`;
411
- this.namespace.set(applicationLogName, {
534
+ const applicationLogName = publicName('GetApplicationLog');
535
+ publish(applicationLogName, {
412
536
  entry,
413
537
  upstream: 'SAP',
414
538
  publicName: 'GetApplicationLog',
415
539
  transformArguments: applicationLogArguments
416
- });
417
- merged.push({
540
+ }, {
418
541
  name: applicationLogName,
419
542
  description: `${APPLICATION_LOG_DESCRIPTION} [destination: ${entry.destination.name}]`,
420
543
  inputSchema: APPLICATION_LOG_SCHEMA
@@ -422,24 +545,24 @@ export class MCPProxy {
422
545
  }
423
546
 
424
547
  if (!exposeVspTool(tool)) continue;
425
- const name = `${slug}__${tool.name}`;
426
- this.namespace.set(name, { entry, upstream: tool.name });
427
- merged.push({ ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
548
+ const name = publicName(tool.name);
549
+ publish(name, { entry, upstream: tool.name }, { ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
428
550
  }
429
551
  for (const localTool of createEngineeringTools(entry, upstreamTools, { env: this.env, log: this.log })) {
430
- const name = `${slug}__${localTool.definition.name}`;
431
- this.namespace.set(name, { handler: localTool.handler });
432
- merged.push({
552
+ const name = publicName(localTool.definition.name);
553
+ publish(name, { handler: localTool.handler }, {
433
554
  ...localTool.definition,
434
555
  name,
435
556
  description: `${localTool.definition.description} [destination: ${entry.destination.name}]`
436
557
  });
437
558
  }
438
559
  } catch (error) {
439
- this.log(`[${entry.destination.name}] tools/list failed: ${redactText(error.message)}`);
560
+ this.eventSink(`[${entry.destination.name}] tools/list failed: ${redactText(error.message)}`, 'error');
440
561
  }
441
562
  }
442
563
  if (!merged.length && this.children.length) throw new Error('No destination child provided tools');
564
+ // Swap atomically so a concurrent tools/call never sees a partial map.
565
+ this.namespace = namespace;
443
566
  return merged;
444
567
  }
445
568
 
@@ -449,13 +572,24 @@ export class MCPProxy {
449
572
  return null;
450
573
  }
451
574
  if (message.id === undefined) return null;
575
+ const receivedAt = Date.now();
452
576
  try {
577
+ this.eventSink(`[MCP] request ${message.method || '?'} (id=${message.id})`, 'debug');
453
578
  if (message.method === 'initialize') {
454
579
  await this.initializeChildren(message.params || {});
455
- return rpcResult(message.id, { protocolVersion: message.params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: brandedEnvValue(this.env, 'DESTINATION') || this.children[0].destination.name, version: '0.1.0' } });
580
+ this.eventSink('[MCP] initialize complete; server ready', 'info');
581
+ return rpcResult(message.id, { protocolVersion: message.params?.protocolVersion || '2024-11-05', capabilities: { tools: { listChanged: true } }, serverInfo: { name: slugifyDestination(brandedEnvValue(this.env, 'DESTINATION') || this.children[0].destination.name), version: '0.1.0' } });
456
582
  }
457
583
  if (!this.initialized) return rpcError(message.id, -32002, 'MCP proxy is not initialized');
458
- if (message.method === 'tools/list') return rpcResult(message.id, { tools: await this.mergedTools() });
584
+ if (message.method === 'logging/setLevel' && MCP_LOG_LEVELS.includes(message.params?.level)) {
585
+ this.mcpLogLevel = message.params.level;
586
+ this.eventSink(`[MCP] log level set to ${this.mcpLogLevel}`);
587
+ }
588
+ if (message.method === 'tools/list') {
589
+ const tools = await this.mergedTools();
590
+ this.eventSink(`[MCP] tools/list returned ${tools.length} tools in ${Date.now() - receivedAt}ms`, 'debug');
591
+ return rpcResult(message.id, { tools });
592
+ }
459
593
  if (message.method === 'tools/call') {
460
594
  const name = message.params?.name;
461
595
  const mapped = this.namespace.get(name);
@@ -465,31 +599,31 @@ export class MCPProxy {
465
599
  const upstreamParams = { ...message.params, name: mapped.upstream };
466
600
  if (mapped.transformArguments) upstreamParams.arguments = mapped.transformArguments(message.params?.arguments);
467
601
  const startedAt = Date.now();
468
- this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} started`);
602
+ this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} started`);
469
603
  try {
470
604
  const response = await mapped.entry.child.request('tools/call', upstreamParams);
471
605
  if (response.result?.isError) {
472
606
  const detail = Array.isArray(response.result.content)
473
607
  ? response.result.content.filter(item => item.type === 'text').map(item => item.text).join(' ')
474
608
  : '';
475
- this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} returned an error in ${Date.now() - startedAt}ms${detail ? `: ${diagnosticText(detail)}` : ''}`);
609
+ this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} returned an error in ${Date.now() - startedAt}ms${detail ? `: ${diagnosticText(detail)}` : ''}`, 'warning');
476
610
  } else {
477
- this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} completed in ${Date.now() - startedAt}ms`);
611
+ this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} completed in ${Date.now() - startedAt}ms`);
478
612
  }
479
613
  return response.error ? { ...response, id: message.id } : rpcResult(message.id, response.result);
480
614
  } catch (error) {
481
- this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`);
615
+ this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`, 'error');
482
616
  // Self-healing: a dead child (crash, OOM, transient pipe break) is
483
617
  // restarted and the call retried once before surfacing the error.
484
618
  const childBroken = mapped.entry.child.exited || !mapped.entry.child.process.stdin.writable;
485
619
  if (childBroken && !this.shuttingDown) {
486
620
  try {
487
621
  await this.restartChild(mapped.entry);
488
- this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
622
+ this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
489
623
  const retried = await mapped.entry.child.request('tools/call', upstreamParams);
490
624
  return retried.error ? { ...retried, id: message.id } : rpcResult(message.id, retried.result);
491
625
  } catch (restartError) {
492
- this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} self-healing restart failed: ${diagnosticText(restartError.message)}`);
626
+ this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} self-healing restart failed: ${diagnosticText(restartError.message)}`, 'error');
493
627
  }
494
628
  }
495
629
  return error.rpcError ? rpcError(message.id, error.rpcError.code || -32001, error.rpcError.message || error.message, error.rpcError.data) : rpcError(message.id, -32001, `Destination ${mapped.entry.destination.name} failed: ${error.message}`);
@@ -498,11 +632,11 @@ export class MCPProxy {
498
632
  if (!FORWARDED_METHODS.has(message.method)) return rpcError(message.id, -32601, `Method not found: ${message.method}`);
499
633
  const responses = [];
500
634
  for (const entry of this.children) {
501
- try { responses.push((await entry.child.request(message.method, message.params)).result); } catch (error) { this.log(`[${entry.destination.name}] ${message.method} failed: ${redactText(error.message)}`); }
635
+ try { responses.push((await entry.child.request(message.method, message.params)).result); } catch (error) { this.eventSink(`[${entry.destination.name}] ${message.method} failed: ${redactText(error.message)}`, 'warning'); }
502
636
  }
503
637
  return rpcResult(message.id, responses.length === 1 ? responses[0] : responses);
504
638
  } catch (error) {
505
- this.log(`[MCP] ${message.method || 'request'} failed: ${diagnosticText(error.message)}`);
639
+ this.eventSink(`[MCP] ${message.method || 'request'} failed: ${diagnosticText(error.message)}`, 'error');
506
640
  return rpcError(message.id, -32000, redactText(error.message));
507
641
  }
508
642
  }
@@ -529,7 +663,7 @@ export class MCPProxy {
529
663
  this.pendingNotifications.length = 0;
530
664
  }
531
665
  }
532
- } catch (error) { this.log(redactText(error.message)); }
666
+ } catch (error) { this.eventSink(`[MCP] invalid client message dropped: ${redactText(error.message)}`, 'error'); }
533
667
  }
534
668
  };
535
669
  input.setEncoding('utf8');
package/src/setup.mjs CHANGED
@@ -167,8 +167,9 @@ function printConnectionInstructions(output, result) {
167
167
  print(output, '');
168
168
  print(output, colorText('📡 MCP servers now available:', 'cyan', output));
169
169
  for (const [name, entry] of servers) {
170
- const detail = entry.env?.BAS_VSP_DESTINATION
171
- ? `destination: ${entry.env.BAS_VSP_DESTINATION}`
170
+ const destinationName = entry.env?.SAP_AI_DEV_TOOLKIT_DESTINATION || entry.env?.BAS_VSP_DESTINATION;
171
+ const detail = destinationName
172
+ ? `destination: ${destinationName}`
172
173
  : (entry.displayName ? `tool server: ${entry.displayName}` : 'tool server');
173
174
  print(output, ` • ${name} — ${detail}`);
174
175
  }