sap-ai-dev-toolkit 0.2.7 → 0.3.1
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 +4 -3
- package/.github/agents/abap-runtime-debugger.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/skills/abap-development/SKILL.md +2 -1
- package/.github/skills/rap-service-delivery/SKILL.md +4 -1
- package/.github/skills/sap-transport-release/SKILL.md +2 -2
- package/README.md +82 -34
- package/package.json +1 -1
- package/scripts/ensure-go.mjs +5 -4
- package/scripts/install-user-copilot-assets.mjs +6 -1
- package/scripts/postinstall.mjs +11 -11
- package/src/bas-discovery.mjs +3 -2
- package/src/binary.mjs +27 -17
- package/src/branding.mjs +21 -0
- package/src/cf-destination.mjs +1 -1
- package/src/demo-vsp.mjs +113 -0
- package/src/engineering-tools.mjs +641 -0
- package/src/launcher.mjs +118 -11
- package/src/mcp-config.mjs +19 -10
- package/src/mcp-proxy.mjs +18 -4
- package/src/setup.mjs +5 -6
- package/test/cf-destination.test.mjs +2 -2
- package/test/cf-runtime.test.mjs +12 -12
- package/test/copilot-assets.test.mjs +26 -0
- package/test/discovery.test.mjs +10 -1
- package/test/launcher.test.mjs +46 -9
- package/test/mcp-config-cf.test.mjs +49 -5
- package/test/mcp-proxy.test.mjs +1 -1
- package/test/pty.mjs +8 -0
- package/test/setup-cf.test.mjs +4 -4
- package/test/setup.test.mjs +51 -14
- package/tools.md +12 -1
package/src/launcher.mjs
CHANGED
|
@@ -9,8 +9,10 @@ import { MCPProxy } from './mcp-proxy.mjs';
|
|
|
9
9
|
import { installMcpConfig } from './mcp-config.mjs';
|
|
10
10
|
import { runSetup } from './setup.mjs';
|
|
11
11
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
12
|
+
import { withBrandedEnvironment } from './branding.mjs';
|
|
12
13
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
13
14
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
15
|
+
const runtimeEnv = withBrandedEnvironment(process.env);
|
|
14
16
|
|
|
15
17
|
function hasFlag(name) { return process.argv.slice(2).includes(name); }
|
|
16
18
|
function json(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
|
|
@@ -19,7 +21,7 @@ function usage() {
|
|
|
19
21
|
return [
|
|
20
22
|
'Usage: sap-ai-dev [options]',
|
|
21
23
|
'',
|
|
22
|
-
'With H2O_URL set and no command, starts the
|
|
24
|
+
'With H2O_URL set and no command, starts the SAP AI Dev Toolkit stdio server for selected BAS destinations.',
|
|
23
25
|
'Options:',
|
|
24
26
|
' --setup Configure generated BAS MCP servers',
|
|
25
27
|
' --tools Also offer full-stack SAP companion MCP servers (use with --setup)',
|
|
@@ -27,6 +29,9 @@ function usage() {
|
|
|
27
29
|
' --list-destinations List discovered BAS destinations',
|
|
28
30
|
' --list-destinations --json Print redacted JSON status',
|
|
29
31
|
' --check Probe destination availability',
|
|
32
|
+
' --doctor Check destination, VSP, SAP, and MCP connectivity',
|
|
33
|
+
' --doctor --json Print redacted JSON diagnostics',
|
|
34
|
+
' --demo Start an isolated, offline SAP MCP playground',
|
|
30
35
|
' --help, -h Show this help and exit',
|
|
31
36
|
'',
|
|
32
37
|
'Use an MCP client to call server tools; the BAS runtime uses stdio for MCP.',
|
|
@@ -34,6 +39,86 @@ function usage() {
|
|
|
34
39
|
].join('\n');
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
function doctorRow(name, stage, status, detail) {
|
|
43
|
+
return { name, stage, status, ...(detail ? { detail } : {}) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function redactText(value) {
|
|
47
|
+
return String(value).replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function runDoctor(destinations) {
|
|
51
|
+
const checks = [];
|
|
52
|
+
if (!destinations.length) {
|
|
53
|
+
return { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', 'No destinations were found. Check H2O_URL or the configured Cloud Foundry destination.')] };
|
|
54
|
+
}
|
|
55
|
+
let binary;
|
|
56
|
+
let binaryError;
|
|
57
|
+
try { binary = await binaryOrError(); }
|
|
58
|
+
catch (error) { binaryError = error.message; }
|
|
59
|
+
|
|
60
|
+
for (const destination of destinations) {
|
|
61
|
+
const probe = destination.probe;
|
|
62
|
+
const probeStatus = !probe || probe.status === 'skipped' ? 'skipped' : (probe.available === true ? 'passed' : 'failed');
|
|
63
|
+
checks.push(doctorRow(destination.name, 'ADT probe', probeStatus, probe?.status || 'No BAS ADT probe was supplied; GetSystemInfo will check the route.'));
|
|
64
|
+
checks.push(doctorRow(destination.name, 'VSP binary', binary ? 'passed' : 'failed', binary ? 'installed' : binaryError));
|
|
65
|
+
if (!binary) {
|
|
66
|
+
checks.push(doctorRow(destination.name, 'VSP MCP startup', 'skipped', 'The VSP binary is unavailable.'));
|
|
67
|
+
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'VSP MCP startup could not run.'));
|
|
68
|
+
checks.push(doctorRow(destination.name, 'MCP tools/list', 'skipped', 'VSP MCP startup could not run.'));
|
|
69
|
+
try { await destination.close?.(); } catch {}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: message => console.error(message) });
|
|
73
|
+
try {
|
|
74
|
+
proxy.start();
|
|
75
|
+
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
76
|
+
if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
|
|
77
|
+
checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
|
|
78
|
+
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
79
|
+
if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
|
|
80
|
+
const tools = listed.result?.tools || [];
|
|
81
|
+
const localNames = new Set(['LintABAP', 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration', 'GenerateRAPRegressionSuite', 'RunRAPRegressionSuite']);
|
|
82
|
+
const upstreamCount = tools.filter(tool => !localNames.has(tool.name.slice(tool.name.indexOf('__') + 2))).length;
|
|
83
|
+
checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} VSP tools available`));
|
|
84
|
+
const systemInfo = tools.find(tool => tool.name.endsWith('__GetSystemInfo'));
|
|
85
|
+
if (!systemInfo) {
|
|
86
|
+
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
|
|
87
|
+
} else {
|
|
88
|
+
const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
|
|
89
|
+
const failed = inspected?.error || inspected?.result?.isError;
|
|
90
|
+
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'));
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const detail = redactText(error.message || error).slice(0, 300);
|
|
94
|
+
const initialized = checks.some(row => row.name === destination.name && row.stage === 'VSP MCP startup' && row.status === 'passed');
|
|
95
|
+
if (!initialized) checks.push(doctorRow(destination.name, 'VSP MCP startup', 'failed', detail));
|
|
96
|
+
else checks.push(doctorRow(destination.name, 'MCP tools/list', 'failed', detail));
|
|
97
|
+
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'An earlier diagnostic step failed.'));
|
|
98
|
+
} finally {
|
|
99
|
+
await proxy.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const required = checks.filter(check => check.status !== 'skipped');
|
|
103
|
+
return { ok: required.every(check => check.status === 'passed'), destinations: destinations.length, checks };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function runOfflineDemo() {
|
|
107
|
+
const demoPath = join(root, 'src', 'demo-vsp.mjs');
|
|
108
|
+
const proxy = new MCPProxy({
|
|
109
|
+
binary: process.execPath,
|
|
110
|
+
childArgs: () => [demoPath],
|
|
111
|
+
destinations: [{ name: 'demo', url: 'http://demo.dest', client: '001', demo: true }],
|
|
112
|
+
env: { ...runtimeEnv, SAP_AI_DEV_TOOLKIT_DESTINATION: 'demo' },
|
|
113
|
+
log: message => console.error(message)
|
|
114
|
+
});
|
|
115
|
+
console.error('[sap-ai-dev-toolkit] offline demo active; all SAP changes are simulated in memory');
|
|
116
|
+
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
117
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
118
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
119
|
+
await proxy.serve(process.stdin);
|
|
120
|
+
}
|
|
121
|
+
|
|
37
122
|
function probeDiagnostic(destination) {
|
|
38
123
|
const probe = destination.probe || {};
|
|
39
124
|
const details = [];
|
|
@@ -49,14 +134,14 @@ function probeDiagnostic(destination) {
|
|
|
49
134
|
}
|
|
50
135
|
|
|
51
136
|
async function binaryOrError() {
|
|
52
|
-
const path = await findBinary(pkg);
|
|
137
|
+
const path = await findBinary(pkg, { env: runtimeEnv });
|
|
53
138
|
if (path) return path;
|
|
54
|
-
throw new Error('VSP binary is not installed. Reinstall with scripts enabled, set
|
|
139
|
+
throw new Error('VSP binary is not installed. Reinstall with scripts enabled, set SAP_AI_DEV_TOOLKIT_BINARY to a trusted patched binary, or run npm run build:vsp.');
|
|
55
140
|
}
|
|
56
141
|
|
|
57
142
|
async function discoverForCommand() {
|
|
58
|
-
const env = { ...
|
|
59
|
-
if (env.
|
|
143
|
+
const env = { ...runtimeEnv };
|
|
144
|
+
if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
|
|
60
145
|
return discoverDestinations({ env });
|
|
61
146
|
}
|
|
62
147
|
|
|
@@ -64,10 +149,32 @@ async function main() {
|
|
|
64
149
|
const setup = hasFlag('--setup');
|
|
65
150
|
const check = hasFlag('--check');
|
|
66
151
|
const list = hasFlag('--list-destinations');
|
|
152
|
+
const doctor = hasFlag('--doctor');
|
|
153
|
+
const demo = hasFlag('--demo');
|
|
67
154
|
if (hasFlag('--help') || hasFlag('-h')) {
|
|
68
155
|
process.stdout.write(usage());
|
|
69
156
|
return;
|
|
70
157
|
}
|
|
158
|
+
if (doctor && demo) throw new Error('Use either --doctor or --demo, not both.');
|
|
159
|
+
if (demo) {
|
|
160
|
+
await runOfflineDemo();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (doctor) {
|
|
164
|
+
let report;
|
|
165
|
+
try {
|
|
166
|
+
const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
|
|
167
|
+
? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
|
|
168
|
+
: await discoverForCommand();
|
|
169
|
+
report = await runDoctor(destinations);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
|
|
172
|
+
}
|
|
173
|
+
if (hasFlag('--json')) json(report);
|
|
174
|
+
else for (const row of report.checks) console.log(`[${row.status.toUpperCase()}] ${row.name} — ${row.stage}: ${row.detail || ''}`);
|
|
175
|
+
if (!report.ok) process.exitCode = 1;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
71
178
|
if (setup) {
|
|
72
179
|
const setupOptions = {
|
|
73
180
|
includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools')
|
|
@@ -93,12 +200,12 @@ async function main() {
|
|
|
93
200
|
return;
|
|
94
201
|
}
|
|
95
202
|
|
|
96
|
-
if (
|
|
97
|
-
const destination = await resolveConfiguredCloudFoundryDestination({ env:
|
|
203
|
+
if (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') {
|
|
204
|
+
const destination = await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv });
|
|
98
205
|
let proxy;
|
|
99
206
|
try {
|
|
100
207
|
const binary = await binaryOrError();
|
|
101
|
-
proxy = new MCPProxy({ binary, destinations: [destination], env:
|
|
208
|
+
proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: message => console.error(message) });
|
|
102
209
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
103
210
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
104
211
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
@@ -111,9 +218,9 @@ async function main() {
|
|
|
111
218
|
return;
|
|
112
219
|
}
|
|
113
220
|
|
|
114
|
-
if (!
|
|
221
|
+
if (!runtimeEnv.H2O_URL) {
|
|
115
222
|
const binary = await binaryOrError();
|
|
116
|
-
const child = spawn(binary, process.argv.slice(2), { env:
|
|
223
|
+
const child = spawn(binary, process.argv.slice(2), { env: runtimeEnv, stdio: 'inherit' });
|
|
117
224
|
process.once('SIGINT', () => child.kill('SIGINT'));
|
|
118
225
|
process.once('SIGTERM', () => child.kill('SIGTERM'));
|
|
119
226
|
const [code, signal] = await new Promise(resolve => child.once('exit', (exitCode, exitSignal) => resolve([exitCode, exitSignal])));
|
|
@@ -122,7 +229,7 @@ async function main() {
|
|
|
122
229
|
return;
|
|
123
230
|
}
|
|
124
231
|
|
|
125
|
-
const discovered = await discoverDestinations({ env:
|
|
232
|
+
const discovered = await discoverDestinations({ env: runtimeEnv });
|
|
126
233
|
for (const destination of discovered) console.error(probeDiagnostic(destination));
|
|
127
234
|
const destinations = discovered;
|
|
128
235
|
if (!destinations.length) {
|
package/src/mcp-config.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
import { brandedEnvValue } from './branding.mjs';
|
|
5
6
|
|
|
6
7
|
const LEGACY_MCP_SERVER_PREFIXES = ['sapAiDev_', 'basVspMcp_'];
|
|
7
8
|
const COMPANION_SERVER_KIND = 'sap-development-companion';
|
|
@@ -52,8 +53,8 @@ async function exists(path) {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export async function resolveMcpConfigPath(env = process.env) {
|
|
55
|
-
|
|
56
|
-
if (
|
|
56
|
+
const configuredPath = brandedEnvValue(env, 'MCP_CONFIG');
|
|
57
|
+
if (configuredPath) return configuredPath;
|
|
57
58
|
const home = env.HOME || homedir();
|
|
58
59
|
const candidates = [
|
|
59
60
|
join(home, '.vscode', 'data', 'User', 'mcp.json'),
|
|
@@ -91,7 +92,7 @@ export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SE
|
|
|
91
92
|
|
|
92
93
|
export function buildMcpEntries(destinations, env = process.env) {
|
|
93
94
|
const h2oUrl = env.H2O_URL;
|
|
94
|
-
if (!h2oUrl) throw new Error('H2O_URL is required to write
|
|
95
|
+
if (!h2oUrl) throw new Error('H2O_URL is required to write SAP AI Dev Toolkit configuration');
|
|
95
96
|
const entries = Object.create(null);
|
|
96
97
|
const selected = [...destinations].sort((a, b) => String(a.serverName || a.name).localeCompare(String(b.serverName || b.name)));
|
|
97
98
|
for (const destination of selected) {
|
|
@@ -106,8 +107,8 @@ export function buildMcpEntries(destinations, env = process.env) {
|
|
|
106
107
|
const cf = destination.cf;
|
|
107
108
|
if (!cf) throw new Error(`Cloud Foundry destination "${destination.name}" is missing service references`);
|
|
108
109
|
Object.assign(entryEnv, {
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
111
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION: String(destination.name),
|
|
111
112
|
BAS_CF_SPACE_GUID: String(cf.spaceGuid),
|
|
112
113
|
BAS_CF_DESTINATION_INSTANCE_GUID: String(cf.destinationInstanceGuid),
|
|
113
114
|
BAS_CF_DESTINATION_INSTANCE: String(cf.destinationInstanceName),
|
|
@@ -122,7 +123,7 @@ export function buildMcpEntries(destinations, env = process.env) {
|
|
|
122
123
|
});
|
|
123
124
|
}
|
|
124
125
|
} else {
|
|
125
|
-
entryEnv.
|
|
126
|
+
entryEnv.SAP_AI_DEV_TOOLKIT_DESTINATION = String(destination.name);
|
|
126
127
|
}
|
|
127
128
|
entries[name] = {
|
|
128
129
|
type: 'stdio',
|
|
@@ -139,7 +140,7 @@ function isCompanionServer(entry) {
|
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
function isPackageLauncher(entry) {
|
|
142
|
-
const commands = new Set(['sap-ai-dev', 'bas-vsp-mcp']);
|
|
143
|
+
const commands = new Set(['sap-ai-dev', 'sap-ai-dev-toolkit', 'bas-vsp-mcp']);
|
|
143
144
|
const packages = [/^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/, /^--package=bas-mcp-addon(?:@[^/]+)?$/];
|
|
144
145
|
const npxLauncher = entry?.command === 'npx'
|
|
145
146
|
&& Array.isArray(entry.args)
|
|
@@ -148,11 +149,19 @@ function isPackageLauncher(entry) {
|
|
|
148
149
|
return entry?.BAS_EXT === 'true' && (commands.has(entry?.command) || npxLauncher);
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
function destinationValue(env) {
|
|
153
|
+
return env?.SAP_AI_DEV_TOOLKIT_DESTINATION ?? env?.BAS_VSP_DESTINATION;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function destinationSource(env) {
|
|
157
|
+
return env?.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE ?? env?.BAS_VSP_DESTINATION_SOURCE;
|
|
158
|
+
}
|
|
159
|
+
|
|
151
160
|
function collectCloudFoundryKeyReferences(config, managedOnly) {
|
|
152
161
|
const refs = new Map();
|
|
153
162
|
for (const entry of Object.values(config?.servers || {})) {
|
|
154
163
|
const e = entry?.env;
|
|
155
|
-
if (!e || (managedOnly && (e
|
|
164
|
+
if (!e || (managedOnly && (destinationSource(e) !== 'cloud-foundry' || !isPackageLauncher(entry)))) continue;
|
|
156
165
|
const candidates = [
|
|
157
166
|
{ kind: 'destination', spaceGuid: e.BAS_CF_SPACE_GUID, instanceGuid: e.BAS_CF_DESTINATION_INSTANCE_GUID, instanceName: e.BAS_CF_DESTINATION_INSTANCE, keyName: e.BAS_CF_DESTINATION_KEY },
|
|
158
167
|
{ kind: 'connectivity', spaceGuid: e.BAS_CF_SPACE_GUID, instanceGuid: e.BAS_CF_CONNECTIVITY_INSTANCE_GUID, instanceName: e.BAS_CF_CONNECTIVITY_INSTANCE, keyName: e.BAS_CF_CONNECTIVITY_KEY }
|
|
@@ -232,8 +241,8 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
232
241
|
const servers = Object.create(null);
|
|
233
242
|
for (const [name, entry] of Object.entries(config.servers)) {
|
|
234
243
|
const managed = isPackageLauncher(entry)
|
|
235
|
-
&& typeof entry?.env
|
|
236
|
-
&& (entry.env
|
|
244
|
+
&& typeof destinationValue(entry?.env) === 'string'
|
|
245
|
+
&& (destinationSource(entry.env) === 'cloud-foundry'
|
|
237
246
|
? typeof entry.env.BAS_CF_DESTINATION_KEY === 'string'
|
|
238
247
|
: entry.env.BAS_VSP_DESTINATION_SOURCE === undefined);
|
|
239
248
|
if (!LEGACY_MCP_SERVER_PREFIXES.some(prefix => name.startsWith(prefix)) && !managed && !isCompanionServer(entry)) servers[name] = entry;
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { spawn as nodeSpawn } from 'node:child_process';
|
|
|
2
2
|
import { once } from 'node:events';
|
|
3
3
|
import { sanitizeChildEnv, slugifyDestination } from './bas-discovery.mjs';
|
|
4
4
|
import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
|
|
5
|
+
import { createEngineeringTools } from './engineering-tools.mjs';
|
|
6
|
+
import { brandedEnvValue } from './branding.mjs';
|
|
5
7
|
|
|
6
8
|
const JSONRPC = '2.0';
|
|
7
9
|
const FORWARDED_METHODS = new Set(['ping', 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'completion/complete', 'logging/setLevel']);
|
|
@@ -64,7 +66,7 @@ function childEnvironment(destination, env) {
|
|
|
64
66
|
export function childArguments(destination, env = process.env) {
|
|
65
67
|
// The proxy allowlist keeps transport mutations to CreateTransport.
|
|
66
68
|
// Transportable source edits are controlled by SAP_ALLOW_TRANSPORTABLE_EDITS.
|
|
67
|
-
const mode = env
|
|
69
|
+
const mode = brandedEnvValue(env, 'MODE') || 'expert';
|
|
68
70
|
const args = ['--url', destination.url, '--client', destination.client || '001', '--mode', mode];
|
|
69
71
|
if (destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation') args.push('--proxy-auth');
|
|
70
72
|
args.push('--enable-transports');
|
|
@@ -186,11 +188,12 @@ class Child {
|
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
export class MCPProxy {
|
|
189
|
-
constructor({ binary, destinations, env = process.env, spawn = nodeSpawn, log = message => console.error(message), output = line => process.stdout.write(`${line}\n`) }) {
|
|
191
|
+
constructor({ binary, destinations, env = process.env, spawn = nodeSpawn, childArgs, log = message => console.error(message), output = line => process.stdout.write(`${line}\n`) }) {
|
|
190
192
|
this.binary = binary;
|
|
191
193
|
this.destinations = destinations;
|
|
192
194
|
this.env = env;
|
|
193
195
|
this.spawn = spawn;
|
|
196
|
+
this.childArgs = childArgs;
|
|
194
197
|
this.log = log;
|
|
195
198
|
this.output = output;
|
|
196
199
|
this.children = [];
|
|
@@ -214,6 +217,7 @@ export class MCPProxy {
|
|
|
214
217
|
child: new Child(this.binary, destination, {
|
|
215
218
|
env: this.env,
|
|
216
219
|
spawn: this.spawn,
|
|
220
|
+
args: this.childArgs?.(destination),
|
|
217
221
|
log: this.log,
|
|
218
222
|
onNotification: notification => {
|
|
219
223
|
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
@@ -264,7 +268,8 @@ export class MCPProxy {
|
|
|
264
268
|
this.namespace.set(lintName, { handler: runABAPLint });
|
|
265
269
|
merged.push({ ...ABAP_LINT_TOOL, name: lintName });
|
|
266
270
|
try {
|
|
267
|
-
|
|
271
|
+
const upstreamTools = await entry.child.listTools();
|
|
272
|
+
for (const tool of upstreamTools) {
|
|
268
273
|
if (tool.name === 'SAP') {
|
|
269
274
|
const applicationLogName = `${slug}__GetApplicationLog`;
|
|
270
275
|
this.namespace.set(applicationLogName, {
|
|
@@ -284,6 +289,15 @@ export class MCPProxy {
|
|
|
284
289
|
this.namespace.set(name, { entry, upstream: tool.name });
|
|
285
290
|
merged.push({ ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
|
|
286
291
|
}
|
|
292
|
+
for (const localTool of createEngineeringTools(entry, upstreamTools, { env: this.env, log: this.log })) {
|
|
293
|
+
const name = `${slug}__${localTool.definition.name}`;
|
|
294
|
+
this.namespace.set(name, { handler: localTool.handler });
|
|
295
|
+
merged.push({
|
|
296
|
+
...localTool.definition,
|
|
297
|
+
name,
|
|
298
|
+
description: `${localTool.definition.description} [destination: ${entry.destination.name}]`
|
|
299
|
+
});
|
|
300
|
+
}
|
|
287
301
|
} catch (error) {
|
|
288
302
|
this.log(`[${entry.destination.name}] tools/list failed: ${redactText(error.message)}`);
|
|
289
303
|
}
|
|
@@ -301,7 +315,7 @@ export class MCPProxy {
|
|
|
301
315
|
try {
|
|
302
316
|
if (message.method === 'initialize') {
|
|
303
317
|
await this.initializeChildren(message.params || {});
|
|
304
|
-
return rpcResult(message.id, { protocolVersion: message.params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: this.env
|
|
318
|
+
return rpcResult(message.id, { protocolVersion: message.params?.protocolVersion || '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: brandedEnvValue(this.env, 'DESTINATION') || this.children[0].destination.name, version: '0.1.0' } });
|
|
305
319
|
}
|
|
306
320
|
if (!this.initialized) return rpcError(message.id, -32002, 'MCP proxy is not initialized');
|
|
307
321
|
if (message.method === 'tools/list') return rpcResult(message.id, { tools: await this.mergedTools() });
|
package/src/setup.mjs
CHANGED
|
@@ -72,7 +72,7 @@ function printConnectionInstructions(output, result) {
|
|
|
72
72
|
const servers = Object.entries(result?.servers || {});
|
|
73
73
|
if (!servers.length) {
|
|
74
74
|
print(output, '');
|
|
75
|
-
print(output, formatStatus('No destinations were selected; no MCP servers were added.', 'info', output, '
|
|
75
|
+
print(output, formatStatus('No destinations were selected; no MCP servers were added.', 'info', output, 'SAP AI Dev Toolkit'));
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
78
|
print(output, '');
|
|
@@ -99,11 +99,11 @@ export async function runSetup({
|
|
|
99
99
|
includeSapDevelopmentToolsPrompt = false
|
|
100
100
|
} = {}) {
|
|
101
101
|
if (!env.H2O_URL) {
|
|
102
|
-
print(output, formatStatus(`Setup skipped: H2O_URL is not set. Run ${SETUP_COMMAND} in a BAS dev space.`, 'info', output, '
|
|
102
|
+
print(output, formatStatus(`Setup skipped: H2O_URL is not set. Run ${SETUP_COMMAND} in a BAS dev space.`, 'info', output, 'SAP AI Dev Toolkit'));
|
|
103
103
|
return { skipped: true, reason: 'non-bas' };
|
|
104
104
|
}
|
|
105
105
|
if (!input.isTTY || !output.isTTY) {
|
|
106
|
-
print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a BAS terminal.`, 'warning', output, '
|
|
106
|
+
print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a BAS terminal.`, 'warning', output, 'SAP AI Dev Toolkit'));
|
|
107
107
|
return { skipped: true, reason: 'non-tty' };
|
|
108
108
|
}
|
|
109
109
|
print(output, '');
|
|
@@ -111,7 +111,7 @@ export async function runSetup({
|
|
|
111
111
|
|
|
112
112
|
let basDestinations;
|
|
113
113
|
try {
|
|
114
|
-
basDestinations = await discover({ env: { ...env,
|
|
114
|
+
basDestinations = await discover({ env: { ...env, SAP_AI_DEV_TOOLKIT_DESTINATION: '' } });
|
|
115
115
|
} catch (error) {
|
|
116
116
|
throw new Error(`BAS discovery failed: ${error.message}`);
|
|
117
117
|
}
|
|
@@ -146,7 +146,7 @@ export async function runSetup({
|
|
|
146
146
|
destinations.push(...cfDestinations.map(safeCloudFoundryDestination));
|
|
147
147
|
warnings.push(...(Array.isArray(result?.warnings) ? result.warnings.filter(value => typeof value === 'string') : []));
|
|
148
148
|
} catch {
|
|
149
|
-
warnings.push('Cloud Foundry import failed;
|
|
149
|
+
warnings.push('Cloud Foundry import failed; SAP AI Dev Toolkit setup can continue.');
|
|
150
150
|
}
|
|
151
151
|
} else if (!warnings.some(message => message.includes('Cloud Foundry import prompt failed'))) {
|
|
152
152
|
warnings.push('Cloud Foundry destinations were skipped because import was not confirmed.');
|
|
@@ -269,4 +269,3 @@ export async function runSetup({
|
|
|
269
269
|
throw new Error(`MCP config writing failed: ${error.message}`);
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
|
-
|
|
@@ -273,8 +273,8 @@ test('resolves a CF Basic destination only after matching the active space', asy
|
|
|
273
273
|
const cf = mockCf({ origin });
|
|
274
274
|
const env = {
|
|
275
275
|
...envForLocal(),
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
277
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION: 'runtime-basic',
|
|
278
278
|
BAS_CF_SPACE_GUID: SPACE_GUID,
|
|
279
279
|
BAS_CF_DESTINATION_INSTANCE_GUID: DESTINATION_GUID,
|
|
280
280
|
BAS_CF_DESTINATION_INSTANCE: DESTINATION_INSTANCE,
|
package/test/cf-runtime.test.mjs
CHANGED
|
@@ -129,9 +129,9 @@ test('launches a CF Basic destination with per-child credentials and no proxy-au
|
|
|
129
129
|
const env = {
|
|
130
130
|
...process.env,
|
|
131
131
|
...cfEnv,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
|
|
133
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
134
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION: 'cf-basic',
|
|
135
135
|
BAS_CF_SPACE_GUID: SPACE_GUID,
|
|
136
136
|
BAS_CF_DESTINATION_INSTANCE_GUID: INSTANCE_GUID,
|
|
137
137
|
BAS_CF_DESTINATION_INSTANCE: INSTANCE_NAME,
|
|
@@ -268,9 +268,9 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
|
|
|
268
268
|
const result = await runLauncher({
|
|
269
269
|
...process.env,
|
|
270
270
|
...cfEnv,
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
271
|
+
SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
|
|
272
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
273
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION: 'pp-target',
|
|
274
274
|
BAS_CF_SPACE_GUID: SPACE_GUID,
|
|
275
275
|
BAS_CF_DESTINATION_INSTANCE_GUID: INSTANCE_GUID,
|
|
276
276
|
BAS_CF_DESTINATION_INSTANCE: INSTANCE_NAME,
|
|
@@ -420,9 +420,9 @@ test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy',
|
|
|
420
420
|
const result = await runLauncher({
|
|
421
421
|
...process.env,
|
|
422
422
|
...cfEnv,
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
423
|
+
SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
|
|
424
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
425
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION: 'basic-onprem',
|
|
426
426
|
BAS_CF_SPACE_GUID: SPACE_GUID,
|
|
427
427
|
BAS_CF_DESTINATION_INSTANCE_GUID: INSTANCE_GUID,
|
|
428
428
|
BAS_CF_DESTINATION_INSTANCE: INSTANCE_NAME,
|
|
@@ -491,13 +491,13 @@ test('CF list-destinations clears the CF name filter and remains BAS-only', asyn
|
|
|
491
491
|
const result = await runLauncher({
|
|
492
492
|
...process.env,
|
|
493
493
|
...cfEnv,
|
|
494
|
-
|
|
495
|
-
|
|
494
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
495
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION: 'cf-name-not-in-bas',
|
|
496
496
|
H2O_URL: origin,
|
|
497
497
|
HTTP_PROXY: '',
|
|
498
498
|
HTTPS_PROXY: '',
|
|
499
499
|
NO_PROXY: '127.0.0.1,localhost',
|
|
500
|
-
|
|
500
|
+
SAP_AI_DEV_TOOLKIT_SKIP_PROBE: 'true'
|
|
501
501
|
}, ['--list-destinations', '--json']);
|
|
502
502
|
assert.equal(result.code, 0, result.stderr);
|
|
503
503
|
assert.deepEqual(JSON.parse(result.stdout), [{ name: 'bas-only', client: '101', authentication: 'Unknown', probe: 'skipped' }]);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
6
7
|
import { installUserCopilotAssets } from '../scripts/install-user-copilot-assets.mjs';
|
|
@@ -35,3 +36,28 @@ test('updates unchanged global assets and preserves user-edited customizations',
|
|
|
35
36
|
assert.equal(await readFile(join(home, '.copilot', 'agents', 'abap-developer.agent.md'), 'utf8'), 'user customization');
|
|
36
37
|
assert.equal(await readFile(join(home, '.copilot', 'skills', 'example', 'SKILL.md'), 'utf8'), 'skill revision two');
|
|
37
38
|
});
|
|
39
|
+
|
|
40
|
+
test('migrates the previous managed-file manifest without losing update ownership', async t => {
|
|
41
|
+
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-assets-migration-'));
|
|
42
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
43
|
+
const root = join(directory, 'package');
|
|
44
|
+
const home = join(directory, 'home');
|
|
45
|
+
const target = join(home, '.copilot', 'agents', 'abap-developer.agent.md');
|
|
46
|
+
const oldManifest = join(home, '.copilot', '.bas-mcp-addon-assets.json');
|
|
47
|
+
await writeAsset(root, 'agents/abap-developer.agent.md', 'managed revision one');
|
|
48
|
+
await writeAsset(root, 'skills/example/SKILL.md', 'skill revision one');
|
|
49
|
+
const first = await installUserCopilotAssets({ home, root });
|
|
50
|
+
assert.equal(first.installed, 2);
|
|
51
|
+
|
|
52
|
+
const managedContent = await readFile(target);
|
|
53
|
+
const managedHash = createHash('sha256').update(managedContent).digest('hex');
|
|
54
|
+
await rm(join(home, '.copilot', '.sap-ai-dev-toolkit-assets.json'));
|
|
55
|
+
await writeFile(oldManifest, JSON.stringify({ version: 1, files: { [join('agents', 'abap-developer.agent.md')]: managedHash } }));
|
|
56
|
+
await writeAsset(root, 'agents/abap-developer.agent.md', 'managed revision two');
|
|
57
|
+
|
|
58
|
+
const migrated = await installUserCopilotAssets({ home, root });
|
|
59
|
+
assert.equal(migrated.updated, 1);
|
|
60
|
+
assert.equal(await readFile(target, 'utf8'), 'managed revision two');
|
|
61
|
+
await assert.rejects(readFile(oldManifest), { code: 'ENOENT' });
|
|
62
|
+
assert.equal(JSON.parse(await readFile(join(home, '.copilot', '.sap-ai-dev-toolkit-assets.json'), 'utf8')).version, 1);
|
|
63
|
+
});
|
package/test/discovery.test.mjs
CHANGED
|
@@ -23,7 +23,7 @@ test('returns every named destination regardless of ADT probe and preserves allo
|
|
|
23
23
|
const probeImpl = async url => ({ status: url.includes('a-system') ? 403 : url.includes('b-system') ? 200 : 404 });
|
|
24
24
|
const allowlisted = await discoverDestinations({
|
|
25
25
|
body,
|
|
26
|
-
env: {
|
|
26
|
+
env: { SAP_AI_DEV_TOOLKIT_DESTINATION: 'a-system,b-system' },
|
|
27
27
|
probeImpl
|
|
28
28
|
});
|
|
29
29
|
assert.deepEqual(allowlisted.map(item => [item.name, item.client, item.probe.status]), [
|
|
@@ -38,6 +38,15 @@ test('returns every named destination regardless of ADT probe and preserves allo
|
|
|
38
38
|
['z-system', 'not-found']
|
|
39
39
|
]);
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
test('accepts the previous destination environment variable during upgrades', async () => {
|
|
43
|
+
const destinations = await discoverDestinations({
|
|
44
|
+
body: [{ Name: 'legacy-system' }, { Name: 'other-system' }],
|
|
45
|
+
env: { BAS_VSP_DESTINATION: 'legacy-system' },
|
|
46
|
+
skipProbe: true
|
|
47
|
+
});
|
|
48
|
+
assert.deepEqual(destinations.map(destination => destination.name), ['legacy-system']);
|
|
49
|
+
});
|
|
41
50
|
test('parses sample-style destination records and enables every ADT heartbeat response', async () => {
|
|
42
51
|
const body = [
|
|
43
52
|
{
|