sap-ai-dev-toolkit 0.6.5 → 0.6.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.
@@ -4,11 +4,11 @@ import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { installBinary } from '../src/binary.mjs';
6
6
  import { runSetup } from '../src/setup.mjs';
7
- import { installUserCopilotAssets } from './install-user-copilot-assets.mjs';
8
- import { createInterface } from 'node:readline/promises';
7
+ import { installUserAssetsForHarnesses } from '../src/install-user-assets.mjs';
8
+ import { DEFAULT_HARNESS_IDS, HARNESSES, HARNESS_ENV_VAR, NON_INTERACTIVE_HARNESS_IDS, envHarnessSelection, harnessRoot } from '../src/harnesses.mjs';
9
9
  import { ReadStream as TTYReadStream, WriteStream as TTYWriteStream } from 'node:tty';
10
10
  import { homedir } from 'node:os';
11
- import { colorText, formatStatus } from '../src/terminal-ui.mjs';
11
+ import { checkboxPrompt, colorText, formatStatus } from '../src/terminal-ui.mjs';
12
12
  import { brandedEnvValue, withBrandedEnvironment } from '../src/branding.mjs';
13
13
  import { generatedServerName } from '../src/mcp-config.mjs';
14
14
  import { redactText } from '../src/redact.mjs';
@@ -67,47 +67,83 @@ async function runInstallSetup() {
67
67
  return withInstallTerminal(terminal => runSetup({ env: runtimeEnv, ...(terminal || {}) }));
68
68
  }
69
69
 
