sap-ai-dev-toolkit 0.4.5 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/abap-developer.agent.md +1 -1
- package/.github/agents/abap-runtime-debugger.agent.md +1 -1
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-debugging/SKILL.md +1 -1
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +1 -1
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +1 -1
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +21 -14
- package/package.json +1 -1
- package/scripts/postinstall.mjs +3 -2
- package/src/bas-destination-relay.mjs +48 -22
- package/src/bas-discovery.mjs +16 -6
- package/src/launcher.mjs +24 -14
- package/src/mcp-config.mjs +36 -7
- package/src/mcp-proxy.mjs +185 -51
- package/src/setup.mjs +3 -2
- package/test/bas-destination-relay.test.mjs +93 -14
- package/test/cf-runtime.test.mjs +2 -2
- package/test/discovery.test.mjs +45 -0
- package/test/fixtures/fake-vsp.mjs +6 -3
- package/test/launcher.test.mjs +15 -13
- package/test/live-s4h.test.mjs +13 -11
- package/test/mcp-config-cf.test.mjs +127 -15
- package/test/mcp-proxy.test.mjs +232 -33
- package/test/setup-cf.test.mjs +7 -7
- package/tools.md +4 -2
package/src/bas-discovery.mjs
CHANGED
|
@@ -122,7 +122,7 @@ function proxyDispatcher(target, env) {
|
|
|
122
122
|
const url = new URL(target);
|
|
123
123
|
const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
|
|
124
124
|
const httpProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.http_proxy ?? env.HTTP_PROXY ?? '');
|
|
125
|
-
const httpsProxy = env.https_proxy ?? env.HTTPS_PROXY ?? '';
|
|
125
|
+
const httpsProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.https_proxy ?? env.HTTPS_PROXY ?? '');
|
|
126
126
|
const proxy = url.protocol === 'https:' ? httpsProxy || httpProxy : httpProxy;
|
|
127
127
|
const noProxy = env.no_proxy ?? env.NO_PROXY ?? '';
|
|
128
128
|
if (!proxy || proxyBypasses(url, noProxy)) return undefined;
|
|
@@ -203,11 +203,21 @@ export async function discoverDestinations(options = {}) {
|
|
|
203
203
|
const configuredProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.HTTP_PROXY ?? env.http_proxy);
|
|
204
204
|
const proxyUrl = configuredProxy !== undefined ? configuredProxy : DEFAULT_PROXY;
|
|
205
205
|
const skipProbe = String(brandedEnvValue(env, 'SKIP_PROBE') || '').toLowerCase() === 'true' || options.skipProbe;
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
206
|
+
const sorted = normalized.sort((a, b) => a.name.localeCompare(b.name));
|
|
207
|
+
// Probes run in parallel so startup and setup pay one probe round trip
|
|
208
|
+
// instead of one per destination; the pool is capped to avoid saturating
|
|
209
|
+
// the BAS proxy with large destination counts. Workers claim destinations
|
|
210
|
+
// in index order (synchronously until the first await), which keeps the
|
|
211
|
+
// probe invocation and result order deterministic.
|
|
212
|
+
const result = new Array(sorted.length);
|
|
213
|
+
let cursor = 0;
|
|
214
|
+
const probeWorker = async () => {
|
|
215
|
+
while (cursor < sorted.length) {
|
|
216
|
+
const index = cursor++;
|
|
217
|
+
result[index] = { ...sorted[index], probe: await probeADT(sorted[index], { ...options, proxyUrl, skipProbe }) };
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
await Promise.all(Array.from({ length: Math.min(8, sorted.length) }, () => probeWorker()));
|
|
211
221
|
return result;
|
|
212
222
|
}
|
|
213
223
|
|
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 [
|
|
@@ -60,7 +63,11 @@ async function runDoctor(destinations) {
|
|
|
60
63
|
|
|
61
64
|
|
|
62
65
|
try {
|
|
63
|
-
const repair = await repairManagedMcpConfig(destinations, {
|
|
66
|
+
const repair = await repairManagedMcpConfig(destinations, {
|
|
67
|
+
env: runtimeEnv,
|
|
68
|
+
discoveryComplete: true,
|
|
69
|
+
packageVersion: pkg.version
|
|
70
|
+
});
|
|
64
71
|
const detail = repair.skipped
|
|
65
72
|
? repair.skipped
|
|
66
73
|
: `${repair.repaired} managed BAS entr${repair.repaired === 1 ? 'y' : 'ies'} repaired; ${repair.managedEntries || 0} managed BAS entries recognized`;
|
|
@@ -104,10 +111,11 @@ async function runDoctor(destinations) {
|
|
|
104
111
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
105
112
|
if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
|
|
106
113
|
const tools = listed.result?.tools || [];
|
|
107
|
-
const
|
|
108
|
-
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;
|
|
109
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`));
|
|
110
|
-
const systemInfo = tools.find(tool => tool.name
|
|
118
|
+
const systemInfo = tools.find(tool => tool.name === `${toolPrefix}get_system_info`);
|
|
111
119
|
if (!systemInfo) {
|
|
112
120
|
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
|
|
113
121
|
} else {
|
|
@@ -116,7 +124,7 @@ async function runDoctor(destinations) {
|
|
|
116
124
|
let session;
|
|
117
125
|
let preflightError;
|
|
118
126
|
try {
|
|
119
|
-
session = await relayedDestination.relay.probeCsrfSession(
|
|
127
|
+
session = await relayedDestination.relay.probeCsrfSession();
|
|
120
128
|
} catch (error) {
|
|
121
129
|
preflightError = redactText(error.message || error).slice(0, 250);
|
|
122
130
|
}
|
|
@@ -254,7 +262,7 @@ async function main() {
|
|
|
254
262
|
let proxy;
|
|
255
263
|
try {
|
|
256
264
|
const binary = await binaryOrError();
|
|
257
|
-
proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log:
|
|
265
|
+
proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine });
|
|
258
266
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
259
267
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
260
268
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
@@ -279,19 +287,21 @@ async function main() {
|
|
|
279
287
|
}
|
|
280
288
|
|
|
281
289
|
const discovered = await discoverDestinations({ env: runtimeEnv });
|
|
282
|
-
for (const destination of discovered)
|
|
290
|
+
for (const destination of discovered) logLine(probeDiagnostic(destination));
|
|
283
291
|
const destinations = await enrichWithStoredCredentials(discovered, runtimeEnv);
|
|
284
292
|
if (!destinations.length) {
|
|
285
|
-
|
|
293
|
+
logLine('[sap-ai-dev] destination discovery returned no named BAS destinations');
|
|
286
294
|
throw new Error(remediation);
|
|
287
295
|
}
|
|
288
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}`);
|
|
289
299
|
for (const destination of destinations) {
|
|
290
|
-
if (destination.credentials?.mode === 'bas-tunnel')
|
|
291
|
-
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`);
|
|
292
302
|
}
|
|
293
|
-
|
|
294
|
-
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 });
|
|
295
305
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
296
306
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
297
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'
|
|
@@ -322,7 +325,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
322
325
|
return { path, servers: generated };
|
|
323
326
|
}
|
|
324
327
|
|
|
325
|
-
export async function repairManagedMcpConfig(discoveredDestinations, { env = process.env, path, discoveryComplete = false } = {}) {
|
|
328
|
+
export async function repairManagedMcpConfig(discoveredDestinations, { env = process.env, path, discoveryComplete = false, packageVersion } = {}) {
|
|
326
329
|
const configPath = path || await resolveMcpConfigPath(env);
|
|
327
330
|
if (!discoveryComplete) {
|
|
328
331
|
return { path: configPath, changed: false, repaired: 0, skipped: 'Destination discovery was incomplete.' };
|
|
@@ -334,20 +337,46 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
|
|
|
334
337
|
const config = await readConfig(configPath);
|
|
335
338
|
const managedEntries = Object.values(config.servers).filter(isManagedBasDestinationEntry).length;
|
|
336
339
|
if (!basDestinations.size) return { path: configPath, changed: false, repaired: 0, managedEntries };
|
|
340
|
+
const localCommand = await resolveMcpServerCommand(env);
|
|
337
341
|
let repaired = 0;
|
|
338
342
|
for (const [serverName, entry] of Object.entries(config.servers)) {
|
|
339
343
|
if (!isManagedBasDestinationEntry(entry)) continue;
|
|
340
344
|
const destinationName = destinationValue(entry.env);
|
|
341
|
-
|
|
345
|
+
const expectedName = generatedServerName(destinationName);
|
|
342
346
|
const destination = basDestinations.get(destinationName);
|
|
343
347
|
if (!destination) continue;
|
|
344
|
-
|
|
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];
|
|
345
357
|
const nextEnvironment = { ...entry.env, ...expected.env };
|
|
358
|
+
if (Object.hasOwn(entry.env, 'SAP_ALLOW_TRANSPORTABLE_EDITS')) {
|
|
359
|
+
nextEnvironment.SAP_ALLOW_TRANSPORTABLE_EDITS = entry.env.SAP_ALLOW_TRANSPORTABLE_EDITS;
|
|
360
|
+
}
|
|
346
361
|
delete nextEnvironment.BAS_VSP_DESTINATION;
|
|
347
362
|
delete nextEnvironment.BAS_VSP_DESTINATION_SOURCE;
|
|
348
363
|
const nextEntry = { ...entry, type: 'stdio', env: nextEnvironment, BAS_EXT: 'true' };
|
|
349
|
-
if (
|
|
350
|
-
|
|
364
|
+
if (entry.command === 'npx') {
|
|
365
|
+
if (packageVersion && Array.isArray(entry.args)) {
|
|
366
|
+
nextEntry.args = entry.args.map(argument => typeof argument === 'string' && /^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/.test(argument)
|
|
367
|
+
? `--package=sap-ai-dev-toolkit@${packageVersion}`
|
|
368
|
+
: argument);
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
nextEntry.command = localCommand;
|
|
372
|
+
}
|
|
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
|
+
}
|
|
351
380
|
repaired += 1;
|
|
352
381
|
}
|
|
353
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');
|