sap-ai-dev-toolkit 0.5.0 → 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 +17 -11
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +17 -18
- package/src/bas-destination-relay.mjs +58 -22
- package/src/bas-discovery.mjs +24 -8
- 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 +22 -19
- package/src/mcp-config.mjs +41 -7
- package/src/mcp-proxy.mjs +392 -104
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +33 -3
- package/tools.md +4 -2
- package/test/bas-destination-relay.test.mjs +0 -374
- 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 -257
- package/test/fixtures/fake-vsp.mjs +0 -263
- 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 -399
- package/test/live-s4h.test.mjs +0 -202
- package/test/mcp-config-cf.test.mjs +0 -407
- package/test/mcp-proxy.test.mjs +0 -409
- 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/test/live-s4h.test.mjs
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
import { once } from 'node:events';
|
|
5
|
-
import { readFile } from 'node:fs/promises';
|
|
6
|
-
import { homedir } from 'node:os';
|
|
7
|
-
import { join } from 'node:path';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
9
|
-
|
|
10
|
-
const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
|
|
11
|
-
const LIVE = /^(1|true|yes)$/iu.test(process.env.SAP_AI_DEV_LIVE_S4H || '');
|
|
12
|
-
const DESTINATION = process.env.SAP_AI_DEV_LIVE_DESTINATION || 'S4H';
|
|
13
|
-
const SLUG = process.env.SAP_AI_DEV_LIVE_DESTINATION_SLUG || DESTINATION.toLowerCase().replace(/[^a-z0-9_-]+/gu, '-');
|
|
14
|
-
const REQUEST_TIMEOUT_MS = Number(process.env.SAP_AI_DEV_LIVE_TIMEOUT_MS || 45_000);
|
|
15
|
-
|
|
16
|
-
async function readConfiguredServer() {
|
|
17
|
-
if (/^(0|false|no)$/iu.test(process.env.SAP_AI_DEV_LIVE_USE_MCP_CONFIG || '')) return null;
|
|
18
|
-
const candidates = [
|
|
19
|
-
process.env.SAP_AI_DEV_MCP_CONFIG,
|
|
20
|
-
process.env.BAS_VSP_MCP_CONFIG,
|
|
21
|
-
join(homedir(), '.vscode', 'data', 'User', 'mcp.json'),
|
|
22
|
-
join(homedir(), '.vscode-server', 'data', 'User', 'mcp.json'),
|
|
23
|
-
join(homedir(), '.code-server', 'data', 'User', 'mcp.json')
|
|
24
|
-
].filter(Boolean);
|
|
25
|
-
for (const path of candidates) {
|
|
26
|
-
let parsed;
|
|
27
|
-
try {
|
|
28
|
-
parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
29
|
-
} catch {
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
const server = parsed?.servers?.[DESTINATION];
|
|
33
|
-
if (server?.command) return { path, server };
|
|
34
|
-
}
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
class StdioMcpClient {
|
|
39
|
-
constructor(command, args, env) {
|
|
40
|
-
this.child = spawn(command, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
41
|
-
this.nextId = 1;
|
|
42
|
-
this.pending = new Map();
|
|
43
|
-
this.stdout = '';
|
|
44
|
-
this.stderr = '';
|
|
45
|
-
this.notifications = [];
|
|
46
|
-
this.child.stdout.setEncoding('utf8');
|
|
47
|
-
this.child.stderr.setEncoding('utf8');
|
|
48
|
-
this.child.stdout.on('data', chunk => this.#onStdout(chunk));
|
|
49
|
-
this.child.stderr.on('data', chunk => { this.stderr += chunk; });
|
|
50
|
-
this.child.on('exit', (code, signal) => {
|
|
51
|
-
const error = new Error(`MCP process exited (${code ?? signal})\n${this.stderr.slice(-4000)}`);
|
|
52
|
-
for (const pending of this.pending.values()) pending.reject(error);
|
|
53
|
-
this.pending.clear();
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
#onStdout(chunk) {
|
|
58
|
-
this.stdout += chunk;
|
|
59
|
-
let newline;
|
|
60
|
-
while ((newline = this.stdout.indexOf('\n')) >= 0) {
|
|
61
|
-
const line = this.stdout.slice(0, newline).trim();
|
|
62
|
-
this.stdout = this.stdout.slice(newline + 1);
|
|
63
|
-
if (!line) continue;
|
|
64
|
-
let message;
|
|
65
|
-
try {
|
|
66
|
-
message = JSON.parse(line);
|
|
67
|
-
} catch (error) {
|
|
68
|
-
for (const pending of this.pending.values()) pending.reject(new Error(`Invalid JSON-RPC line: ${line}`));
|
|
69
|
-
this.pending.clear();
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
if (message.id === undefined || message.id === null) {
|
|
73
|
-
this.notifications.push(message);
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
const pending = this.pending.get(message.id);
|
|
77
|
-
if (!pending) continue;
|
|
78
|
-
this.pending.delete(message.id);
|
|
79
|
-
clearTimeout(pending.timeout);
|
|
80
|
-
if (message.error) pending.reject(Object.assign(new Error(message.error.message), { response: message }));
|
|
81
|
-
else pending.resolve(message.result);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
request(method, params) {
|
|
86
|
-
const id = this.nextId++;
|
|
87
|
-
const payload = { jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) };
|
|
88
|
-
return new Promise((resolve, reject) => {
|
|
89
|
-
const timeout = setTimeout(() => {
|
|
90
|
-
this.pending.delete(id);
|
|
91
|
-
reject(new Error(`Timed out waiting for ${method}\n${this.stderr.slice(-4000)}`));
|
|
92
|
-
}, REQUEST_TIMEOUT_MS);
|
|
93
|
-
this.pending.set(id, { resolve, reject, timeout });
|
|
94
|
-
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
notify(method, params) {
|
|
99
|
-
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) })}\n`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async close() {
|
|
103
|
-
this.child.stdin.end();
|
|
104
|
-
this.child.kill('SIGTERM');
|
|
105
|
-
await Promise.race([
|
|
106
|
-
once(this.child, 'exit').catch(() => {}),
|
|
107
|
-
new Promise(resolve => setTimeout(resolve, 1500))
|
|
108
|
-
]);
|
|
109
|
-
if (!this.child.killed) this.child.kill('SIGKILL');
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function textPayload(result) {
|
|
114
|
-
return (result?.content || []).filter(item => item.type === 'text').map(item => item.text).join('\n');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function withLiveClient(t) {
|
|
118
|
-
const configured = await readConfiguredServer();
|
|
119
|
-
const server = configured?.server;
|
|
120
|
-
const command = process.env.SAP_AI_DEV_LIVE_COMMAND || server?.command || process.execPath;
|
|
121
|
-
const args = process.env.SAP_AI_DEV_LIVE_COMMAND
|
|
122
|
-
? (process.env.SAP_AI_DEV_LIVE_ARGS ? JSON.parse(process.env.SAP_AI_DEV_LIVE_ARGS) : [])
|
|
123
|
-
: (server?.command ? (server.args || []) : [launcher]);
|
|
124
|
-
const client = new StdioMcpClient(command, args, {
|
|
125
|
-
...process.env,
|
|
126
|
-
...(server?.env || {}),
|
|
127
|
-
SAP_AI_DEV_TOOLKIT_DESTINATION: server?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION || DESTINATION,
|
|
128
|
-
SAP_AI_DEV_TOOLKIT_MODE: process.env.SAP_AI_DEV_TOOLKIT_MODE || server?.env?.SAP_AI_DEV_TOOLKIT_MODE || 'expert',
|
|
129
|
-
SAP_ALLOW_TRANSPORTABLE_EDITS: process.env.SAP_ALLOW_TRANSPORTABLE_EDITS || server?.env?.SAP_ALLOW_TRANSPORTABLE_EDITS || 'true'
|
|
130
|
-
});
|
|
131
|
-
t.after(() => client.close());
|
|
132
|
-
const initialized = await client.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'sap-ai-dev-live-test', version: '1.0.0' } });
|
|
133
|
-
client.notify('notifications/initialized');
|
|
134
|
-
assert.equal(initialized.serverInfo.name, DESTINATION);
|
|
135
|
-
return client;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
test('live S4H MCP exposes a curated ABAP lifecycle surface under 60 tools', { skip: LIVE ? false : 'set SAP_AI_DEV_LIVE_S4H=1 to run against a live BAS/S4H destination' }, async t => {
|
|
139
|
-
const client = await withLiveClient(t);
|
|
140
|
-
const listed = await client.request('tools/list', {});
|
|
141
|
-
const tools = listed.tools || [];
|
|
142
|
-
const names = new Set(tools.map(tool => tool.name));
|
|
143
|
-
const prefix = `${SLUG}__`;
|
|
144
|
-
|
|
145
|
-
assert.ok(tools.length > 25, `expected a useful ABAP lifecycle surface, got ${tools.length}`);
|
|
146
|
-
assert.ok(tools.length < 60, `curated destination tool count must stay below 60, got ${tools.length}`);
|
|
147
|
-
assert.ok(tools.every(tool => tool.name.startsWith(prefix)), 'every tool is destination-prefixed for the selected S4H server');
|
|
148
|
-
|
|
149
|
-
for (const required of [
|
|
150
|
-
'LintABAP', 'GetSystemInfo', 'GetConnectionInfo', 'GetFeatures',
|
|
151
|
-
'SearchObject', 'GrepObjects', 'GrepPackages', 'GetSource', 'WriteSource', 'EditSource', 'CompareSource',
|
|
152
|
-
'SyntaxCheck', 'PrettyPrint', 'Activate', 'RunUnitTests', 'RunATCCheck', 'GetInactiveObjects',
|
|
153
|
-
'GetClass', 'GetProgram', 'GetPackage', 'GetTable', 'GetTableContents', 'RunQuery',
|
|
154
|
-
'GetTransport', 'GetTransportInfo', 'ListTransports', 'CreateTransport',
|
|
155
|
-
'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration'
|
|
156
|
-
]) {
|
|
157
|
-
assert.ok(names.has(`${prefix}${required}`), `expected curated lifecycle tool ${required}`);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
for (const hidden of [
|
|
161
|
-
'SAP', 'ReleaseTransport', 'DeleteTransport', 'DeleteObject', 'ExecuteABAP', 'CallRFC',
|
|
162
|
-
'ListSQLTraces', 'AnalyzeABAPCode', 'CreateObject', 'CreateClassWithTests', 'WriteClass', 'UpdateSource'
|
|
163
|
-
]) {
|
|
164
|
-
assert.equal(names.has(`${prefix}${hidden}`), false, `broad/destructive tool ${hidden} must remain hidden`);
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test('live S4H MCP handles safe local and read-only SAP calls', { skip: LIVE ? false : 'set SAP_AI_DEV_LIVE_S4H=1 to run against a live BAS/S4H destination' }, async t => {
|
|
169
|
-
const client = await withLiveClient(t);
|
|
170
|
-
const prefix = `${SLUG}__`;
|
|
171
|
-
await client.request('tools/list', {});
|
|
172
|
-
|
|
173
|
-
const lint = await client.request('tools/call', {
|
|
174
|
-
name: `${prefix}LintABAP`,
|
|
175
|
-
arguments: { files: [{ filename: 'zlive_probe.prog.abap', source: "REPORT zlive_probe.\nWRITE / 'ok'.\n" }] }
|
|
176
|
-
});
|
|
177
|
-
const lintJson = JSON.parse(textPayload(lint));
|
|
178
|
-
assert.equal(lintJson.filesChecked, 1);
|
|
179
|
-
|
|
180
|
-
for (const [tool, args] of [
|
|
181
|
-
['GetSystemInfo', {}],
|
|
182
|
-
['GetConnectionInfo', {}],
|
|
183
|
-
['GetFeatures', {}],
|
|
184
|
-
['GetInstalledComponents', {}]
|
|
185
|
-
]) {
|
|
186
|
-
const result = await client.request('tools/call', { name: `${prefix}${tool}`, arguments: args });
|
|
187
|
-
assert.notEqual(result.isError, true, `${tool} returned an MCP tool error: ${textPayload(result)}`);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const query = await client.request('tools/call', {
|
|
191
|
-
name: `${prefix}RunQuery`,
|
|
192
|
-
arguments: { sql_query: 'SELECT * FROM T000', max_rows: 1 }
|
|
193
|
-
});
|
|
194
|
-
assert.notEqual(query.isError, true, `RunQuery returned an MCP tool error: ${textPayload(query)}`);
|
|
195
|
-
assert.match(textPayload(query), /T000|MANDT|client|rows|\[/iu);
|
|
196
|
-
|
|
197
|
-
const appLog = await client.request('tools/call', {
|
|
198
|
-
name: `${prefix}GetApplicationLog`,
|
|
199
|
-
arguments: { max_results: 1 }
|
|
200
|
-
});
|
|
201
|
-
assert.notEqual(appLog.isError, true, `GetApplicationLog returned an MCP tool error: ${textPayload(appLog)}`);
|
|
202
|
-
});
|
|
@@ -1,407 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import { buildMcpEntries, buildSapDevelopmentMcpEntries, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, repairManagedMcpConfig, resolveMcpConfigPath } from '../src/mcp-config.mjs';
|
|
7
|
-
|
|
8
|
-
const bas = {
|
|
9
|
-
source: 'bas', name: 'shared', serverName: 'shared', url: 'http://shared.dest',
|
|
10
|
-
client: '001', authentication: 'NoAuthentication', probe: { status: 'available', available: true }
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
function cfDestination(spaceGuid, destinationInstanceGuid, keyName, proxyType = 'Internet') {
|
|
14
|
-
return {
|
|
15
|
-
source: 'cloud-foundry',
|
|
16
|
-
name: 'shared',
|
|
17
|
-
serverName: `cf:${spaceGuid}:${destinationInstanceGuid}:shared`,
|
|
18
|
-
client: '100',
|
|
19
|
-
authentication: 'BasicAuthentication',
|
|
20
|
-
proxyType,
|
|
21
|
-
probe: { status: 'auth-required', available: true },
|
|
22
|
-
cf: {
|
|
23
|
-
spaceGuid,
|
|
24
|
-
destinationInstanceGuid,
|
|
25
|
-
destinationInstanceName: `destination-${destinationInstanceGuid}`,
|
|
26
|
-
destinationKeyName: keyName,
|
|
27
|
-
...(proxyType === 'OnPremise' ? {
|
|
28
|
-
connectivityInstanceGuid: 'connectivity-guid',
|
|
29
|
-
connectivityInstanceName: 'connectivity-service',
|
|
30
|
-
connectivityKeyName: 'connectivity-key'
|
|
31
|
-
} : {})
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
test('writes source-qualified CF entries beside same-named BAS destinations', () => {
|
|
37
|
-
const first = cfDestination('space-one', 'instance-one', 'key-one');
|
|
38
|
-
const second = cfDestination('space-one', 'instance-two', 'key-two');
|
|
39
|
-
const entries = buildMcpEntries([bas, first, second], { H2O_URL: 'http://h2o.example' });
|
|
40
|
-
assert.deepEqual(Object.keys(entries).sort(), [
|
|
41
|
-
'cf:space-one:instance-one:shared',
|
|
42
|
-
'cf:space-one:instance-two:shared',
|
|
43
|
-
'shared'
|
|
44
|
-
]);
|
|
45
|
-
assert.deepEqual(entries['cf:space-one:instance-one:shared'].env, {
|
|
46
|
-
H2O_URL: 'http://h2o.example',
|
|
47
|
-
SAP_ALLOW_TRANSPORTABLE_EDITS: 'true',
|
|
48
|
-
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
49
|
-
SAP_AI_DEV_TOOLKIT_DESTINATION: 'shared',
|
|
50
|
-
BAS_CF_SPACE_GUID: 'space-one',
|
|
51
|
-
BAS_CF_DESTINATION_INSTANCE_GUID: 'instance-one',
|
|
52
|
-
BAS_CF_DESTINATION_INSTANCE: 'destination-instance-one',
|
|
53
|
-
BAS_CF_DESTINATION_KEY: 'key-one',
|
|
54
|
-
BAS_CF_DESTINATION_NAME: 'shared'
|
|
55
|
-
});
|
|
56
|
-
assert.equal(entries.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
|
|
57
|
-
assert.equal(JSON.stringify(entries).includes('shared.dest'), false);
|
|
58
|
-
assert.equal(JSON.stringify(entries).includes('Password'), false);
|
|
59
|
-
|
|
60
|
-
const onPremise = buildMcpEntries([cfDestination('space-one', 'onprem-instance', 'destination-key', 'OnPremise')], { H2O_URL: 'http://h2o.example' });
|
|
61
|
-
assert.equal(onPremise['cf:space-one:onprem-instance:shared'].env.BAS_CF_CONNECTIVITY_KEY, 'connectivity-key');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('stores and reconciles the MCP launcher executable location', async t => {
|
|
65
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-launcher-location-'));
|
|
66
|
-
const path = join(directory, 'mcp.json');
|
|
67
|
-
const command = join(directory, 'sap-ai-dev');
|
|
68
|
-
await writeFile(command, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
69
|
-
await writeFile(path, JSON.stringify({ servers: { userServer: { command: 'custom-server' } } }));
|
|
70
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
71
|
-
|
|
72
|
-
const env = { H2O_URL: 'http://h2o.example', PATH: directory };
|
|
73
|
-
const installed = await installMcpConfig([bas], { env, path });
|
|
74
|
-
assert.equal(installed.servers.shared.command, command);
|
|
75
|
-
assert.equal((await readMcpConfig(path)).servers.shared.command, command);
|
|
76
|
-
|
|
77
|
-
await installMcpConfig([], { env, path });
|
|
78
|
-
assert.deepEqual(Object.keys((await readMcpConfig(path)).servers), ['userServer']);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('resolves documented, toolkit, and legacy MCP config paths in order', async () => {
|
|
82
|
-
assert.equal(await resolveMcpConfigPath({
|
|
83
|
-
SAP_AI_DEV_MCP_CONFIG: '/tmp/documented-mcp.json',
|
|
84
|
-
SAP_AI_DEV_TOOLKIT_MCP_CONFIG: '/tmp/toolkit-mcp.json',
|
|
85
|
-
BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json'
|
|
86
|
-
}), '/tmp/documented-mcp.json');
|
|
87
|
-
assert.equal(await resolveMcpConfigPath({
|
|
88
|
-
SAP_AI_DEV_TOOLKIT_MCP_CONFIG: '/tmp/toolkit-mcp.json',
|
|
89
|
-
BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json'
|
|
90
|
-
}), '/tmp/toolkit-mcp.json');
|
|
91
|
-
assert.equal(await resolveMcpConfigPath({ BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json' }), '/tmp/legacy-mcp.json');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test('doctor repair updates only discovered toolkit BAS entries and is idempotent', async t => {
|
|
95
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-'));
|
|
96
|
-
const path = join(directory, 'mcp.json');
|
|
97
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
98
|
-
const cf = cfDestination('space-one', 'instance-one', 'cf-key');
|
|
99
|
-
const strictDestination = { ...bas, name: 'strict', serverName: 'strict', url: 'http://strict.dest' };
|
|
100
|
-
const managed = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
|
|
101
|
-
const strictManaged = buildMcpEntries([strictDestination], { H2O_URL: 'http://old-h2o.example' }).strict;
|
|
102
|
-
const cfEntry = buildMcpEntries([cf], { H2O_URL: 'http://cf-h2o.example' })[cf.serverName];
|
|
103
|
-
const companion = buildSapDevelopmentMcpEntries(['sap-fiori-tools'])['sap-fiori-tools'];
|
|
104
|
-
const unrelated = { type: 'stdio', command: 'external-server' };
|
|
105
|
-
const unverifiedManaged = {
|
|
106
|
-
...managed,
|
|
107
|
-
env: { ...managed.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'temporarily-unavailable' }
|
|
108
|
-
};
|
|
109
|
-
const config = {
|
|
110
|
-
userFlag: true,
|
|
111
|
-
servers: {
|
|
112
|
-
shared: { ...managed, env: { ...managed.env, H2O_URL: 'http://old-h2o.example' } },
|
|
113
|
-
strict: { ...strictManaged, env: { ...strictManaged.env, SAP_ALLOW_TRANSPORTABLE_EDITS: 'false' } },
|
|
114
|
-
'temporarily-unavailable': unverifiedManaged,
|
|
115
|
-
[cf.serverName]: cfEntry,
|
|
116
|
-
'sap-fiori-tools': companion,
|
|
117
|
-
ActionS4D_100: unrelated
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
await writeFile(path, JSON.stringify(config, null, 2));
|
|
121
|
-
|
|
122
|
-
const first = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
123
|
-
assert.equal(first.changed, true);
|
|
124
|
-
assert.equal(first.repaired, 2);
|
|
125
|
-
const repaired = await readMcpConfig(path);
|
|
126
|
-
assert.equal(repaired.userFlag, true);
|
|
127
|
-
assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
|
|
128
|
-
assert.equal(repaired.servers.shared.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'true');
|
|
129
|
-
assert.equal(repaired.servers.strict.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'false', "doctor must preserve a user's restricted write setting");
|
|
130
|
-
assert.deepEqual(repaired.servers['temporarily-unavailable'], unverifiedManaged);
|
|
131
|
-
assert.deepEqual(repaired.servers[cf.serverName], cfEntry);
|
|
132
|
-
assert.deepEqual(repaired.servers['sap-fiori-tools'], companion);
|
|
133
|
-
assert.deepEqual(repaired.servers.ActionS4D_100, unrelated);
|
|
134
|
-
assert.equal(repaired.servers.newDestination, undefined, 'repair must not add unselected destinations');
|
|
135
|
-
|
|
136
|
-
const beforeSecondRepair = await readFile(path, 'utf8');
|
|
137
|
-
const second = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
138
|
-
assert.equal(second.changed, false);
|
|
139
|
-
assert.equal(second.repaired, 0);
|
|
140
|
-
assert.equal(await readFile(path, 'utf8'), beforeSecondRepair, 'an already-healed config must not be rewritten');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('doctor repair leaves malformed config untouched', async t => {
|
|
144
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-invalid-'));
|
|
145
|
-
const path = join(directory, 'mcp.json');
|
|
146
|
-
const malformed = '{ "servers": ';
|
|
147
|
-
await writeFile(path, malformed);
|
|
148
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
149
|
-
await assert.rejects(
|
|
150
|
-
() => repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example' }, path, discoveryComplete: true }),
|
|
151
|
-
/invalid JSON/
|
|
152
|
-
);
|
|
153
|
-
assert.equal(await readFile(path, 'utf8'), malformed);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test('doctor repair skips config changes if destination discovery is incomplete', async t => {
|
|
157
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-incomplete-'));
|
|
158
|
-
const path = join(directory, 'mcp.json');
|
|
159
|
-
const entry = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
|
|
160
|
-
const initial = JSON.stringify({ servers: { shared: entry } });
|
|
161
|
-
await writeFile(path, initial);
|
|
162
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
163
|
-
const result = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://new-h2o.example' }, path });
|
|
164
|
-
assert.equal(result.changed, false);
|
|
165
|
-
assert.match(result.skipped, /incomplete/);
|
|
166
|
-
assert.equal(await readFile(path, 'utf8'), initial);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('doctor repair refreshes stale toolkit launchers without changing npx behavior', async t => {
|
|
170
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-launcher-repair-'));
|
|
171
|
-
const path = join(directory, 'mcp.json');
|
|
172
|
-
const bin = join(directory, 'bin');
|
|
173
|
-
await import('node:fs/promises').then(({ mkdir }) => mkdir(bin));
|
|
174
|
-
const command = join(bin, 'sap-ai-dev');
|
|
175
|
-
await writeFile(command, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
176
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
177
|
-
const npx = {
|
|
178
|
-
...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared,
|
|
179
|
-
command: 'npx',
|
|
180
|
-
args: ['--yes', '--package=sap-ai-dev-toolkit@0.4.2', 'sap-ai-dev'],
|
|
181
|
-
env: {
|
|
182
|
-
...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared.env,
|
|
183
|
-
SAP_AI_DEV_TOOLKIT_DESTINATION: 'shared',
|
|
184
|
-
OPERATOR_SETTING: 'preserve'
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
const direct = {
|
|
188
|
-
...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared,
|
|
189
|
-
command: 'stale-sap-ai-dev'
|
|
190
|
-
};
|
|
191
|
-
await writeFile(path, JSON.stringify({ servers: { shared: npx, other: { ...direct, env: { ...direct.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'other' } }, userServer: { command: 'other' } } }));
|
|
192
|
-
|
|
193
|
-
const result = await repairManagedMcpConfig([bas], {
|
|
194
|
-
env: { H2O_URL: 'http://current-h2o.example', PATH: bin },
|
|
195
|
-
path,
|
|
196
|
-
discoveryComplete: true,
|
|
197
|
-
packageVersion: '0.4.5'
|
|
198
|
-
});
|
|
199
|
-
assert.equal(result.repaired, 1, 'only the entry that maps to the discovered destination is repaired');
|
|
200
|
-
const repaired = await readMcpConfig(path);
|
|
201
|
-
assert.equal(repaired.servers.shared.command, 'npx');
|
|
202
|
-
assert.deepEqual(repaired.servers.shared.args, ['--yes', '--package=sap-ai-dev-toolkit@0.4.5', 'sap-ai-dev']);
|
|
203
|
-
assert.equal(repaired.servers.shared.env.OPERATOR_SETTING, 'preserve');
|
|
204
|
-
assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
|
|
205
|
-
assert.equal(repaired.servers.other.command, 'stale-sap-ai-dev', 'unmatched toolkit entry must remain untouched');
|
|
206
|
-
assert.deepEqual(repaired.servers.userServer, { command: 'other' });
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test('extracts key references only from package-managed Cloud Foundry entries', () => {
|
|
210
|
-
const cloudFoundry = buildMcpEntries([
|
|
211
|
-
cfDestination('space-one', 'instance-one', 'destination-key', 'OnPremise')
|
|
212
|
-
], { H2O_URL: 'http://h2o.example' })['cf:space-one:instance-one:shared'];
|
|
213
|
-
const managedNpx = {
|
|
214
|
-
...cloudFoundry,
|
|
215
|
-
command: 'npx',
|
|
216
|
-
args: ['--yes', '--package=sap-ai-dev-toolkit@0.1.0', 'sap-ai-dev']
|
|
217
|
-
};
|
|
218
|
-
const config = { servers: {
|
|
219
|
-
first: cloudFoundry,
|
|
220
|
-
duplicate: managedNpx,
|
|
221
|
-
unmanaged: { ...cloudFoundry, BAS_EXT: undefined },
|
|
222
|
-
otherPackage: { ...cloudFoundry, command: 'other' },
|
|
223
|
-
otherSource: { ...cloudFoundry, env: { ...cloudFoundry.env, SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'bas' } }
|
|
224
|
-
} };
|
|
225
|
-
assert.deepEqual(collectManagedCloudFoundryKeyReferences(config), [
|
|
226
|
-
{ kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'destination-key' },
|
|
227
|
-
{ kind: 'connectivity', spaceGuid: 'space-one', instanceGuid: 'connectivity-guid', instanceName: 'connectivity-service', keyName: 'connectivity-key' }
|
|
228
|
-
]);
|
|
229
|
-
const userConfig = { servers: {
|
|
230
|
-
customServer: { command: 'custom-launcher', env: { ...cloudFoundry.env, SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: undefined } },
|
|
231
|
-
noEnvironment: { command: 'custom-launcher' }
|
|
232
|
-
} };
|
|
233
|
-
assert.deepEqual(collectCloudFoundryKeyReferencesFromAllEntries(userConfig), [
|
|
234
|
-
{ kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'destination-key' },
|
|
235
|
-
{ kind: 'connectivity', spaceGuid: 'space-one', instanceGuid: 'connectivity-guid', instanceName: 'connectivity-service', keyName: 'connectivity-key' }
|
|
236
|
-
]);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
test('builds SAP development companion MCP entries', () => {
|
|
240
|
-
const entries = buildSapDevelopmentMcpEntries(['sap-fiori-tools', 'ui5-tools']);
|
|
241
|
-
assert.deepEqual(Object.keys(entries), ['sap-fiori-tools', 'ui5-tools']);
|
|
242
|
-
assert.deepEqual(entries['sap-fiori-tools'].args, ['--yes', '--package=@sap-ux/fiori-mcp-server', 'fiori-mcp']);
|
|
243
|
-
assert.equal(entries['ui5-tools'].command, 'npx');
|
|
244
|
-
assert.equal(entries['ui5-tools'].BAS_EXT_KIND, 'sap-development-companion');
|
|
245
|
-
assert.throws(() => buildSapDevelopmentMcpEntries(['missing-tool']), /Unknown SAP development MCP server id/);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test('configures the HANA inspector without persisting credentials', () => {
|
|
249
|
-
const entry = buildSapDevelopmentMcpEntries(['hana-cloud-inspector'])['hana-cloud-inspector'];
|
|
250
|
-
assert.equal(entry.command, 'npx');
|
|
251
|
-
assert.deepEqual(entry.args, ['--yes', '--ignore-scripts', '--package=sap-ai-dev-toolkit', 'sap-ai-hana']);
|
|
252
|
-
assert.equal(entry.BAS_EXT_KIND, 'sap-development-companion');
|
|
253
|
-
assert.equal(entry.displayName, 'HANA Cloud inspector');
|
|
254
|
-
assert.equal(entry.env, undefined);
|
|
255
|
-
assert.doesNotMatch(JSON.stringify(entry), /"(?:HANA_RO_PASSWORD|VCAP_SERVICES)"\s*:/);
|
|
256
|
-
assert.doesNotMatch(JSON.stringify(entry), /"(?:password|pwd)"\s*:/i);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
test('installs and removes SAP development companion entries', async t => {
|
|
260
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-tools-config-'));
|
|
261
|
-
const path = join(directory, 'mcp.json');
|
|
262
|
-
await writeFile(path, JSON.stringify({ servers: { userServer: { type: 'stdio', command: 'custom-server' } } }));
|
|
263
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
264
|
-
|
|
265
|
-
const result = await installMcpConfig([], {
|
|
266
|
-
env: { H2O_URL: 'http://h2o.example' },
|
|
267
|
-
path,
|
|
268
|
-
sapDevelopmentServers: ['sap-fiori-tools', 'cap-tools']
|
|
269
|
-
});
|
|
270
|
-
assert.deepEqual(Object.keys(result.servers).sort(), ['cap-tools', 'sap-fiori-tools']);
|
|
271
|
-
const written = await readMcpConfig(path);
|
|
272
|
-
assert.deepEqual(Object.keys(written.servers).sort(), ['cap-tools', 'sap-fiori-tools', 'userServer']);
|
|
273
|
-
|
|
274
|
-
await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
275
|
-
const cleared = await readMcpConfig(path);
|
|
276
|
-
assert.deepEqual(Object.keys(cleared.servers), ['userServer']);
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
test('installs and removes the HANA inspector without changing unrelated MCP servers', async t => {
|
|
280
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-hana-config-'));
|
|
281
|
-
const path = join(directory, 'mcp.json');
|
|
282
|
-
const initial = {
|
|
283
|
-
custom: true,
|
|
284
|
-
servers: { userServer: { type: 'stdio', command: 'custom-server' } }
|
|
285
|
-
};
|
|
286
|
-
await writeFile(path, JSON.stringify(initial));
|
|
287
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
288
|
-
|
|
289
|
-
const installed = await installMcpConfig([], {
|
|
290
|
-
env: { H2O_URL: 'http://h2o.example' },
|
|
291
|
-
path,
|
|
292
|
-
sapDevelopmentServers: ['hana-cloud-inspector']
|
|
293
|
-
});
|
|
294
|
-
assert.deepEqual(Object.keys(installed.servers), ['hana-cloud-inspector']);
|
|
295
|
-
const configured = await readMcpConfig(path);
|
|
296
|
-
assert.deepEqual(Object.keys(configured.servers).sort(), ['hana-cloud-inspector', 'userServer']);
|
|
297
|
-
assert.equal(configured.custom, true);
|
|
298
|
-
assert.equal(configured.servers['hana-cloud-inspector'].env, undefined);
|
|
299
|
-
assert.doesNotMatch(JSON.stringify(configured), /"(?:HANA_RO_PASSWORD|VCAP_SERVICES)"\s*:/);
|
|
300
|
-
assert.doesNotMatch(JSON.stringify(configured), /"(?:password|pwd)"\s*:/i);
|
|
301
|
-
|
|
302
|
-
await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
303
|
-
const removed = await readMcpConfig(path);
|
|
304
|
-
assert.deepEqual(removed.servers, initial.servers);
|
|
305
|
-
assert.equal(removed.custom, true);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
test('installs and removes CF entries without deleting unrelated MCP servers', async t => {
|
|
309
|
-
const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-cf-config-'));
|
|
310
|
-
const path = join(directory, 'mcp.json');
|
|
311
|
-
await writeFile(path, JSON.stringify({
|
|
312
|
-
unrelated: true,
|
|
313
|
-
servers: { userServer: { type: 'stdio', command: 'custom-server' } }
|
|
314
|
-
}));
|
|
315
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
316
|
-
|
|
317
|
-
const result = await installMcpConfig([bas, cfDestination('space-one', 'instance-one', 'managed-key')], {
|
|
318
|
-
env: { H2O_URL: 'http://h2o.example' },
|
|
319
|
-
path,
|
|
320
|
-
command: 'npx',
|
|
321
|
-
args: ['--yes', '--package=sap-ai-dev-toolkit@0.1.0', 'sap-ai-dev']
|
|
322
|
-
});
|
|
323
|
-
assert.deepEqual(Object.keys(result.servers).sort(), ['cf:space-one:instance-one:shared', 'shared']);
|
|
324
|
-
const written = await readMcpConfig(path);
|
|
325
|
-
assert.equal(written.unrelated, true);
|
|
326
|
-
assert.deepEqual(Object.keys(written.servers).sort(), ['cf:space-one:instance-one:shared', 'shared', 'userServer']);
|
|
327
|
-
assert.deepEqual(collectManagedCloudFoundryKeyReferences(written), [
|
|
328
|
-
{ kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'managed-key' }
|
|
329
|
-
]);
|
|
330
|
-
|
|
331
|
-
await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
332
|
-
const cleared = JSON.parse(await readFile(path, 'utf8'));
|
|
333
|
-
assert.deepEqual(Object.keys(cleared.servers), ['userServer']);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test('wizard replaces legacy launcher entries with the branded command and environment', async t => {
|
|
337
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-migration-'));
|
|
338
|
-
const path = join(directory, 'mcp.json');
|
|
339
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
340
|
-
await writeFile(path, JSON.stringify({
|
|
341
|
-
servers: {
|
|
342
|
-
shared: {
|
|
343
|
-
type: 'stdio',
|
|
344
|
-
command: 'bas-vsp-mcp',
|
|
345
|
-
env: { H2O_URL: 'http://h2o.example', BAS_VSP_DESTINATION: 'shared' },
|
|
346
|
-
BAS_EXT: 'true'
|
|
347
|
-
},
|
|
348
|
-
userServer: { type: 'stdio', command: 'custom-server' }
|
|
349
|
-
}
|
|
350
|
-
}));
|
|
351
|
-
|
|
352
|
-
await installMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example', BAS_VSP_MCP_CONFIG: path } });
|
|
353
|
-
const migrated = await readMcpConfig(path);
|
|
354
|
-
assert.deepEqual(Object.keys(migrated.servers).sort(), ['shared', 'userServer']);
|
|
355
|
-
assert.equal(migrated.servers.shared.command, 'sap-ai-dev');
|
|
356
|
-
assert.equal(migrated.servers.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
|
|
357
|
-
assert.equal(migrated.servers.shared.env.BAS_VSP_DESTINATION, undefined);
|
|
358
|
-
assert.equal(migrated.servers.userServer.command, 'custom-server');
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
test('recognizes old npx Cloud Foundry entries so setup can reuse their service keys', () => {
|
|
362
|
-
const current = buildMcpEntries([cfDestination('space-one', 'instance-one', 'old-key')], { H2O_URL: 'http://h2o.example' })['cf:space-one:instance-one:shared'];
|
|
363
|
-
const legacy = {
|
|
364
|
-
...current,
|
|
365
|
-
command: 'npx',
|
|
366
|
-
args: ['--yes', '--package=bas-mcp-addon@0.1.0', 'bas-vsp-mcp'],
|
|
367
|
-
env: {
|
|
368
|
-
...current.env,
|
|
369
|
-
BAS_VSP_DESTINATION_SOURCE: current.env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE,
|
|
370
|
-
BAS_VSP_DESTINATION: current.env.SAP_AI_DEV_TOOLKIT_DESTINATION
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
delete legacy.BAS_EXT;
|
|
374
|
-
delete legacy.env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE;
|
|
375
|
-
delete legacy.env.SAP_AI_DEV_TOOLKIT_DESTINATION;
|
|
376
|
-
assert.deepEqual(collectManagedCloudFoundryKeyReferences({ servers: { legacy } }), [
|
|
377
|
-
{ kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'old-key' }
|
|
378
|
-
]);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
test('setup removes untagged legacy bas-mcp-addon launchers that expose the full VSP surface', async t => {
|
|
382
|
-
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-untagged-legacy-'));
|
|
383
|
-
const path = join(directory, 'mcp.json');
|
|
384
|
-
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
385
|
-
await writeFile(path, JSON.stringify({
|
|
386
|
-
servers: {
|
|
387
|
-
shared: {
|
|
388
|
-
type: 'stdio',
|
|
389
|
-
command: 'npx',
|
|
390
|
-
args: ['--yes', '--package=bas-mcp-addon@0.2.0', 'bas-vsp-mcp'],
|
|
391
|
-
env: { H2O_URL: 'http://old-h2o.example', BAS_VSP_DESTINATION: 'shared' }
|
|
392
|
-
},
|
|
393
|
-
legacyCommand: {
|
|
394
|
-
type: 'stdio',
|
|
395
|
-
command: 'bas-vsp-mcp',
|
|
396
|
-
env: { H2O_URL: 'http://old-h2o.example', BAS_VSP_DESTINATION: 'other' }
|
|
397
|
-
},
|
|
398
|
-
userServer: { type: 'stdio', command: 'custom-server' }
|
|
399
|
-
}
|
|
400
|
-
}));
|
|
401
|
-
|
|
402
|
-
await installMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
403
|
-
const migrated = await readMcpConfig(path);
|
|
404
|
-
assert.deepEqual(Object.keys(migrated.servers).sort(), ['shared', 'userServer']);
|
|
405
|
-
assert.equal(migrated.servers.shared.command, 'sap-ai-dev');
|
|
406
|
-
assert.equal(migrated.servers.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
|
|
407
|
-
});
|