70
- async function runCopilotAssetInstall() {
71
- const copilotRoot = join(process.env.HOME || homedir(), '.copilot');
72
- const installAssets = async () => {
73
- const assets = await installUserCopilotAssets({ root });
70
+ function harnessSelectionLabels(ids) {
71
+ return HARNESSES.filter(harness => ids.includes(harness.id)).map(harness => harness.label).join(', ');
72
+ }
73
+
74
+ async function resolveHarnessOverride() {
75
+ try {
76
+ return envHarnessSelection(runtimeEnv);
77
+ } catch (error) {
78
+ await announce(`${error.message} Ignoring ${HARNESS_ENV_VAR}.`, 'warning');
79
+ return null;
80
+ }
81
+ }
82
+
83
+ async function installSelectedHarnessAssets(ids) {
84
+ const { results, failures } = await installUserAssetsForHarnesses(ids, { root });
85
+ for (const failure of failures) {
86
+ await announce(`${failure.label}: bundled agent and skill installation failed: ${failure.error.message}`, 'error');
87
+ }
88
+ for (const assets of results) {
74
89
  const installed = assets.installed + assets.updated;
75
- await announce(`Installed ${installed} Copilot files in ${assets.root}; ${assets.unchanged} already current.`, 'success');
90
+ await announce(`${assets.label}: ${installed} file${installed === 1 ? '' : 's'} installed or updated in ${assets.root}; ${assets.unchanged} already current.`, 'success');
76
91
  if (assets.conflicts.length > 0) {
77
92
  const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
78
- await announce(`Existing Copilot customizations were preserved; review these paths: ${paths}`, 'warning');
93
+ await announce(`${assets.label}: existing customizations were preserved; review these paths: ${paths}`, 'warning');
79
94
  }
80
- };
95
+ }
96
+ const total = results.reduce((sum, assets) => sum + assets.installed + assets.updated, 0);
97
+ if (results.length > 0) {
98
+ await announce(`Installed ${total} files across ${results.length} harness${results.length === 1 ? '' : 'es'}.`, 'success');
99
+ }
100
+ }
101
+
102
+ async function runHarnessAssetInstall() {
103
+ const home = process.env.HOME || homedir();
104
+ const override = await resolveHarnessOverride();
81
105
  await withInstallTerminal(async terminal => {
106
+ let selected = override;
82
107
  if (!terminal) {
83
- await announce('Installing bundled agents and skills by default because no interactive terminal is available.', 'progress');
84
- await installAssets();
108
+ if (!selected) selected = NON_INTERACTIVE_HARNESS_IDS;
109
+ await announce(`Installing bundled agents and skills for ${harnessSelectionLabels(selected)} by default because no interactive terminal is available.`, 'progress');
110
+ await installSelectedHarnessAssets(selected);
85
111
  return;
86
112
  }
113
+ if (!selected) selected = DEFAULT_HARNESS_IDS;
87
114
  await announce([
88
- 'Bundled Copilot agents and skills are installed by default.',
89
- '',
90
- 'Type n then press Enter to skip installing or updating the bundled files.',
115
+ 'The bundled agents and skills can be installed for several AI coding harnesses.',
91
116
  '',
92
- `${colorText('✅ Press Enter', 'green', true)} to install or update the bundled agents and all skills.`,
93
- `${colorText('⏭️ Type n then Enter', 'yellow', true)} to skip this step.`,
117
+ 'Space = select or deselect · a = toggle all · Enter = confirm. Pressing Enter without changes installs the pre-checked harnesses; confirming with none selected skips this step.',
94
118
  '',
95
- `${colorText('📁 Target folder:', 'cyan', true)}`,
96
- ` ${copilotRoot}`,
119
+ `${colorText('📁 Target folders:', 'cyan', true)}`,
120
+ ...HARNESSES.map(harness => ` ${harnessRoot(harness, { home, env: runtimeEnv })} — ${harness.label} (${harness.supportsAgents ? 'skills + agents' : 'skills only'})`),
97
121
  ''
98
122
  ].join('\n'), 'copilot');
99
- const prompt = createInterface({ input: terminal.input, output: terminal.output });
100
123
  let answer;
101
124
  try {
102
- answer = await prompt.question(`${colorText('🤖 Install the bundled agents and all skills?', 'magenta', terminal.output)} ${colorText('[Y/n]', 'yellow', terminal.output)} `);
103
- } finally {
104
- prompt.close();
125
+ answer = await checkboxPrompt({
126
+ message: colorText('🤖 Install bundled agents and skills for', 'magenta', terminal.output),
127
+ choices: HARNESSES.map(harness => ({
128
+ value: harness.id,
129
+ name: `${harness.label} (${harness.supportsAgents ? 'skills + agents' : 'skills'})`,
130
+ checked: selected.includes(harness.id)
131
+ })),
132
+ required: false,
133
+ shortcuts: { all: 'a' }
134
+ }, { input: terminal.input, output: terminal.output });
135
+ } catch (error) {
136
+ if (error.message === 'Prompt interrupted') {
137
+ await announce('Bundled agents and skills were skipped because the prompt was interrupted. Your files were not changed.', 'info');
138
+ return;
139
+ }
140
+ throw error;
105
141
  }
106
- if (/^n(?:o)?$/i.test(answer.trim())) {
107
- await announce('Copilot agent and skills were skipped. Your files were not changed.', 'info');
142
+ if (answer.length === 0) {
143
+ await announce('Bundled agents and skills were skipped. Your files were not changed.', 'info');
108
144
  return;
109
145
  }
110
- await installAssets();
146
+ await installSelectedHarnessAssets(answer);
111
147
  });
112
148
  }
113
149
 
@@ -256,9 +292,9 @@ async function main() {
256
292
  }
257
293
 
258
294
  try {
259
- await runCopilotAssetInstall();
295
+ await runHarnessAssetInstall();
260
296
  } catch (error) {
261
- await announce(`Copilot agent and skill installation failed: ${error.message}`, 'error');
297
+ await announce(`Bundled agent and skill installation failed: ${error.message}`, 'error');
262
298
  }
263
299
  if (setupCompleted) await announceSetup(setupResult);
264
300
  }
package/src/abaplint.mjs CHANGED
@@ -2,7 +2,7 @@ import { Config, MemoryFile, Registry } from '@abaplint/core';
2
2
 
3
3
  export const ABAP_LINT_TOOL = {
4
4
  name: 'LintABAP',
5
- description: 'Run abaplint on caller-supplied ABAPGit-serialized files in memory; does not read workspace files or call SAP.',
5
+ description: 'Run abaplint on caller-supplied source files in memory; does not read workspace files or call SAP.',
6
6
  inputSchema: {
7
7
  type: 'object',
8
8
  properties: {
@@ -50,7 +50,7 @@ function validateArguments(arguments_) {
50
50
  }
51
51
  const basename = file.filename.split(/[\\/]/).at(-1);
52
52
  if (!/^[^.]+\.[^.]+\.[^.]+$/.test(basename)) {
53
- throw new Error('Each filename must have an abapGit object.type.extension basename');
53
+ throw new Error('Each filename must have an object.type.extension basename');
54
54
  }
55
55
  }
56
56
  if (arguments_.config !== undefined && !isObject(arguments_.config)) {
@@ -0,0 +1,93 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ export const HARNESS_ENV_VAR = 'SAP_AI_DEV_TOOLKIT_HARNESSES';
5
+ export const DEFAULT_HARNESS_IDS = ['github-copilot', 'claude-code'];
6
+ export const NON_INTERACTIVE_HARNESS_IDS = ['github-copilot'];
7
+
8
+ const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n/;
9
+
10
+ function splitFrontmatter(source) {
11
+ const match = FRONTMATTER_PATTERN.exec(String(source));
12
+ if (!match) return null;
13
+ const fields = match[1].split('\n').map(line => {
14
+ const separator = line.indexOf(':');
15
+ return { key: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() };
16
+ });
17
+ if (fields.some(field => !field.key)) return null;
18
+ return { fields, body: String(source).slice(match[0].length) };
19
+ }
20
+
21
+ function requiredField(fields, key) {
22
+ const field = fields.find(entry => entry.key === key);
23
+ return field && field.value ? field.value : null;
24
+ }
25
+
26
+ function renderDocument(fields, body) {
27
+ return `---\n${fields.map(field => `${field.key}: ${field.value}`).join('\n')}\n---\n${body}`;
28
+ }
29
+
30
+ export function agentMarkdownName(fileName) {
31
+ return String(fileName).replace(/\.agent\.md$/, '.md');
32
+ }
33
+
34
+ export function claudeCodeAgentDocument(source) {
35
+ const frontmatter = splitFrontmatter(source);
36
+ if (!frontmatter) throw new Error('unsupported agent frontmatter');
37
+ const kept = frontmatter.fields
38
+ .filter(field => field.key === 'name' || field.key === 'description')
39
+ .filter(field => field.value);
40
+ if (!requiredField(frontmatter.fields, 'name') || !requiredField(frontmatter.fields, 'description')) {
41
+ throw new Error('unsupported agent frontmatter');
42
+ }
43
+ return renderDocument(kept, frontmatter.body);
44
+ }
45
+
46
+ export function opencodeAgentDocument(source) {
47
+ const frontmatter = splitFrontmatter(source);
48
+ if (!frontmatter) throw new Error('unsupported agent frontmatter');
49
+ const description = requiredField(frontmatter.fields, 'description');
50
+ if (!description) throw new Error('unsupported agent frontmatter');
51
+ return renderDocument([{ key: 'description', value: description }, { key: 'mode', value: 'subagent' }], frontmatter.body);
52
+ }
53
+
54
+ export const HARNESSES = [
55
+ { id: 'github-copilot', label: 'GitHub Copilot', directory: '.copilot', supportsAgents: true, legacyManifest: true },
56
+ { id: 'claude-code', label: 'Claude Code', directory: '.claude', supportsAgents: true, agentFileName: agentMarkdownName, agentDocument: claudeCodeAgentDocument },
57
+ { id: 'codex', label: 'OpenAI Codex', directory: '.agents', supportsAgents: false },
58
+ { id: 'cursor', label: 'Cursor', directory: '.cursor', supportsAgents: false },
59
+ { id: 'gemini-cli', label: 'Gemini CLI', directory: '.gemini', supportsAgents: false },
60
+ { id: 'opencode', label: 'opencode', directory: 'opencode', configHome: true, supportsAgents: true, agentFileName: agentMarkdownName, agentDocument: opencodeAgentDocument }
61
+ ];
62
+
63
+ export function harnessById(id) {
64
+ const harness = HARNESSES.find(entry => entry.id === id);
65
+ if (!harness) {
66
+ throw new Error(`Unknown harness id: ${id}. Valid harness ids: ${HARNESSES.map(entry => entry.id).join(', ')}.`);
67
+ }
68
+ return harness;
69
+ }
70
+
71
+ export function harnessRoot(harness, { home = process.env.HOME || homedir(), env = process.env } = {}) {
72
+ if (harness.configHome) {
73
+ const override = typeof env?.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
74
+ return join(override || join(home, '.config'), harness.directory);
75
+ }
76
+ return join(home, harness.directory);
77
+ }
78
+
79
+ export function parseHarnessSelection(raw) {
80
+ const tokens = [...new Set(String(raw).split(',').map(token => token.trim().toLowerCase()).filter(Boolean))];
81
+ const known = new Set(HARNESSES.map(entry => entry.id));
82
+ const unknown = tokens.filter(token => !known.has(token));
83
+ if (unknown.length > 0) {
84
+ throw new Error(`Unknown harness id${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}. Valid harness ids: ${[...known].join(', ')}.`);
85
+ }
86
+ return HARNESSES.filter(entry => tokens.includes(entry.id)).map(entry => entry.id);
87
+ }
88
+
89
+ export function envHarnessSelection(env = process.env) {
90
+ const raw = env?.[HARNESS_ENV_VAR];
91
+ if (typeof raw !== 'string' || !raw.trim()) return null;
92
+ return parseHarnessSelection(raw);
93
+ }
@@ -0,0 +1,149 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { HARNESSES, harnessById, harnessRoot } from './harnesses.mjs';
7
+
8
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ export const MANIFEST_NAME = '.sap-ai-dev-toolkit-assets.json';
10
+ const LEGACY_MANIFEST_NAME = '.bas-mcp-addon-assets.json';
11
+
12
+ function digest(content) {
13
+ return createHash('sha256').update(content).digest('hex');
14
+ }
15
+
16
+ async function collectFiles(sourceRoot, targetPrefix, relativePath = '') {
17
+ const files = [];
18
+ const entries = await readdir(join(sourceRoot, relativePath), { withFileTypes: true });
19
+ entries.sort((left, right) => left.name.localeCompare(right.name));
20
+ for (const entry of entries) {
21
+ const childPath = join(relativePath, entry.name);
22
+ if (entry.isDirectory()) {
23
+ files.push(...await collectFiles(sourceRoot, targetPrefix, childPath));
24
+ } else if (entry.isFile()) {
25
+ files.push({ source: join(sourceRoot, childPath), target: join(targetPrefix, childPath) });
26
+ }
27
+ }
28
+ return files;
29
+ }
30
+
31
+ async function readManagedFiles(path) {
32
+ try {
33
+ const manifest = JSON.parse(await readFile(path, 'utf8'));
34
+ if (manifest === null || typeof manifest !== 'object' || manifest.version !== 1 || manifest.files === null || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) return {};
35
+ return manifest.files;
36
+ } catch (error) {
37
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) return {};
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ async function replaceFile(path, content) {
43
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
44
+ try {
45
+ await writeFile(temporary, content, { flag: 'wx', mode: 0o644 });
46
+ await rename(temporary, path);
47
+ } finally {
48
+ await rm(temporary, { force: true });
49
+ }
50
+ }
51
+
52
+ async function collectSourceFiles(root) {
53
+ const sourceRoot = join(root, '.github');
54
+ const files = [
55
+ ...await collectFiles(join(sourceRoot, 'agents'), 'agents'),
56
+ ...await collectFiles(join(sourceRoot, 'skills'), 'skills')
57
+ ];
58
+ if (!files.some(file => file.target === join('agents', 'abap-developer.agent.md'))) {
59
+ throw new Error('Packaged ABAP Developer agent profile is missing');
60
+ }
61
+ if (!files.some(file => file.target.startsWith(`skills${sep}`) && file.target.endsWith('SKILL.md'))) {
62
+ throw new Error('Packaged Agent Skills are missing');
63
+ }
64
+ return files;
65
+ }
66
+
67
+ async function planDestinations(harness, files) {
68
+ const destinations = [];
69
+ for (const file of files) {
70
+ const raw = await readFile(file.source);
71
+ if (!file.target.startsWith(`agents${sep}`)) {
72
+ destinations.push({ target: file.target, content: raw });
73
+ continue;
74
+ }
75
+ if (!harness.supportsAgents) continue;
76
+ const fileName = harness.agentFileName ? harness.agentFileName(basename(file.target)) : basename(file.target);
77
+ const content = harness.agentDocument ? Buffer.from(harness.agentDocument(raw.toString('utf8')), 'utf8') : raw;
78
+ destinations.push({ target: join('agents', fileName), content });
79
+ }
80
+ return destinations;
81
+ }
82
+
83
+ export async function installUserAssets({ harness, home = process.env.HOME || homedir(), root = packageRoot, env = process.env } = {}) {
84
+ const destinations = await planDestinations(harness, await collectSourceFiles(root));
85
+ const targetRoot = harnessRoot(harness, { home, env });
86
+ const manifestPath = join(targetRoot, MANIFEST_NAME);
87
+ const legacyManifestPath = join(targetRoot, LEGACY_MANIFEST_NAME);
88
+ await mkdir(targetRoot, { recursive: true, mode: 0o755 });
89
+ const legacyFiles = harness.legacyManifest ? await readManagedFiles(legacyManifestPath) : {};
90
+ const currentFiles = await readManagedFiles(manifestPath);
91
+ const previousFiles = { ...legacyFiles, ...currentFiles };
92
+ const managedFiles = { ...previousFiles };
93
+ const conflicts = [];
94
+ let installed = 0;
95
+ let updated = 0;
96
+ let unchanged = 0;
97
+
98
+ for (const file of destinations) {
99
+ const destination = join(targetRoot, file.target);
100
+ const contentHash = digest(file.content);
101
+ await mkdir(dirname(destination), { recursive: true, mode: 0o755 });
102
+
103
+ let existing;
104
+ try {
105
+ existing = await readFile(destination);
106
+ } catch (error) {
107
+ if (error.code !== 'ENOENT') throw error;
108
+ try {
109
+ await writeFile(destination, file.content, { flag: 'wx', mode: 0o644 });
110
+ managedFiles[file.target] = contentHash;
111
+ installed += 1;
112
+ continue;
113
+ } catch (writeError) {
114
+ if (writeError.code !== 'EEXIST') throw writeError;
115
+ existing = await readFile(destination);
116
+ }
117
+ }
118
+
119
+ const existingHash = digest(existing);
120
+ if (existingHash === contentHash) {
121
+ managedFiles[file.target] = contentHash;
122
+ unchanged += 1;
123
+ } else if (previousFiles[file.target] === existingHash) {
124
+ await replaceFile(destination, file.content);
125
+ managedFiles[file.target] = contentHash;
126
+ updated += 1;
127
+ } else {
128
+ conflicts.push(file.target);
129
+ }
130
+ }
131
+
132
+ await replaceFile(manifestPath, `${JSON.stringify({ version: 1, files: managedFiles }, null, 2)}\n`);
133
+ if (harness.legacyManifest) await rm(legacyManifestPath, { force: true });
134
+ return { harness: harness.id, label: harness.label, root: targetRoot, installed, updated, unchanged, conflicts };
135
+ }
136
+
137
+ export async function installUserAssetsForHarnesses(ids, { home, root = packageRoot, env = process.env } = {}) {
138
+ const results = [];
139
+ const failures = [];
140
+ for (const id of ids) {
141
+ try {
142
+ results.push(await installUserAssets({ harness: harnessById(id), home, root, env }));
143
+ } catch (error) {
144
+ const entry = HARNESSES.find(harness => harness.id === id);
145
+ failures.push({ id, label: entry ? entry.label : id, error });
146
+ }
147
+ }
148
+ return { results, failures };
149
+ }
package/src/launcher.mjs CHANGED
@@ -89,16 +89,6 @@ async function runDoctor(destinations) {
89
89
  const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
90
90
  if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
91
91
  checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
92
- const relayedDestination = proxy.children[0]?.destination;
93
- if (relayedDestination?.relay) {
94
- checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); runtime session preflight pending`));
95
- } else if (destination.source === 'cloud-foundry') {
96
- checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Cloud Foundry destinations connect through the connectivity proxy directly.'));
97
- } else if (String(runtimeEnv.SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY || '').toLowerCase() === 'true') {
98
- checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Disabled through SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY.'));
99
- } else {
100
- checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
101
- }
102
92
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
103
93
  if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
104
94
  const tools = listed.result?.tools || [];
@@ -114,32 +104,9 @@ async function runDoctor(destinations) {
114
104
  if (!systemInfo) {
115
105
  checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
116
106
  } else {
117
- let sessionReady = true;
118
- if (relayedDestination?.relay?.probeCsrfSession) {
119
- let session;
120
- let preflightError;
121
- try {
122
- session = await relayedDestination.relay.probeCsrfSession();
123
- } catch (error) {
124
- preflightError = redactText(error.message || error).slice(0, 250);
125
- }
126
- sessionReady = session?.sessionUsable === true;
127
- const status = sessionReady ? 'passed' : 'failed';
128
- const stats = relayedDestination.relay.stats || {};
129
- const detail = preflightError
130
- ? `preflight request failed: ${preflightError}`
131
- : `HTTP ${session?.httpStatus || 0}; token=${session?.tokenReceived ? 'received' : 'missing'}; cookies=${session?.cookieCount || 0}; usableSession=${sessionReady}; csrfFetches=${stats.csrfFetches || 0}; csrfRetries=${stats.csrfRetries || 0}; sessionFailures=${stats.csrfSessionFailures || 0}; tunnelFallbacks=${stats.tunnelFallbacks || 0}`;
132
- checks.push(doctorRow(destination.name, 'BAS relay CSRF preflight', status, detail));
133
- if (!sessionReady) {
134
- const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session. Confirm the configured destination authentication and BAS proxy route.';
135
- checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', preflightError ? `${remediation} Relay error: ${preflightError}` : remediation));
136
- }
137
- }
138
- if (sessionReady) {
139
- const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
140
- const failed = inspected?.error || inspected?.result?.isError;
141
- checks.push(doctorRow(destination.name, 'SAP system check', failed ? 'failed' : 'passed', failed ? redactText(inspected?.error?.message || inspected?.result?.content?.[0]?.text || 'GetSystemInfo failed') : 'GetSystemInfo returned successfully'));
142
- }
107
+ const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
108
+ const failed = inspected?.error || inspected?.result?.isError;
109
+ checks.push(doctorRow(destination.name, 'SAP system check', failed ? 'failed' : 'passed', failed ? redactText(inspected?.error?.message || inspected?.result?.content?.[0]?.text || 'GetSystemInfo failed') : 'GetSystemInfo returned successfully'));
143
110
  }
144
111
  } catch (error) {
145
112
  const detail = redactText(error.message || error).slice(0, 300);
package/src/mcp-proxy.mjs CHANGED
@@ -4,7 +4,6 @@ import { sanitizeChildEnv, slugifyDestination } from './bas-discovery.mjs';
4
4
  import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
5
5
  import { createEngineeringTools } from './engineering-tools.mjs';
6
6
  import { brandedEnvValue } from './branding.mjs';
7
- import { createBasDestinationRelay } from './bas-destination-relay.mjs';
8
7
  import { diagnosticText, redactText } from './redact.mjs';
9
8
 
10
9
  const JSONRPC = '2.0';
@@ -167,12 +166,6 @@ function closeDestinationRoute(destination) {
167
166
  return closedRoutes.get(destination);
168
167
  }
169
168
 
170
- function useBasDestinationRelay(destination, env = process.env) {
171
- if (String(brandedEnvValue(env, 'DISABLE_BAS_RELAY') || '').toLowerCase() === 'true') return false;
172
- if (destination.source === 'cloud-foundry') return false;
173
- if (!destination.authentication) return false;
174
- return Boolean(destination.url && String(destination.url).includes('.dest'));
175
- }
176
169
 
177
170
  function useProxyAuthentication(destination) {
178
171
  return destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation';
@@ -190,17 +183,6 @@ function childEnvironment(destination, env) {
190
183
  'SAP_SAML_AUTH', 'SAP_SAML_USER', 'SAP_SAML_PASSWORD', 'SAP_CREDENTIAL_CMD'
191
184
  ]) childEnv[key] = '';
192
185
  }
193
- // The VSP child must reach the local BAS relay directly, never through the
194
- // BAS .dest proxy. Without a loopback exception every child request would
195
- // be double-proxied and the relay's session pairing could never apply.
196
- if (destination.relay) {
197
- const loopback = '127.0.0.1,localhost';
198
- for (const key of ['NO_PROXY', 'no_proxy']) {
199
- const entries = String(childEnv[key] || '').split(',').map(value => value.trim()).filter(Boolean);
200
- for (const host of loopback.split(',')) if (!entries.some(entry => entry.toLowerCase() === host)) entries.push(host);
201
- childEnv[key] = entries.join(',');
202
- }
203
- }
204
186
  return childEnv;
205
187
  }
206
188
 
@@ -245,14 +227,12 @@ class Child {
245
227
  this.process.on('error', error => {
246
228
  if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
247
229
  this.fail(error);
248
- // Relay routes survive child crashes so self-healing restarts can reuse
249
- // them; they are closed by the proxy shutdown path instead.
250
- if (!destination.relay) void closeDestinationRoute(destination);
230
+ void closeDestinationRoute(destination);
251
231
  });
252
232
  this.process.on('exit', (code, signal) => {
253
233
  if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child exited (${code ?? signal})`);
254
234
  this.fail(new Error(`child exited (${code ?? signal})`));
255
- if (!destination.relay) void closeDestinationRoute(destination);
235
+ void closeDestinationRoute(destination);
256
236
  });
257
237
  }
258
238
 
@@ -442,34 +422,10 @@ export class MCPProxy {
442
422
  if (this.started) return this;
443
423
  this.started = true;
444
424
  this.starting = (async () => {
445
- // Destinations start concurrently (relay bind + child spawn); children
446
- // are assembled in destination order, which keeps serverInfo's
447
- // children[0] fallback and merged slug numbering deterministic.
448
- const started = await Promise.all(this.destinations.map(originalDestination => (async () => {
449
- let destination = originalDestination;
450
- let relay;
425
+ // Destinations start concurrently and are assembled in destination order,
426
+ // keeping serverInfo's children[0] fallback and merged slug numbering deterministic.
427
+ const started = await Promise.all(this.destinations.map(destination => (async () => {
451
428
  try {
452
- if (useBasDestinationRelay(originalDestination, this.env)) {
453
- relay = createBasDestinationRelay(originalDestination, { env: this.env, log: message => this.eventSink(message) });
454
- const originalClose = originalDestination.close;
455
- const relayUrl = await relay.ready;
456
- destination = {
457
- ...originalDestination,
458
- url: relayUrl,
459
- relay: {
460
- probeCsrfSession: relay.probeCsrfSession,
461
- stats: relay.stats
462
- },
463
- async close() {
464
- try {
465
- await originalClose?.();
466
- } finally {
467
- await relay.close();
468
- }
469
- }
470
- };
471
- this.eventSink(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
472
- }
473
429
  this.eventSink(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
474
430
  return {
475
431
  destination,
@@ -477,8 +433,6 @@ export class MCPProxy {
477
433
  };
478
434
  } catch (error) {
479
435
  this.eventSink(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`, 'error');
480
- // A relay whose ready promise rejected still owns dispatchers.
481
- if (relay && destination === originalDestination) await relay.close().catch(() => {});
482
436
  void closeDestinationRoute(destination);
483
437
  return null;
484
438
  }
@@ -1,5 +0,0 @@
1
- 78c0f6b7adf151a4c83ee3a757752aa409225fc75af64dfe728aed68dc5cf598 vsp-linux-x64
2
- 4ceaf3ef3519431aa5ebe7d7c14569cf7c7035956dbfc9342c8fa9b6343a5380 vsp-linux-arm64
3
- f8d075f1ef955f8bb4be5b1d9ec3ffc86e84349b8a141265cf39e74c9fd83246 vsp-darwin-x64
4
- 25e19e0d7317fe5adcb3c670b5fcf1da1caafa3c1bf8a6783c4ff00b312b5194 vsp-darwin-arm64
5
- c67c921ae884abeacaa39782c5524f58ec58bdbccc0d7097ee8a2499c04331ac vsp-win32-x64.exe
Binary file
Binary file
Binary file
Binary file
Binary file