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.
- 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 +17 -11
- package/package.json +1 -1
- package/scripts/postinstall.mjs +3 -2
- package/src/bas-destination-relay.mjs +33 -19
- package/src/bas-discovery.mjs +15 -5
- package/src/launcher.mjs +19 -13
- package/src/mcp-config.mjs +22 -6
- package/src/mcp-proxy.mjs +185 -51
- package/src/setup.mjs +3 -2
- package/test/bas-destination-relay.test.mjs +53 -12
- package/test/cf-runtime.test.mjs +2 -2
- package/test/discovery.test.mjs +25 -0
- package/test/fixtures/fake-vsp.mjs +3 -0
- package/test/launcher.test.mjs +15 -13
- package/test/live-s4h.test.mjs +13 -11
- package/test/mcp-config-cf.test.mjs +80 -12
- package/test/mcp-proxy.test.mjs +226 -31
- package/test/setup-cf.test.mjs +7 -7
- 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
|
|
112
|
-
const
|
|
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
|
|
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(
|
|
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:
|
|
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)
|
|
290
|
+
for (const destination of discovered) logLine(probeDiagnostic(destination));
|
|
287
291
|
const destinations = await enrichWithStoredCredentials(discovered, runtimeEnv);
|
|
288
292
|
if (!destinations.length) {
|
|
289
|
-
|
|
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')
|
|
295
|
-
else if (destination.credentials?.host)
|
|
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
|
-
|
|
298
|
-
const proxy = new MCPProxy({ binary, destinations, env: process.env, log:
|
|
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'));
|
package/src/mcp-config.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
345
|
+
const expectedName = generatedServerName(destinationName);
|
|
343
346
|
const destination = basDestinations.get(destinationName);
|
|
344
347
|
if (!destination) continue;
|
|
345
|
-
|
|
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 (
|
|
363
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
377
|
+
this.eventSink(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
|
|
315
378
|
}
|
|
316
|
-
this.
|
|
317
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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.
|
|
384
|
-
|
|
464
|
+
this.eventSink(`[${entry.destination.name}] VSP MCP session initialized (${Date.now() - startedAt}ms)`);
|
|
465
|
+
return entry;
|
|
385
466
|
} catch (error) {
|
|
386
|
-
this.
|
|
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
|
-
|
|
398
|
-
this.
|
|
399
|
-
|
|
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,
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
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.
|
|
531
|
+
const upstreamTools = await entry.child.listToolsCached();
|
|
408
532
|
for (const tool of upstreamTools) {
|
|
409
533
|
if (tool.name === 'SAP') {
|
|
410
|
-
const applicationLogName =
|
|
411
|
-
|
|
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 =
|
|
426
|
-
|
|
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 =
|
|
431
|
-
|
|
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.
|
|
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
|
-
|
|
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 === '
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
171
|
-
|
|
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
|
}
|