sap-ai-dev-toolkit 0.5.5 → 0.5.7
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 +8 -8
- package/.github/agents/abap-runtime-debugger.agent.md +6 -6
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +5 -5
- package/.github/agents/sap-solution-architect.agent.md +2 -2
- package/.github/skills/abap-debugging/SKILL.md +3 -3
- package/.github/skills/abap-development/SKILL.md +3 -3
- package/.github/skills/abap-runtime-analysis/SKILL.md +4 -4
- package/.github/skills/abap-testing-quality/SKILL.md +3 -3
- package/.github/skills/cds-development/SKILL.md +3 -3
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +2 -2
- package/.github/skills/hana-cloud-native-development/SKILL.md +2 -2
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +3 -3
- package/.github/skills/rap-service-delivery/SKILL.md +2 -2
- 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 +2 -2
- package/README.md +30 -23
- package/inventory.md +181 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +14 -16
- package/src/bas-destination-relay.mjs +25 -3
- package/src/bas-discovery.mjs +9 -3
- package/src/binary.mjs +101 -17
- package/src/cf-destination.mjs +55 -1
- package/src/credentials-store.mjs +0 -10
- package/src/engineering-tools.mjs +32 -10
- package/src/hana-tools.mjs +9 -2
- package/src/launcher.mjs +14 -13
- package/src/mcp-config.mjs +19 -1
- package/src/mcp-proxy.mjs +284 -103
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +30 -1
- package/tools.md +10 -2
- package/test/bas-destination-relay.test.mjs +0 -415
- package/test/cf-connectivity.test.mjs +0 -287
- package/test/cf-destination.test.mjs +0 -307
- package/test/cf-runtime.test.mjs +0 -527
- package/test/copilot-assets.test.mjs +0 -63
- package/test/copilot-content.test.mjs +0 -249
- package/test/credentials-store.test.mjs +0 -187
- package/test/discovery.test.mjs +0 -282
- package/test/fixtures/fake-vsp.mjs +0 -266
- package/test/hana-config.test.mjs +0 -168
- package/test/hana-inspector-stdio.test.mjs +0 -44
- package/test/hana-tools.test.mjs +0 -206
- package/test/launcher.test.mjs +0 -401
- package/test/live-s4h.test.mjs +0 -204
- package/test/mcp-config-cf.test.mjs +0 -475
- package/test/mcp-proxy.test.mjs +0 -604
- package/test/pty.mjs +0 -8
- package/test/setup-cf.test.mjs +0 -489
- package/test/setup.test.mjs +0 -417
- package/test/terminal-ui.test.mjs +0 -47
package/src/mcp-proxy.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
|
|
|
5
5
|
import { createEngineeringTools } from './engineering-tools.mjs';
|
|
6
6
|
import { brandedEnvValue } from './branding.mjs';
|
|
7
7
|
import { createBasDestinationRelay } from './bas-destination-relay.mjs';
|
|
8
|
+
import { diagnosticText, redactText } from './redact.mjs';
|
|
8
9
|
|
|
9
10
|
const JSONRPC = '2.0';
|
|
10
11
|
const FORWARDED_METHODS = new Set(['ping', 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'completion/complete', 'logging/setLevel']);
|
|
@@ -37,6 +38,51 @@ const APPLICATION_LOG_SCHEMA = {
|
|
|
37
38
|
};
|
|
38
39
|
const APPLICATION_LOG_DESCRIPTION = 'Read SAP application log (SLG1) entries. Results are newest first and limited to 100 by default; set messages=true to include log message details.';
|
|
39
40
|
|
|
41
|
+
// Read-only mode (SAP_AI_DEV_TOOLKIT_READ_ONLY=true) removes every tool that
|
|
42
|
+
// changes SAP state from the public surface and starts VSP with
|
|
43
|
+
// --transport-read-only, so an exploration destination cannot be written to
|
|
44
|
+
// through this proxy even if a chat prompt asks for it.
|
|
45
|
+
const READ_ONLY_HIDDEN_VSP_TOOLS = new Set([
|
|
46
|
+
'Activate',
|
|
47
|
+
'ActivateMultiple',
|
|
48
|
+
'CreateTransport',
|
|
49
|
+
'EditSource',
|
|
50
|
+
'SetBreakpoint',
|
|
51
|
+
'WriteSource'
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// Self-healing restarts retry the interrupted tools/call once. Retrying a
|
|
55
|
+
// state-changing tool after a crash can apply the same write twice (the child
|
|
56
|
+
// may have completed the side effect before dying), so these are never retried;
|
|
57
|
+
// the original error is surfaced instead.
|
|
58
|
+
const NON_RETRIABLE_VSP_TOOLS = new Set([
|
|
59
|
+
'Activate',
|
|
60
|
+
'ActivateMultiple',
|
|
61
|
+
'CreateTransport',
|
|
62
|
+
'EditSource',
|
|
63
|
+
'WriteSource'
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const MAX_CHILD_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
67
|
+
const MAX_PENDING_NOTIFICATIONS = 200;
|
|
68
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
69
|
+
const MAX_RESTARTS_PER_WINDOW = 5;
|
|
70
|
+
const RESTART_WINDOW_MS = 5 * 60 * 1000;
|
|
71
|
+
|
|
72
|
+
function requestTimeoutMs(env) {
|
|
73
|
+
const raw = brandedEnvValue(env, 'REQUEST_TIMEOUT_MS');
|
|
74
|
+
if (raw === undefined || raw === '') return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
75
|
+
const parsed = Number(raw);
|
|
76
|
+
// 0 or a negative value disables the timeout; invalid values fall back.
|
|
77
|
+
if (!Number.isFinite(parsed)) return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
78
|
+
return parsed > 0 ? Math.floor(parsed) : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readOnlyMode(env = process.env) {
|
|
82
|
+
const raw = brandedEnvValue(env, 'READ_ONLY');
|
|
83
|
+
return raw !== undefined && ['1', 'true', 'yes'].includes(String(raw).trim().toLowerCase());
|
|
84
|
+
}
|
|
85
|
+
|
|
40
86
|
// Keep the default public VSP surface small enough for developer-lifecycle use.
|
|
41
87
|
// Hidden upstream tools can still be used by local workflow tools when needed.
|
|
42
88
|
export const PUBLIC_VSP_TOOLS = new Set([
|
|
@@ -93,8 +139,11 @@ export const PUBLIC_VSP_TOOLS = new Set([
|
|
|
93
139
|
'WriteSource'
|
|
94
140
|
]);
|
|
95
141
|
|
|
96
|
-
function exposeVspTool(tool) {
|
|
97
|
-
|
|
142
|
+
function exposeVspTool(tool, readOnly = false) {
|
|
143
|
+
if (!PUBLIC_VSP_TOOLS.has(tool?.name)) return false;
|
|
144
|
+
// Read-only destinations never expose the write/activate/transport-create
|
|
145
|
+
// surface, even when VSP registers it.
|
|
146
|
+
return !(readOnly && READ_ONLY_HIDDEN_VSP_TOOLS.has(tool.name));
|
|
98
147
|
}
|
|
99
148
|
|
|
100
149
|
// Public tool names are lowercase snake_case. BAS/VS Code chat tool references
|
|
@@ -108,10 +157,7 @@ export function snakeCaseName(value) {
|
|
|
108
157
|
}
|
|
109
158
|
|
|
110
159
|
// Every generated tool is prefixed with the destination it targets:
|
|
111
|
-
//
|
|
112
|
-
// sap_system_actions4d_get_table_contents.
|
|
113
|
-
export const TOOL_NAME_PREFIX = 'sap_system_';
|
|
114
|
-
|
|
160
|
+
// <destination-slug>_<tool>, for example demo-abap_get_table_contents.
|
|
115
161
|
function publicToolSegment(name) {
|
|
116
162
|
return snakeCaseName(name) || String(name).toLowerCase();
|
|
117
163
|
}
|
|
@@ -127,8 +173,6 @@ function applicationLogArguments(arguments_ = {}) {
|
|
|
127
173
|
|
|
128
174
|
function rpcResult(id, result) { return { jsonrpc: JSONRPC, id, result }; }
|
|
129
175
|
function rpcError(id, code, message, data) { return { jsonrpc: JSONRPC, id, error: { code, message, ...(data === undefined ? {} : { data }) } }; }
|
|
130
|
-
function redactText(text) { return String(text).replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]'); }
|
|
131
|
-
function diagnosticText(text) { return redactText(text || 'unknown error').replace(/\s+/g, ' ').slice(0, 500); }
|
|
132
176
|
|
|
133
177
|
const closedRoutes = new WeakMap();
|
|
134
178
|
|
|
@@ -171,7 +215,8 @@ export function childArguments(destination, env = process.env) {
|
|
|
171
215
|
const mode = brandedEnvValue(env, 'MODE') || 'expert';
|
|
172
216
|
const args = ['--url', destination.url, '--client', destination.client || '001', '--mode', mode];
|
|
173
217
|
if (destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation') args.push('--proxy-auth');
|
|
174
|
-
args.push('--
|
|
218
|
+
if (readOnlyMode(env)) args.push('--transport-read-only');
|
|
219
|
+
else args.push('--enable-transports');
|
|
175
220
|
return args;
|
|
176
221
|
}
|
|
177
222
|
|
|
@@ -184,6 +229,7 @@ class Child {
|
|
|
184
229
|
this.buffer = '';
|
|
185
230
|
this.exited = false;
|
|
186
231
|
this.closing = false;
|
|
232
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
|
|
187
233
|
this.process = (options.spawn || nodeSpawn)(binary, options.args || childArguments(destination, options.env), {
|
|
188
234
|
env: childEnvironment(destination, options.env),
|
|
189
235
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
@@ -194,6 +240,13 @@ class Child {
|
|
|
194
240
|
this.process.stderr.on('data', chunk => {
|
|
195
241
|
if (options.log) options.log(`[${destination.name}] ${redactText(chunk).trimEnd()}`);
|
|
196
242
|
});
|
|
243
|
+
// A write racing child death surfaces as an EPIPE 'error' event on stdin.
|
|
244
|
+
// Without a listener that is an uncaught exception that kills the whole
|
|
245
|
+
// MCP server — exactly the crash window self-healing is meant to cover.
|
|
246
|
+
this.process.stdin.on('error', error => {
|
|
247
|
+
if (!this.closing && this.options.log) this.options.log(`[${destination.name}] VSP child stdin error: ${diagnosticText(error.message)}`);
|
|
248
|
+
this.fail(new Error(`child stdin failed: ${error.message}`));
|
|
249
|
+
});
|
|
197
250
|
this.process.on('error', error => {
|
|
198
251
|
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
|
|
199
252
|
this.fail(error);
|
|
@@ -210,12 +263,21 @@ class Child {
|
|
|
210
263
|
|
|
211
264
|
onData(chunk) {
|
|
212
265
|
this.buffer += chunk;
|
|
266
|
+
if (this.buffer.length > MAX_CHILD_BUFFER_BYTES) {
|
|
267
|
+
// A child streaming non-JSON garbage would grow this buffer unbounded.
|
|
268
|
+
this.fail(new Error('child stdout exceeded the buffered line limit'));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
213
271
|
let newline;
|
|
214
272
|
while ((newline = this.buffer.indexOf('\n')) >= 0) {
|
|
215
273
|
const line = this.buffer.slice(0, newline).trim();
|
|
216
274
|
this.buffer = this.buffer.slice(newline + 1);
|
|
217
275
|
if (!line) continue;
|
|
218
|
-
|
|
276
|
+
// One garbled line (interleaved stderr, partial write) must not reject
|
|
277
|
+
// every in-flight request: skip it and let the per-request timeout or
|
|
278
|
+
// child exit handle a genuinely desynchronized stream.
|
|
279
|
+
try { this.onMessage(JSON.parse(line)); }
|
|
280
|
+
catch (error) { this.options.log?.(`[${this.destination.name}] dropped a non-JSON child line: ${diagnosticText(error.message)}`); }
|
|
219
281
|
}
|
|
220
282
|
}
|
|
221
283
|
|
|
@@ -250,9 +312,22 @@ class Child {
|
|
|
250
312
|
if (this.exited || !this.process.stdin.writable) return Promise.reject(new Error(`destination ${this.destination.name} child is not running`));
|
|
251
313
|
const id = this.nextId++;
|
|
252
314
|
return new Promise((resolve, reject) => {
|
|
253
|
-
|
|
315
|
+
// A hung child (network stall, backend deadlock) must not hang the call
|
|
316
|
+
// forever. The timeout only fails this request; the child is left alone
|
|
317
|
+
// because killing it would also kill unrelated in-flight requests.
|
|
318
|
+
let timeout;
|
|
319
|
+
const cancel = () => clearTimeout(timeout);
|
|
320
|
+
this.pending.set(id, {
|
|
321
|
+
resolve: value => { cancel(); resolve(value); },
|
|
322
|
+
reject: error => { cancel(); reject(error); }
|
|
323
|
+
});
|
|
324
|
+
if (this.requestTimeoutMs > 0) {
|
|
325
|
+
timeout = setTimeout(() => {
|
|
326
|
+
if (this.pending.delete(id)) reject(new Error(`destination ${this.destination.name} did not answer ${method} within ${this.requestTimeoutMs}ms`));
|
|
327
|
+
}, this.requestTimeoutMs);
|
|
328
|
+
}
|
|
254
329
|
try { this.process.stdin.write(`${JSON.stringify({ jsonrpc: JSONRPC, id, method, ...(params === undefined ? {} : { params })})}\n`); }
|
|
255
|
-
catch (error) { this.pending.delete(id); reject(error); }
|
|
330
|
+
catch (error) { cancel(); this.pending.delete(id); reject(error); }
|
|
256
331
|
});
|
|
257
332
|
}
|
|
258
333
|
|
|
@@ -310,7 +385,7 @@ class Child {
|
|
|
310
385
|
}
|
|
311
386
|
|
|
312
387
|
export class MCPProxy {
|
|
313
|
-
constructor({ binary, destinations, env = process.env, spawn = nodeSpawn, childArgs, log = message => console.error(message), output = line => process.stdout.write(`${line}\n`) }) {
|
|
388
|
+
constructor({ binary, destinations, env = process.env, spawn = nodeSpawn, childArgs, log = message => console.error(message), output = line => process.stdout.write(`${line}\n`), version = '0.0.0' }) {
|
|
314
389
|
this.binary = binary;
|
|
315
390
|
this.destinations = destinations;
|
|
316
391
|
this.env = env;
|
|
@@ -318,6 +393,9 @@ export class MCPProxy {
|
|
|
318
393
|
this.childArgs = childArgs;
|
|
319
394
|
this.log = log;
|
|
320
395
|
this.output = output;
|
|
396
|
+
this.version = version;
|
|
397
|
+
this.readOnly = readOnlyMode(env);
|
|
398
|
+
this.requestTimeoutMs = requestTimeoutMs(env);
|
|
321
399
|
this.children = [];
|
|
322
400
|
this.started = false;
|
|
323
401
|
this.namespace = new Map();
|
|
@@ -338,13 +416,32 @@ export class MCPProxy {
|
|
|
338
416
|
// notifications/message log event, in the host's MCP server output channel
|
|
339
417
|
// (VS Code/BAS and Claude Code both render these). Events raised before the
|
|
340
418
|
// client initializes are buffered so the channel still shows the full
|
|
341
|
-
// startup picture once it opens.
|
|
419
|
+
// startup picture once it opens. Everything passes through redactText so no
|
|
420
|
+
// lane can leak a credential that reached a diagnostic string.
|
|
342
421
|
eventSink(message, level = 'info', logger = 'sap-ai-dev-toolkit') {
|
|
343
|
-
|
|
422
|
+
const text = redactText(message);
|
|
423
|
+
this.log(text);
|
|
344
424
|
if (!this.mcpLogEnabled(level)) return;
|
|
345
|
-
const notification = { jsonrpc: JSONRPC, method: 'notifications/message', params: { level, logger, data:
|
|
425
|
+
const notification = { jsonrpc: JSONRPC, method: 'notifications/message', params: { level, logger, data: text } };
|
|
346
426
|
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
347
|
-
else this.pendingNotifications.push(notification);
|
|
427
|
+
else if (this.pendingNotifications.length < MAX_PENDING_NOTIFICATIONS) this.pendingNotifications.push(notification);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Shared spawn options for initial start and self-healing restarts, so the
|
|
431
|
+
// replacement child behaves exactly like the one it replaces.
|
|
432
|
+
childSpawnOptions(destination) {
|
|
433
|
+
return {
|
|
434
|
+
env: this.env,
|
|
435
|
+
spawn: this.spawn,
|
|
436
|
+
args: this.childArgs?.(destination),
|
|
437
|
+
requestTimeoutMs: this.requestTimeoutMs,
|
|
438
|
+
log: message => this.eventSink(message),
|
|
439
|
+
onNotification: notification => {
|
|
440
|
+
if (notification.method === 'notifications/tools/list_changed') this.toolsCache = null;
|
|
441
|
+
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
442
|
+
else if (this.pendingNotifications.length < MAX_PENDING_NOTIFICATIONS) this.pendingNotifications.push(notification);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
348
445
|
}
|
|
349
446
|
|
|
350
447
|
start() {
|
|
@@ -370,8 +467,11 @@ export class MCPProxy {
|
|
|
370
467
|
stats: relay.stats
|
|
371
468
|
},
|
|
372
469
|
async close() {
|
|
373
|
-
|
|
374
|
-
|
|
470
|
+
try {
|
|
471
|
+
await originalClose?.();
|
|
472
|
+
} finally {
|
|
473
|
+
await relay.close();
|
|
474
|
+
}
|
|
375
475
|
}
|
|
376
476
|
};
|
|
377
477
|
this.eventSink(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
|
|
@@ -379,17 +479,7 @@ export class MCPProxy {
|
|
|
379
479
|
this.eventSink(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
|
|
380
480
|
return {
|
|
381
481
|
destination,
|
|
382
|
-
child: new Child(this.binary, destination,
|
|
383
|
-
env: this.env,
|
|
384
|
-
spawn: this.spawn,
|
|
385
|
-
args: this.childArgs?.(destination),
|
|
386
|
-
log: message => this.eventSink(message),
|
|
387
|
-
onNotification: notification => {
|
|
388
|
-
if (notification.method === 'notifications/tools/list_changed') this.toolsCache = null;
|
|
389
|
-
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
390
|
-
else this.pendingNotifications.push(notification);
|
|
391
|
-
}
|
|
392
|
-
})
|
|
482
|
+
child: new Child(this.binary, destination, this.childSpawnOptions(destination))
|
|
393
483
|
};
|
|
394
484
|
} catch (error) {
|
|
395
485
|
this.eventSink(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`, 'error');
|
|
@@ -417,30 +507,38 @@ export class MCPProxy {
|
|
|
417
507
|
// Self-healing mode 3: transparently restart a crashed VSP child so an
|
|
418
508
|
// MCP tools/call does not permanently fail after a single crash. The
|
|
419
509
|
// restarted child re-initializes and re-registers tools before the call
|
|
420
|
-
// is retried once.
|
|
510
|
+
// is retried once. Concurrent triggers share one restart (no orphaned
|
|
511
|
+
// children), and a bounded budget within a rolling window stops a
|
|
512
|
+
// crash-looping child from respawning forever.
|
|
421
513
|
async restartChild(entry) {
|
|
422
514
|
if (this.shuttingDown) throw new Error('MCP proxy is shutting down');
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
515
|
+
entry.restartState ||= { inFlight: null, timestamps: [] };
|
|
516
|
+
const state = entry.restartState;
|
|
517
|
+
if (state.inFlight) return state.inFlight;
|
|
518
|
+
const now = Date.now();
|
|
519
|
+
state.timestamps = state.timestamps.filter(at => now - at < RESTART_WINDOW_MS);
|
|
520
|
+
if (state.timestamps.length >= MAX_RESTARTS_PER_WINDOW) {
|
|
521
|
+
throw new Error(`VSP child restart budget exhausted (${MAX_RESTARTS_PER_WINDOW} in ${RESTART_WINDOW_MS / 1000}s); surfacing the failure instead of respawning`);
|
|
522
|
+
}
|
|
523
|
+
state.inFlight = (async () => {
|
|
524
|
+
const name = entry.destination.name;
|
|
525
|
+
this.eventSink(`[${name}] VSP child crashed; self-healing restart in progress`, 'warning');
|
|
526
|
+
try {
|
|
527
|
+
await entry.child.close().catch(() => {});
|
|
528
|
+
const child = new Child(this.binary, entry.destination, this.childSpawnOptions(entry.destination));
|
|
529
|
+
entry.child = child;
|
|
530
|
+
entry.server = await child.initialize(this.lastInitializeParams || {});
|
|
531
|
+
this.toolsCache = null;
|
|
532
|
+
await child.listToolsCached().catch(() => {});
|
|
533
|
+
state.timestamps.push(Date.now());
|
|
534
|
+
this.eventSink(`[${name}] VSP child self-healing restart complete`);
|
|
535
|
+
this.emitToolsListChanged();
|
|
536
|
+
return entry;
|
|
537
|
+
} finally {
|
|
538
|
+
state.inFlight = null;
|
|
435
539
|
}
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
entry.server = await child.initialize(this.lastInitializeParams || {});
|
|
439
|
-
this.toolsCache = null;
|
|
440
|
-
await child.listToolsCached().catch(() => {});
|
|
441
|
-
this.eventSink(`[${name}] VSP child self-healing restart complete`);
|
|
442
|
-
this.emitToolsListChanged();
|
|
443
|
-
return entry;
|
|
540
|
+
})();
|
|
541
|
+
return state.inFlight;
|
|
444
542
|
}
|
|
445
543
|
|
|
446
544
|
emitToolsListChanged() {
|
|
@@ -494,11 +592,14 @@ export class MCPProxy {
|
|
|
494
592
|
if (this.mergedToolsPromise) return this.mergedToolsPromise;
|
|
495
593
|
if (this.toolsCacheValid()) return Promise.resolve([...this.toolsCache.tools]);
|
|
496
594
|
const childrenAtBuild = this.children.map(entry => entry.child);
|
|
497
|
-
|
|
595
|
+
// A build whose child listing failed is incomplete; caching it would pin
|
|
596
|
+
// the destination's missing tools until a restart or list_changed.
|
|
597
|
+
const buildState = { complete: true };
|
|
598
|
+
this.mergedToolsPromise = this.buildMergedTools(buildState)
|
|
498
599
|
.then(tools => {
|
|
499
600
|
// Cache only when the child set is unchanged; a restart that landed
|
|
500
601
|
// mid-build invalidates the result and the next list rebuilds.
|
|
501
|
-
if (!this.shuttingDown && this.children.length === childrenAtBuild.length
|
|
602
|
+
if (!this.shuttingDown && buildState.complete && this.children.length === childrenAtBuild.length
|
|
502
603
|
&& this.children.every((entry, index) => entry.child === childrenAtBuild[index])) {
|
|
503
604
|
this.toolsCache = { children: childrenAtBuild, tools };
|
|
504
605
|
}
|
|
@@ -508,10 +609,17 @@ export class MCPProxy {
|
|
|
508
609
|
return this.mergedToolsPromise;
|
|
509
610
|
}
|
|
510
611
|
|
|
511
|
-
async buildMergedTools() {
|
|
612
|
+
async buildMergedTools(buildState = { complete: true }) {
|
|
512
613
|
const namespace = new Map();
|
|
513
614
|
const usedSlugs = new Map();
|
|
514
615
|
const merged = [];
|
|
616
|
+
// Generated MCP entries pin exactly one destination per server, so tool
|
|
617
|
+
// names are unprefixed (run_query, get_source): the server name already
|
|
618
|
+
// identifies the target and a slug prefix would be redundant in chat
|
|
619
|
+
// references. Only when one server fronts several destinations (manual
|
|
620
|
+
// multi-destination startup) does each name carry its destination slug
|
|
621
|
+
// so every tool stays unambiguous.
|
|
622
|
+
const prefixed = this.children.length > 1;
|
|
515
623
|
const publish = (name, mapping, definition) => {
|
|
516
624
|
// A duplicate public name must not shadow the first registration: the
|
|
517
625
|
// namespace map would keep the first handler while advertising both.
|
|
@@ -524,40 +632,54 @@ export class MCPProxy {
|
|
|
524
632
|
};
|
|
525
633
|
for (const entry of this.children) {
|
|
526
634
|
const slug = slugifyDestination(entry.destination.name, usedSlugs);
|
|
527
|
-
const publicName = name => `${
|
|
635
|
+
const publicName = prefixed ? (name => `${slug}_${publicToolSegment(name)}`) : (name => publicToolSegment(name));
|
|
528
636
|
const lintName = publicName(ABAP_LINT_TOOL.name);
|
|
529
637
|
publish(lintName, { handler: runABAPLint }, { ...ABAP_LINT_TOOL, name: lintName });
|
|
638
|
+
let upstreamTools;
|
|
530
639
|
try {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (tool.name === 'SAP') {
|
|
534
|
-
const applicationLogName = publicName('GetApplicationLog');
|
|
535
|
-
publish(applicationLogName, {
|
|
536
|
-
entry,
|
|
537
|
-
upstream: 'SAP',
|
|
538
|
-
publicName: 'GetApplicationLog',
|
|
539
|
-
transformArguments: applicationLogArguments
|
|
540
|
-
}, {
|
|
541
|
-
name: applicationLogName,
|
|
542
|
-
description: `${APPLICATION_LOG_DESCRIPTION} [destination: ${entry.destination.name}]`,
|
|
543
|
-
inputSchema: APPLICATION_LOG_SCHEMA
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
if (!exposeVspTool(tool)) continue;
|
|
548
|
-
const name = publicName(tool.name);
|
|
549
|
-
publish(name, { entry, upstream: tool.name }, { ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
|
|
550
|
-
}
|
|
551
|
-
for (const localTool of createEngineeringTools(entry, upstreamTools, { env: this.env, log: this.log })) {
|
|
552
|
-
const name = publicName(localTool.definition.name);
|
|
553
|
-
publish(name, { handler: localTool.handler }, {
|
|
554
|
-
...localTool.definition,
|
|
555
|
-
name,
|
|
556
|
-
description: `${localTool.definition.description} [destination: ${entry.destination.name}]`
|
|
557
|
-
});
|
|
558
|
-
}
|
|
640
|
+
upstreamTools = await entry.child.listToolsCached();
|
|
641
|
+
entry.lastUpstreamTools = upstreamTools;
|
|
559
642
|
} catch (error) {
|
|
643
|
+
buildState.complete = false;
|
|
560
644
|
this.eventSink(`[${entry.destination.name}] tools/list failed: ${redactText(error.message)}`, 'error');
|
|
645
|
+
// A crashed child must not silently remove its tools from the
|
|
646
|
+
// surface: fall back to the last successful listing so the names stay
|
|
647
|
+
// callable and the tools/call self-heal path can restart the child.
|
|
648
|
+
upstreamTools = entry.lastUpstreamTools;
|
|
649
|
+
}
|
|
650
|
+
if (!upstreamTools) continue;
|
|
651
|
+
for (const tool of upstreamTools) {
|
|
652
|
+
if (tool.name === 'SAP') {
|
|
653
|
+
const applicationLogName = publicName('GetApplicationLog');
|
|
654
|
+
publish(applicationLogName, {
|
|
655
|
+
entry,
|
|
656
|
+
upstream: 'SAP',
|
|
657
|
+
publicName: 'GetApplicationLog',
|
|
658
|
+
transformArguments: applicationLogArguments
|
|
659
|
+
}, {
|
|
660
|
+
name: applicationLogName,
|
|
661
|
+
description: `${APPLICATION_LOG_DESCRIPTION} [destination: ${entry.destination.name}]`,
|
|
662
|
+
inputSchema: APPLICATION_LOG_SCHEMA
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (!exposeVspTool(tool, this.readOnly)) continue;
|
|
667
|
+
const name = publicName(tool.name);
|
|
668
|
+
publish(name, { entry, upstream: tool.name }, { ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
|
|
669
|
+
}
|
|
670
|
+
// Local workflow tools gate on the upstream surface they can use; in
|
|
671
|
+
// read-only mode the hidden write tools must not enable change-set
|
|
672
|
+
// staging either.
|
|
673
|
+
const effectiveUpstream = this.readOnly
|
|
674
|
+
? upstreamTools.filter(tool => !READ_ONLY_HIDDEN_VSP_TOOLS.has(tool.name))
|
|
675
|
+
: upstreamTools;
|
|
676
|
+
for (const localTool of createEngineeringTools(entry, effectiveUpstream, { env: this.env, log: this.log })) {
|
|
677
|
+
const name = publicName(localTool.definition.name);
|
|
678
|
+
publish(name, { handler: localTool.handler }, {
|
|
679
|
+
...localTool.definition,
|
|
680
|
+
name,
|
|
681
|
+
description: `${localTool.definition.description} [destination: ${entry.destination.name}]`
|
|
682
|
+
});
|
|
561
683
|
}
|
|
562
684
|
}
|
|
563
685
|
if (!merged.length && this.children.length) throw new Error('No destination child provided tools');
|
|
@@ -578,7 +700,7 @@ export class MCPProxy {
|
|
|
578
700
|
if (message.method === 'initialize') {
|
|
579
701
|
await this.initializeChildren(message.params || {});
|
|
580
702
|
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:
|
|
703
|
+
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: this.version } });
|
|
582
704
|
}
|
|
583
705
|
if (!this.initialized) return rpcError(message.id, -32002, 'MCP proxy is not initialized');
|
|
584
706
|
if (message.method === 'logging/setLevel' && MCP_LOG_LEVELS.includes(message.params?.level)) {
|
|
@@ -592,6 +714,10 @@ export class MCPProxy {
|
|
|
592
714
|
}
|
|
593
715
|
if (message.method === 'tools/call') {
|
|
594
716
|
const name = message.params?.name;
|
|
717
|
+
// Concurrent dispatch means a tools/call can race the first
|
|
718
|
+
// tools/list that builds the tool namespace; ensure the surface
|
|
719
|
+
// exists before resolving the name.
|
|
720
|
+
if (!this.namespace.size) await this.mergedTools().catch(() => {});
|
|
595
721
|
const mapped = this.namespace.get(name);
|
|
596
722
|
if (!mapped) return rpcError(message.id, -32602, `Unknown namespaced tool: ${name}`);
|
|
597
723
|
if (mapped.handler) return rpcResult(message.id, await mapped.handler(message.params?.arguments));
|
|
@@ -615,17 +741,31 @@ export class MCPProxy {
|
|
|
615
741
|
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`, 'error');
|
|
616
742
|
// Self-healing: a dead child (crash, OOM, transient pipe break) is
|
|
617
743
|
// restarted and the call retried once before surfacing the error.
|
|
744
|
+
// State-changing tools are never retried: the child may have
|
|
745
|
+
// completed the write before dying, and a blind re-send would
|
|
746
|
+
// apply it twice.
|
|
618
747
|
const childBroken = mapped.entry.child.exited || !mapped.entry.child.process.stdin.writable;
|
|
619
|
-
|
|
748
|
+
const retriable = !NON_RETRIABLE_VSP_TOOLS.has(mapped.upstream);
|
|
749
|
+
if (!retriable) {
|
|
750
|
+
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} is state-changing; not retried after a child crash (possible duplicate write)`, 'warning');
|
|
751
|
+
}
|
|
752
|
+
let restartFailure;
|
|
753
|
+
if (childBroken && retriable && !this.shuttingDown) {
|
|
620
754
|
try {
|
|
621
755
|
await this.restartChild(mapped.entry);
|
|
622
756
|
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
|
|
623
757
|
const retried = await mapped.entry.child.request('tools/call', upstreamParams);
|
|
624
758
|
return retried.error ? { ...retried, id: message.id } : rpcResult(message.id, retried.result);
|
|
625
759
|
} catch (restartError) {
|
|
760
|
+
restartFailure = restartError;
|
|
626
761
|
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} self-healing restart failed: ${diagnosticText(restartError.message)}`, 'error');
|
|
627
762
|
}
|
|
628
763
|
}
|
|
764
|
+
if (restartFailure) {
|
|
765
|
+
// The restart refusal (budget exhausted, shutdown) is the
|
|
766
|
+
// actionable cause; keep the original failure as context.
|
|
767
|
+
return rpcError(message.id, -32001, `Destination ${mapped.entry.destination.name} failed: ${error.message}; self-healing restart did not run: ${restartFailure.message}`);
|
|
768
|
+
}
|
|
629
769
|
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}`);
|
|
630
770
|
}
|
|
631
771
|
}
|
|
@@ -645,33 +785,74 @@ export class MCPProxy {
|
|
|
645
785
|
this.output = output;
|
|
646
786
|
this.start();
|
|
647
787
|
let buffer = '';
|
|
648
|
-
|
|
788
|
+
// Requests are dispatched concurrently so one slow tools/call (an ATC
|
|
789
|
+
// run, a big query) cannot block ping, tools/list, or notifications and
|
|
790
|
+
// make the host declare the server unresponsive. JSON-RPC ids make
|
|
791
|
+
// response order irrelevant to the client; only the initialize handshake
|
|
792
|
+
// must finish first, so every request awaits the gate captured at parse
|
|
793
|
+
// time. In-flight requests are awaited on stream end so their responses
|
|
794
|
+
// are still written before shutdown.
|
|
795
|
+
let initGate = Promise.resolve();
|
|
796
|
+
const inFlight = new Set();
|
|
797
|
+
const dispatch = async message => {
|
|
798
|
+
try {
|
|
799
|
+
const response = await this.handle(message);
|
|
800
|
+
if (!response) return;
|
|
801
|
+
output(JSON.stringify(response));
|
|
802
|
+
if (message.method === 'initialize') {
|
|
803
|
+
this.clientInitialized = true;
|
|
804
|
+
for (const notification of this.pendingNotifications) output(JSON.stringify(notification));
|
|
805
|
+
this.pendingNotifications.length = 0;
|
|
806
|
+
}
|
|
807
|
+
} catch (error) {
|
|
808
|
+
// One failed dispatch must never poison the stream: log and keep
|
|
809
|
+
// serving whatever the client sends next.
|
|
810
|
+
this.eventSink(`[MCP] failed to answer client message: ${diagnosticText(error.message)}`, 'error');
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
const track = promise => {
|
|
814
|
+
inFlight.add(promise);
|
|
815
|
+
promise.finally(() => inFlight.delete(promise)).catch(() => {});
|
|
816
|
+
};
|
|
817
|
+
const onData = chunk => {
|
|
649
818
|
buffer += chunk;
|
|
650
819
|
let newline;
|
|
651
820
|
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
652
821
|
const line = buffer.slice(0, newline).trim();
|
|
653
822
|
buffer = buffer.slice(newline + 1);
|
|
654
823
|
if (!line) continue;
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
824
|
+
let message;
|
|
825
|
+
try { message = JSON.parse(line); }
|
|
826
|
+
catch (error) {
|
|
827
|
+
this.eventSink(`[MCP] invalid client message dropped: ${redactText(error.message)}`, 'error');
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
if (message?.method === 'initialize' && message.id !== undefined) {
|
|
831
|
+
// Serialize the handshake itself so a second initialize cannot
|
|
832
|
+
// race the first into initializeChildren.
|
|
833
|
+
const run = initGate.then(() => dispatch(message));
|
|
834
|
+
initGate = run;
|
|
835
|
+
track(run);
|
|
836
|
+
} else if (message?.id !== undefined) {
|
|
837
|
+
track(initGate.then(() => dispatch(message)));
|
|
838
|
+
} else {
|
|
839
|
+
track(dispatch(message));
|
|
840
|
+
}
|
|
667
841
|
}
|
|
668
842
|
};
|
|
669
843
|
input.setEncoding('utf8');
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
844
|
+
input.on('error', error => {
|
|
845
|
+
this.eventSink(`[MCP] client input stream failed: ${diagnosticText(error.message)}`, 'error');
|
|
846
|
+
void this.close();
|
|
847
|
+
});
|
|
848
|
+
input.on('data', onData);
|
|
849
|
+
try {
|
|
850
|
+
await once(input, 'end');
|
|
851
|
+
await Promise.allSettled([...inFlight]);
|
|
852
|
+
} finally {
|
|
853
|
+
input.off('data', onData);
|
|
854
|
+
await this.close();
|
|
855
|
+
}
|
|
675
856
|
}
|
|
676
857
|
|
|
677
858
|
async close() {
|
package/src/redact.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared credential redaction for every diagnostic lane (stderr, MCP log
|
|
2
|
+
// notifications, tool errors, relay responses). One implementation so a fix
|
|
3
|
+
// applies everywhere; the old per-file copies each missed a different case.
|
|
4
|
+
//
|
|
5
|
+
// Covered patterns:
|
|
6
|
+
// - authorization: Basic <base64> / Bearer <token> / proxy-authorization
|
|
7
|
+
// - cookie: a=b; c=d (the whole pair list, not just the first pair)
|
|
8
|
+
// - password=... / secret=... / token=... / x-csrf-token: ... style keys
|
|
9
|
+
// - URLs with embedded userinfo, https://user:pass@host
|
|
10
|
+
// Bare credential blobs without a recognizable key cannot be detected
|
|
11
|
+
// generically and are out of scope.
|
|
12
|
+
const URL_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)([^\s/@:?=&]+):([^\s/@]+)@/gi;
|
|
13
|
+
const NAMED_VALUE = /((?:proxy-)?authorization|cookie|passwd|password|secret|token)\s*[:=]\s*(?:bearer\s+|basic\s+)?[^\s,;"']+/gi;
|
|
14
|
+
// Header-shaped values may carry lists (cookie pairs); after the first pass
|
|
15
|
+
// redacts the leading pair, this pass drops the remainder of the line.
|
|
16
|
+
const HEADER_TAIL = /^((?:proxy-)?authorization|cookie)\s*[:=].*$/gim;
|
|
17
|
+
|
|
18
|
+
export function redactText(value) {
|
|
19
|
+
return String(value ?? '')
|
|
20
|
+
.replace(URL_USERINFO, '$1[redacted]@')
|
|
21
|
+
.replace(NAMED_VALUE, '$1=[redacted]')
|
|
22
|
+
.replace(HEADER_TAIL, '$1=[redacted]');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Redact, collapse whitespace, and clamp to a diagnostic-friendly length.
|
|
26
|
+
export function diagnosticText(value, limit = 500) {
|
|
27
|
+
return redactText(value?.message || value || 'unknown error').replace(/\s+/g, ' ').trim().slice(0, limit);
|
|
28
|
+
}
|
package/src/setup.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
3
|
import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
|
|
4
4
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
5
|
-
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
5
|
+
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
6
6
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
7
7
|
import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
|
|
8
8
|
import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
|
|
@@ -224,6 +224,35 @@ export async function runSetup({
|
|
|
224
224
|
if (!target.available) {
|
|
225
225
|
warnings.push(target.reason);
|
|
226
226
|
} else {
|
|
227
|
+
// Sweep for service keys this toolkit created but no config references
|
|
228
|
+
// (an interrupted earlier setup leaves them behind; a Destination key
|
|
229
|
+
// exposes credentials for every destination in its instance).
|
|
230
|
+
try {
|
|
231
|
+
const orphans = await findOrphanedCloudFoundryServiceKeys({ env, spaceGuid: target.spaceGuid, managedKeys });
|
|
232
|
+
if (orphans.length) {
|
|
233
|
+
print(output, '');
|
|
234
|
+
print(output, formatStatus(`Found ${orphans.length} unreferenced sap-ai-dev-toolkit service key${orphans.length === 1 ? '' : 's'} in this CF space:`, 'warning', output, 'Cloud Foundry'));
|
|
235
|
+
for (const orphan of orphans.slice(0, 5)) print(output, ` • ${orphan.keyName} on ${orphan.instanceName}`);
|
|
236
|
+
const readline = createInterface({ input, output });
|
|
237
|
+
let removeOrphans = false;
|
|
238
|
+
try {
|
|
239
|
+
const answer = await readline.question('Delete these unreferenced service keys? (y + Enter = delete; Enter = keep) ');
|
|
240
|
+
removeOrphans = /^y(?:es)?$/i.test(answer.trim());
|
|
241
|
+
} finally {
|
|
242
|
+
readline.close();
|
|
243
|
+
}
|
|
244
|
+
if (removeOrphans) {
|
|
245
|
+
const result = await deleteManagedCloudFoundryServiceKeys({ env, keys: orphans });
|
|
246
|
+
warnings.push(...(result.warnings || []));
|
|
247
|
+
const deleted = orphans.length - (result.warnings || []).length;
|
|
248
|
+
print(output, formatStatus(`Removed ${deleted} unreferenced service key${deleted === 1 ? '' : 's'}.`, 'success', output, 'Cloud Foundry'));
|
|
249
|
+
} else {
|
|
250
|
+
warnings.push(`${orphans.length} unreferenced sap-ai-dev-toolkit service key${orphans.length === 1 ? '' : 's'} were kept; review them with "cf service-keys" and delete manually if unwanted.`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// The sweep is best-effort; a failed lookup must not block setup.
|
|
255
|
+
}
|
|
227
256
|
let shouldImport = false;
|
|
228
257
|
try {
|
|
229
258
|
shouldImport = await confirmCfImport({ input, output });
|