sap-ai-dev-toolkit 0.5.5 → 0.5.6
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 +3 -3
- package/.github/agents/abap-runtime-debugger.agent.md +3 -3
- 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 +3 -3
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
- 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 +2 -2
- 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 +6 -6
- package/inventory.md +179 -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 +8 -11
- package/src/mcp-config.mjs +19 -1
- package/src/mcp-proxy.mjs +230 -76
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +30 -1
- package/tools.md +1 -1
- 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/launcher.mjs
CHANGED
|
@@ -5,12 +5,13 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
|
|
7
7
|
import { findBinary } from './binary.mjs';
|
|
8
|
-
import { MCPProxy
|
|
8
|
+
import { MCPProxy } 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';
|
|
12
12
|
import { withBrandedEnvironment } from './branding.mjs';
|
|
13
13
|
import { enrichWithStoredCredentials } from './credential-overrides.mjs';
|
|
14
|
+
import { redactText } from './redact.mjs';
|
|
14
15
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
15
16
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
16
17
|
const runtimeEnv = withBrandedEnvironment(process.env);
|
|
@@ -47,10 +48,6 @@ function doctorRow(name, stage, status, detail) {
|
|
|
47
48
|
return { name, stage, status, ...(detail ? { detail } : {}) };
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function redactText(value) {
|
|
51
|
-
return String(value).replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
51
|
async function runDoctor(destinations) {
|
|
55
52
|
const checks = [];
|
|
56
53
|
if (!destinations.length) {
|
|
@@ -111,7 +108,7 @@ async function runDoctor(destinations) {
|
|
|
111
108
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
112
109
|
if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
|
|
113
110
|
const tools = listed.result?.tools || [];
|
|
114
|
-
const toolPrefix = `${
|
|
111
|
+
const toolPrefix = `${slugifyDestination(destination.name)}_`;
|
|
115
112
|
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
113
|
const upstreamCount = tools.filter(tool => tool.name.startsWith(toolPrefix) && !localSegments.has(tool.name.slice(toolPrefix.length))).length;
|
|
117
114
|
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`));
|
|
@@ -167,7 +164,8 @@ async function runOfflineDemo() {
|
|
|
167
164
|
childArgs: () => [demoPath],
|
|
168
165
|
destinations: [{ name: 'demo', url: 'http://demo.dest', client: '001', demo: true }],
|
|
169
166
|
env: { ...runtimeEnv, SAP_AI_DEV_TOOLKIT_DESTINATION: 'demo' },
|
|
170
|
-
log: message => console.error(message)
|
|
167
|
+
log: message => console.error(message),
|
|
168
|
+
version: pkg.version
|
|
171
169
|
});
|
|
172
170
|
console.error('[sap-ai-dev-toolkit] offline demo active; all SAP changes are simulated in memory');
|
|
173
171
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
@@ -181,8 +179,7 @@ function probeDiagnostic(destination) {
|
|
|
181
179
|
const details = [];
|
|
182
180
|
if (probe.httpStatus) details.push(`HTTP ${probe.httpStatus}`);
|
|
183
181
|
if (probe.error) {
|
|
184
|
-
const error =
|
|
185
|
-
.replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
182
|
+
const error = redactText(probe.error)
|
|
186
183
|
.replace(/\s+/g, ' ')
|
|
187
184
|
.slice(0, 400);
|
|
188
185
|
details.push(error);
|
|
@@ -262,7 +259,7 @@ async function main() {
|
|
|
262
259
|
let proxy;
|
|
263
260
|
try {
|
|
264
261
|
const binary = await binaryOrError();
|
|
265
|
-
proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine });
|
|
262
|
+
proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine, version: pkg.version });
|
|
266
263
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
267
264
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
268
265
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
@@ -301,7 +298,7 @@ async function main() {
|
|
|
301
298
|
else if (destination.credentials?.host) logLine(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
|
|
302
299
|
}
|
|
303
300
|
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 });
|
|
301
|
+
const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine, version: pkg.version });
|
|
305
302
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
306
303
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
307
304
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
package/src/mcp-config.mjs
CHANGED
|
@@ -3,11 +3,27 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { brandedEnvValue } from './branding.mjs';
|
|
7
8
|
import { slugifyDestination } from './bas-discovery.mjs';
|
|
8
9
|
|
|
9
10
|
const LEGACY_MCP_SERVER_PREFIXES = ['sapAiDev_', 'basVspMcp_'];
|
|
10
11
|
const COMPANION_SERVER_KIND = 'sap-development-companion';
|
|
12
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
13
|
+
|
|
14
|
+
// The self-package companion (HANA inspector) must launch the exact version
|
|
15
|
+
// the user installed; an unpinned `npx sap-ai-dev-toolkit` would re-resolve
|
|
16
|
+
// executable code from the registry at every MCP host start.
|
|
17
|
+
let selfPackageVersion;
|
|
18
|
+
async function runningPackageVersion() {
|
|
19
|
+
if (selfPackageVersion !== undefined) return selfPackageVersion;
|
|
20
|
+
try {
|
|
21
|
+
selfPackageVersion = String(JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version || '');
|
|
22
|
+
} catch {
|
|
23
|
+
selfPackageVersion = '';
|
|
24
|
+
}
|
|
25
|
+
return selfPackageVersion;
|
|
26
|
+
}
|
|
11
27
|
|
|
12
28
|
export const SAP_DEVELOPMENT_MCP_SERVERS = Object.freeze([
|
|
13
29
|
Object.freeze({
|
|
@@ -294,9 +310,11 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
294
310
|
const env = options.env || process.env;
|
|
295
311
|
const path = options.path || await resolveMcpConfigPath(env);
|
|
296
312
|
const config = await readConfig(path);
|
|
313
|
+
const selfVersion = await runningPackageVersion();
|
|
314
|
+
const companionVersions = selfVersion ? { 'sap-ai-dev-toolkit': selfVersion, 'hana-cloud-inspector': selfVersion } : {};
|
|
297
315
|
const generated = {
|
|
298
316
|
...buildMcpEntries(destinations, env),
|
|
299
|
-
...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [])
|
|
317
|
+
...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [], { packageVersions: companionVersions })
|
|
300
318
|
};
|
|
301
319
|
const launcherCommand = options.command || (destinations.length ? await resolveMcpServerCommand(env) : null);
|
|
302
320
|
if (launcherCommand) {
|
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,7 +609,7 @@ 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 = [];
|
|
@@ -524,7 +625,7 @@ export class MCPProxy {
|
|
|
524
625
|
};
|
|
525
626
|
for (const entry of this.children) {
|
|
526
627
|
const slug = slugifyDestination(entry.destination.name, usedSlugs);
|
|
527
|
-
const publicName = name => `${
|
|
628
|
+
const publicName = name => `${slug}_${publicToolSegment(name)}`;
|
|
528
629
|
const lintName = publicName(ABAP_LINT_TOOL.name);
|
|
529
630
|
publish(lintName, { handler: runABAPLint }, { ...ABAP_LINT_TOOL, name: lintName });
|
|
530
631
|
try {
|
|
@@ -544,7 +645,7 @@ export class MCPProxy {
|
|
|
544
645
|
});
|
|
545
646
|
}
|
|
546
647
|
|
|
547
|
-
if (!exposeVspTool(tool)) continue;
|
|
648
|
+
if (!exposeVspTool(tool, this.readOnly)) continue;
|
|
548
649
|
const name = publicName(tool.name);
|
|
549
650
|
publish(name, { entry, upstream: tool.name }, { ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
|
|
550
651
|
}
|
|
@@ -557,6 +658,7 @@ export class MCPProxy {
|
|
|
557
658
|
});
|
|
558
659
|
}
|
|
559
660
|
} catch (error) {
|
|
661
|
+
buildState.complete = false;
|
|
560
662
|
this.eventSink(`[${entry.destination.name}] tools/list failed: ${redactText(error.message)}`, 'error');
|
|
561
663
|
}
|
|
562
664
|
}
|
|
@@ -578,7 +680,7 @@ export class MCPProxy {
|
|
|
578
680
|
if (message.method === 'initialize') {
|
|
579
681
|
await this.initializeChildren(message.params || {});
|
|
580
682
|
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:
|
|
683
|
+
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
684
|
}
|
|
583
685
|
if (!this.initialized) return rpcError(message.id, -32002, 'MCP proxy is not initialized');
|
|
584
686
|
if (message.method === 'logging/setLevel' && MCP_LOG_LEVELS.includes(message.params?.level)) {
|
|
@@ -592,6 +694,10 @@ export class MCPProxy {
|
|
|
592
694
|
}
|
|
593
695
|
if (message.method === 'tools/call') {
|
|
594
696
|
const name = message.params?.name;
|
|
697
|
+
// Concurrent dispatch means a tools/call can race the first
|
|
698
|
+
// tools/list that builds the tool namespace; ensure the surface
|
|
699
|
+
// exists before resolving the name.
|
|
700
|
+
if (!this.namespace.size) await this.mergedTools().catch(() => {});
|
|
595
701
|
const mapped = this.namespace.get(name);
|
|
596
702
|
if (!mapped) return rpcError(message.id, -32602, `Unknown namespaced tool: ${name}`);
|
|
597
703
|
if (mapped.handler) return rpcResult(message.id, await mapped.handler(message.params?.arguments));
|
|
@@ -615,8 +721,15 @@ export class MCPProxy {
|
|
|
615
721
|
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`, 'error');
|
|
616
722
|
// Self-healing: a dead child (crash, OOM, transient pipe break) is
|
|
617
723
|
// restarted and the call retried once before surfacing the error.
|
|
724
|
+
// State-changing tools are never retried: the child may have
|
|
725
|
+
// completed the write before dying, and a blind re-send would
|
|
726
|
+
// apply it twice.
|
|
618
727
|
const childBroken = mapped.entry.child.exited || !mapped.entry.child.process.stdin.writable;
|
|
619
|
-
|
|
728
|
+
const retriable = !NON_RETRIABLE_VSP_TOOLS.has(mapped.upstream);
|
|
729
|
+
if (!retriable) {
|
|
730
|
+
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} is state-changing; not retried after a child crash (possible duplicate write)`, 'warning');
|
|
731
|
+
}
|
|
732
|
+
if (childBroken && retriable && !this.shuttingDown) {
|
|
620
733
|
try {
|
|
621
734
|
await this.restartChild(mapped.entry);
|
|
622
735
|
this.eventSink(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
|
|
@@ -645,33 +758,74 @@ export class MCPProxy {
|
|
|
645
758
|
this.output = output;
|
|
646
759
|
this.start();
|
|
647
760
|
let buffer = '';
|
|
648
|
-
|
|
761
|
+
// Requests are dispatched concurrently so one slow tools/call (an ATC
|
|
762
|
+
// run, a big query) cannot block ping, tools/list, or notifications and
|
|
763
|
+
// make the host declare the server unresponsive. JSON-RPC ids make
|
|
764
|
+
// response order irrelevant to the client; only the initialize handshake
|
|
765
|
+
// must finish first, so every request awaits the gate captured at parse
|
|
766
|
+
// time. In-flight requests are awaited on stream end so their responses
|
|
767
|
+
// are still written before shutdown.
|
|
768
|
+
let initGate = Promise.resolve();
|
|
769
|
+
const inFlight = new Set();
|
|
770
|
+
const dispatch = async message => {
|
|
771
|
+
try {
|
|
772
|
+
const response = await this.handle(message);
|
|
773
|
+
if (!response) return;
|
|
774
|
+
output(JSON.stringify(response));
|
|
775
|
+
if (message.method === 'initialize') {
|
|
776
|
+
this.clientInitialized = true;
|
|
777
|
+
for (const notification of this.pendingNotifications) output(JSON.stringify(notification));
|
|
778
|
+
this.pendingNotifications.length = 0;
|
|
779
|
+
}
|
|
780
|
+
} catch (error) {
|
|
781
|
+
// One failed dispatch must never poison the stream: log and keep
|
|
782
|
+
// serving whatever the client sends next.
|
|
783
|
+
this.eventSink(`[MCP] failed to answer client message: ${diagnosticText(error.message)}`, 'error');
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
const track = promise => {
|
|
787
|
+
inFlight.add(promise);
|
|
788
|
+
promise.finally(() => inFlight.delete(promise)).catch(() => {});
|
|
789
|
+
};
|
|
790
|
+
const onData = chunk => {
|
|
649
791
|
buffer += chunk;
|
|
650
792
|
let newline;
|
|
651
793
|
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
652
794
|
const line = buffer.slice(0, newline).trim();
|
|
653
795
|
buffer = buffer.slice(newline + 1);
|
|
654
796
|
if (!line) continue;
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
797
|
+
let message;
|
|
798
|
+
try { message = JSON.parse(line); }
|
|
799
|
+
catch (error) {
|
|
800
|
+
this.eventSink(`[MCP] invalid client message dropped: ${redactText(error.message)}`, 'error');
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (message?.method === 'initialize' && message.id !== undefined) {
|
|
804
|
+
// Serialize the handshake itself so a second initialize cannot
|
|
805
|
+
// race the first into initializeChildren.
|
|
806
|
+
const run = initGate.then(() => dispatch(message));
|
|
807
|
+
initGate = run;
|
|
808
|
+
track(run);
|
|
809
|
+
} else if (message?.id !== undefined) {
|
|
810
|
+
track(initGate.then(() => dispatch(message)));
|
|
811
|
+
} else {
|
|
812
|
+
track(dispatch(message));
|
|
813
|
+
}
|
|
667
814
|
}
|
|
668
815
|
};
|
|
669
816
|
input.setEncoding('utf8');
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
817
|
+
input.on('error', error => {
|
|
818
|
+
this.eventSink(`[MCP] client input stream failed: ${diagnosticText(error.message)}`, 'error');
|
|
819
|
+
void this.close();
|
|
820
|
+
});
|
|
821
|
+
input.on('data', onData);
|
|
822
|
+
try {
|
|
823
|
+
await once(input, 'end');
|
|
824
|
+
await Promise.allSettled([...inFlight]);
|
|
825
|
+
} finally {
|
|
826
|
+
input.off('data', onData);
|
|
827
|
+
await this.close();
|
|
828
|
+
}
|
|
675
829
|
}
|
|
676
830
|
|
|
677
831
|
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
|
+
}
|