sap-ai-dev-toolkit 0.5.0 → 0.5.5
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 +1 -1
- package/.github/agents/abap-runtime-debugger.agent.md +1 -1
- 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 +1 -1
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +1 -1
- 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 +1 -1
- 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/package.json +1 -1
- package/scripts/postinstall.mjs +3 -2
- package/src/bas-destination-relay.mjs +33 -19
- package/src/bas-discovery.mjs +15 -5
- package/src/launcher.mjs +19 -13
- package/src/mcp-config.mjs +22 -6
- package/src/mcp-proxy.mjs +185 -51
- package/src/setup.mjs +3 -2
- package/test/bas-destination-relay.test.mjs +53 -12
- package/test/cf-runtime.test.mjs +2 -2
- package/test/discovery.test.mjs +25 -0
- package/test/fixtures/fake-vsp.mjs +3 -0
- package/test/launcher.test.mjs +15 -13
- package/test/live-s4h.test.mjs +13 -11
- package/test/mcp-config-cf.test.mjs +80 -12
- package/test/mcp-proxy.test.mjs +226 -31
- package/test/setup-cf.test.mjs +7 -7
- package/tools.md +4 -2
|
@@ -18,11 +18,20 @@ function sapLikeBackend({
|
|
|
18
18
|
partialRefreshCookies = false,
|
|
19
19
|
omitCookies = false,
|
|
20
20
|
setCookieOnSafeGet = false,
|
|
21
|
-
secureSessionCookie = false
|
|
21
|
+
secureSessionCookie = false,
|
|
22
|
+
postOnlyPaths = [],
|
|
23
|
+
acceptTokenWithoutCookies = false
|
|
22
24
|
} = {}) {
|
|
23
25
|
const state = { sessionCounter: 0, requests: [] };
|
|
24
26
|
const server = http.createServer((req, res) => {
|
|
25
27
|
state.requests.push({ method: req.method, path: req.url, headers: { ...req.headers } });
|
|
28
|
+
// POST-only services (ADT data preview among them) reject every GET,
|
|
29
|
+
// including CSRF token fetches, the way real SAP does.
|
|
30
|
+
if (req.method === 'GET' && postOnlyPaths.some(prefix => req.url.startsWith(prefix))) {
|
|
31
|
+
res.writeHead(405, { allow: 'POST' });
|
|
32
|
+
res.end('<method-not-allowed/>');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
26
35
|
if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
|
|
27
36
|
state.sessionCounter += 1;
|
|
28
37
|
const headers = { 'x-csrf-token': `token-${state.sessionCounter}` };
|
|
@@ -63,12 +72,16 @@ function sapLikeBackend({
|
|
|
63
72
|
const match = String(token || '').match(/^token-(\d+)$/);
|
|
64
73
|
// Without rotation any still-live token+cookie pair is accepted; with
|
|
65
74
|
// rotation only the newest issued session remains valid, matching SAP
|
|
66
|
-
// server-side session invalidation.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
// server-side session invalidation. acceptTokenWithoutCookies models a
|
|
76
|
+
// BAS proxy route where Set-Cookie is stripped yet the backend honors
|
|
77
|
+
// the token on the proxy-established Basic-auth session.
|
|
78
|
+
const valid = acceptTokenWithoutCookies
|
|
79
|
+
? Boolean(match)
|
|
80
|
+
: match
|
|
81
|
+
&& cookie.includes(`sap-session-${match[1]}=`)
|
|
82
|
+
&& (!rotateOnFetch || Number(match[1]) === state.sessionCounter)
|
|
83
|
+
&& (!partialRefreshCookies || cookie.includes('sap-usercontext=sap-client=100'))
|
|
84
|
+
&& (!partialRefreshCookies || Number(match[1]) === 1 || !cookie.includes('sap-stale='));
|
|
72
85
|
if (!valid) {
|
|
73
86
|
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
74
87
|
res.end('CSRF token validation failed');
|
|
@@ -135,6 +148,22 @@ test('pairs CSRF token with its session cookies so unsafe requests succeed', asy
|
|
|
135
148
|
});
|
|
136
149
|
});
|
|
137
150
|
|
|
151
|
+
test('fetches the CSRF token from a GET-capable endpoint when the target is POST-only (ADT data preview)', async () => {
|
|
152
|
+
await withBackend({ postOnlyPaths: ['/sap/bc/adt/datapreview'] }, async ({ relayUrl, state }) => {
|
|
153
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/datapreview/freestyle', {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { accept: 'application/xml' },
|
|
156
|
+
body: '<query/>'
|
|
157
|
+
});
|
|
158
|
+
assert.equal(response.status, 201, `expected 201, got ${response.status}: ${response.body}`);
|
|
159
|
+
const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
160
|
+
assert.equal(tokenFetch.path, '/sap/bc/adt/discovery', 'token must be fetched from the GET-capable discovery endpoint');
|
|
161
|
+
const post = state.requests.find(request => request.method === 'POST');
|
|
162
|
+
assert.match(post.headers['x-csrf-token'], /^token-\d+$/);
|
|
163
|
+
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
138
167
|
test('reuses the cached CSRF session for subsequent unsafe requests', async () => {
|
|
139
168
|
await withBackend(null, async ({ relay, relayUrl, state }) => {
|
|
140
169
|
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
@@ -216,12 +245,22 @@ test('does not retry generic authorization failures as CSRF errors', async () =>
|
|
|
216
245
|
});
|
|
217
246
|
});
|
|
218
247
|
|
|
219
|
-
test('
|
|
248
|
+
test('sends a cookie-less token once and surfaces the rejection without retrying', async () => {
|
|
220
249
|
await withBackend({ omitCookies: true }, async ({ relayUrl, relay, state }) => {
|
|
221
250
|
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
222
|
-
assert.equal(response.status,
|
|
223
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length,
|
|
224
|
-
assert.equal(relay.stats.csrfRetries, 0);
|
|
251
|
+
assert.equal(response.status, 403, `a cookie-less session must surface SAP's verdict, got ${response.status}`);
|
|
252
|
+
assert.equal(state.requests.filter(request => request.method === 'POST').length, 1);
|
|
253
|
+
assert.equal(relay.stats.csrfRetries, 0, 'a cookie-less route cannot be fixed by a retry');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('completes unsafe requests with a token-only session when the route strips cookies (BAS proxy)', async () => {
|
|
258
|
+
await withBackend({ omitCookies: true, acceptTokenWithoutCookies: true }, async ({ relayUrl, relay, state }) => {
|
|
259
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/datapreview/freestyle', { method: 'POST', body: '<query/>' });
|
|
260
|
+
assert.equal(response.status, 201, `expected 201, got ${response.status}: ${response.body}`);
|
|
261
|
+
const post = state.requests.find(request => request.method === 'POST');
|
|
262
|
+
assert.match(post.headers['x-csrf-token'], /^token-\d+$/);
|
|
263
|
+
assert.equal(relay.stats.csrfSessionFailures, 0);
|
|
225
264
|
});
|
|
226
265
|
});
|
|
227
266
|
|
|
@@ -366,7 +405,9 @@ test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF ses
|
|
|
366
405
|
assert.match(post.headers.authorization, /^Basic /);
|
|
367
406
|
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
368
407
|
assert.match(String(post.headers.cookie), /sap-secure=secure-value/, 'Secure SAP cookies must be sent through the HTTPS-backed BAS .dest route');
|
|
369
|
-
|
|
408
|
+
// The POST reuses the probe's cached CSRF session (tokens are shared across
|
|
409
|
+
// paths), so a single pooled tunnel connection can carry both requests.
|
|
410
|
+
assert.ok(connectTargets.length >= 1, 'upstream traffic must reach the backend through the BAS proxy tunnel');
|
|
370
411
|
assert.ok(connectTargets.every(target => target === 'cc-system.dest:80'));
|
|
371
412
|
assert.equal(relay.stats.direct, false);
|
|
372
413
|
assert.equal(relay.stats.proxyTunnel, true);
|
package/test/cf-runtime.test.mjs
CHANGED
|
@@ -298,7 +298,7 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
|
|
|
298
298
|
}, [], [
|
|
299
299
|
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } },
|
|
300
300
|
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
|
|
301
|
-
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: '
|
|
301
|
+
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_pp-target_get_system_info', arguments: {} } }
|
|
302
302
|
]);
|
|
303
303
|
assert.equal(result.code, 0, `${result.stdout}\\n${result.stderr}`);
|
|
304
304
|
const responses = result.stdout.trim().split('\n').map(line => JSON.parse(line));
|
|
@@ -455,7 +455,7 @@ test('overrides CF Basic OnPremise backend auth while retaining the HTTP Connect
|
|
|
455
455
|
}, [], [
|
|
456
456
|
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } },
|
|
457
457
|
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
|
|
458
|
-
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: '
|
|
458
|
+
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'sap_system_basic-onprem_get_system_info', arguments: {} } }
|
|
459
459
|
]);
|
|
460
460
|
|
|
461
461
|
assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`);
|
package/test/discovery.test.mjs
CHANGED
|
@@ -47,6 +47,31 @@ test('accepts the previous destination environment variable during upgrades', as
|
|
|
47
47
|
});
|
|
48
48
|
assert.deepEqual(destinations.map(destination => destination.name), ['legacy-system']);
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
test('probes destinations concurrently while preserving order', async () => {
|
|
52
|
+
const body = [{ Name: 'c-system' }, { Name: 'a-system' }, { Name: 'b-system' }];
|
|
53
|
+
const started = [];
|
|
54
|
+
const release = [];
|
|
55
|
+
const probeImpl = url => new Promise(resolve => {
|
|
56
|
+
started.push(url);
|
|
57
|
+
release.push(() => resolve({ status: 200 }));
|
|
58
|
+
});
|
|
59
|
+
const discovery = discoverDestinations({ body, env: {}, probeImpl });
|
|
60
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
61
|
+
assert.equal(started.length, 3, 'all probes must be in flight at once');
|
|
62
|
+
assert.deepEqual(started, [
|
|
63
|
+
'http://a-system.dest/sap/bc/adt/discovery',
|
|
64
|
+
'http://b-system.dest/sap/bc/adt/discovery',
|
|
65
|
+
'http://c-system.dest/sap/bc/adt/discovery'
|
|
66
|
+
], 'probes start in sorted destination order');
|
|
67
|
+
for (const resolve of release) resolve();
|
|
68
|
+
const destinations = await discovery;
|
|
69
|
+
assert.deepEqual(destinations.map(destination => [destination.name, destination.probe.status]), [
|
|
70
|
+
['a-system', 'available'],
|
|
71
|
+
['b-system', 'available'],
|
|
72
|
+
['c-system', 'available']
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
50
75
|
test('parses sample-style destination records and enables every ADT heartbeat response', async () => {
|
|
51
76
|
const body = [
|
|
52
77
|
{
|
|
@@ -222,6 +222,9 @@ process.stdin.on('data', async chunk => {
|
|
|
222
222
|
params: { level: 'info', logger: 'fake-vsp', data: 'fixture log notification' }
|
|
223
223
|
})}\n`);
|
|
224
224
|
}
|
|
225
|
+
if (message.params?.arguments?.emitListChanged) {
|
|
226
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' })}\n`);
|
|
227
|
+
}
|
|
225
228
|
const transportTools = [
|
|
226
229
|
'ListTransports', 'GetTransport', 'GetUserTransports', 'GetTransportInfo',
|
|
227
230
|
'CreateTransport', 'ReleaseTransport', 'DeleteTransport'
|
package/test/launcher.test.mjs
CHANGED
|
@@ -169,15 +169,16 @@ test('doctor preflights the failing ADT endpoint through the runtime relay and r
|
|
|
169
169
|
}]));
|
|
170
170
|
return;
|
|
171
171
|
}
|
|
172
|
+
if (requestPath === '/sap/bc/adt/discovery' && request.method === 'GET' && request.headers['x-csrf-token'] === 'Fetch') {
|
|
173
|
+
// Real SAP issues CSRF tokens from GET-capable endpoints only.
|
|
174
|
+
response.writeHead(200, {
|
|
175
|
+
'x-csrf-token': 'doctor-token',
|
|
176
|
+
'set-cookie': 'sap-session=doctor-session; Path=/'
|
|
177
|
+
});
|
|
178
|
+
response.end('<ok/>');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
172
181
|
if (requestPath === '/sap/bc/adt/datapreview/freestyle') {
|
|
173
|
-
if (request.method === 'GET' && request.headers['x-csrf-token'] === 'Fetch') {
|
|
174
|
-
response.writeHead(200, {
|
|
175
|
-
'x-csrf-token': 'doctor-token',
|
|
176
|
-
'set-cookie': 'sap-session=doctor-session; Path=/'
|
|
177
|
-
});
|
|
178
|
-
response.end('<ok/>');
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
182
|
if (request.method === 'POST'
|
|
182
183
|
&& request.headers['x-csrf-token'] === 'doctor-token'
|
|
183
184
|
&& String(request.headers.cookie).includes('sap-session=doctor-session')) {
|
|
@@ -217,14 +218,14 @@ test('doctor preflights the failing ADT endpoint through the runtime relay and r
|
|
|
217
218
|
assert.ok(report.checks.some(check => check.stage === 'SAP system check' && check.status === 'passed'));
|
|
218
219
|
assert.ok(report.checks.some(check => check.stage === 'MCP config repair' && check.status === 'passed' && /1 managed BAS entry repaired/.test(check.detail)));
|
|
219
220
|
assert.ok(report.checks.some(check => check.stage === 'MCP tools/list' && /total MCP tools returned; chat-picker binding is host-managed/.test(check.detail)));
|
|
220
|
-
assert.ok(requests.some(request => request.method === 'GET' && request.path === '/sap/bc/adt/
|
|
221
|
+
assert.ok(requests.some(request => request.method === 'GET' && request.path === '/sap/bc/adt/discovery' && request.csrf === 'Fetch'));
|
|
221
222
|
assert.ok(requests.some(request => request.method === 'POST' && request.path === '/sap/bc/adt/datapreview/freestyle' && request.csrf === 'doctor-token' && String(request.cookie).includes('sap-session=doctor-session')));
|
|
222
223
|
const repairedConfig = JSON.parse(await readFile(configPath, 'utf8'));
|
|
223
224
|
assert.equal(repairedConfig.servers['doctor-system'].env.H2O_URL, `http://127.0.0.1:${server.address().port}`);
|
|
224
225
|
assert.deepEqual(repairedConfig.servers.ActionS4D_100, config.servers.ActionS4D_100);
|
|
225
226
|
});
|
|
226
227
|
|
|
227
|
-
test('doctor stops before GetSystemInfo when the runtime CSRF preflight
|
|
228
|
+
test('doctor stops before GetSystemInfo when the runtime CSRF preflight receives no token', async t => {
|
|
228
229
|
const directory = await mkdtemp(join(tmpdir(), 'bas-doctor-csrf-missing-cookie-'));
|
|
229
230
|
const configPath = join(directory, 'mcp.json');
|
|
230
231
|
await writeFile(configPath, JSON.stringify({ servers: {} }));
|
|
@@ -245,8 +246,9 @@ test('doctor stops before GetSystemInfo when the runtime CSRF preflight has no s
|
|
|
245
246
|
}]));
|
|
246
247
|
return;
|
|
247
248
|
}
|
|
248
|
-
if (requestPath === '/sap/bc/adt/
|
|
249
|
-
|
|
249
|
+
if (requestPath === '/sap/bc/adt/discovery' && request.method === 'GET' && request.headers['x-csrf-token'] === 'Fetch') {
|
|
250
|
+
// Issues no token at all: the relay must fail closed before any write.
|
|
251
|
+
response.writeHead(200);
|
|
250
252
|
response.end('<ok/>');
|
|
251
253
|
return;
|
|
252
254
|
}
|
|
@@ -276,7 +278,7 @@ test('doctor stops before GetSystemInfo when the runtime CSRF preflight has no s
|
|
|
276
278
|
assert.equal(report.ok, false);
|
|
277
279
|
assert.ok(report.checks.some(check => check.stage === 'BAS relay CSRF preflight' && check.status === 'failed' && /cookies=0; usableSession=false/.test(check.detail)));
|
|
278
280
|
assert.ok(report.checks.some(check => check.stage === 'SAP system check' && check.status === 'skipped' && /validated BAS proxy tunnel credential override/.test(check.detail)));
|
|
279
|
-
assert.equal(requests.some(request => request.method === 'GET' && request.path === '/sap/bc/adt/
|
|
281
|
+
assert.equal(requests.some(request => request.method === 'GET' && request.path === '/sap/bc/adt/discovery' && request.csrf === 'Fetch'), true);
|
|
280
282
|
assert.equal(requests.some(request => request.method === 'POST' && request.path === '/sap/bc/adt/datapreview/freestyle'), false, 'doctor must not call GetSystemInfo after a failed preflight');
|
|
281
283
|
});
|
|
282
284
|
|
package/test/live-s4h.test.mjs
CHANGED
|
@@ -6,11 +6,13 @@ import { readFile } from 'node:fs/promises';
|
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { slugifyDestination } from '../src/bas-discovery.mjs';
|
|
10
|
+
import { snakeCaseName, TOOL_NAME_PREFIX } from '../src/mcp-proxy.mjs';
|
|
9
11
|
|
|
10
12
|
const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
|
|
11
13
|
const LIVE = /^(1|true|yes)$/iu.test(process.env.SAP_AI_DEV_LIVE_S4H || '');
|
|
12
14
|
const DESTINATION = process.env.SAP_AI_DEV_LIVE_DESTINATION || 'S4H';
|
|
13
|
-
const SLUG = process.env.SAP_AI_DEV_LIVE_DESTINATION_SLUG || DESTINATION
|
|
15
|
+
const SLUG = process.env.SAP_AI_DEV_LIVE_DESTINATION_SLUG || slugifyDestination(DESTINATION);
|
|
14
16
|
const REQUEST_TIMEOUT_MS = Number(process.env.SAP_AI_DEV_LIVE_TIMEOUT_MS || 45_000);
|
|
15
17
|
|
|
16
18
|
async function readConfiguredServer() {
|
|
@@ -29,7 +31,7 @@ async function readConfiguredServer() {
|
|
|
29
31
|
} catch {
|
|
30
32
|
continue;
|
|
31
33
|
}
|
|
32
|
-
const server = parsed?.servers?.[DESTINATION];
|
|
34
|
+
const server = parsed?.servers?.[SLUG] || parsed?.servers?.[DESTINATION];
|
|
33
35
|
if (server?.command) return { path, server };
|
|
34
36
|
}
|
|
35
37
|
return null;
|
|
@@ -131,7 +133,7 @@ async function withLiveClient(t) {
|
|
|
131
133
|
t.after(() => client.close());
|
|
132
134
|
const initialized = await client.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'sap-ai-dev-live-test', version: '1.0.0' } });
|
|
133
135
|
client.notify('notifications/initialized');
|
|
134
|
-
assert.equal(initialized.serverInfo.name,
|
|
136
|
+
assert.equal(initialized.serverInfo.name, SLUG);
|
|
135
137
|
return client;
|
|
136
138
|
}
|
|
137
139
|
|
|
@@ -140,7 +142,7 @@ test('live S4H MCP exposes a curated ABAP lifecycle surface under 60 tools', { s
|
|
|
140
142
|
const listed = await client.request('tools/list', {});
|
|
141
143
|
const tools = listed.tools || [];
|
|
142
144
|
const names = new Set(tools.map(tool => tool.name));
|
|
143
|
-
const prefix = `${SLUG}
|
|
145
|
+
const prefix = `${TOOL_NAME_PREFIX}${SLUG}_`;
|
|
144
146
|
|
|
145
147
|
assert.ok(tools.length > 25, `expected a useful ABAP lifecycle surface, got ${tools.length}`);
|
|
146
148
|
assert.ok(tools.length < 60, `curated destination tool count must stay below 60, got ${tools.length}`);
|
|
@@ -154,24 +156,24 @@ test('live S4H MCP exposes a curated ABAP lifecycle surface under 60 tools', { s
|
|
|
154
156
|
'GetTransport', 'GetTransportInfo', 'ListTransports', 'CreateTransport',
|
|
155
157
|
'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration'
|
|
156
158
|
]) {
|
|
157
|
-
assert.ok(names.has(`${prefix}${required}`), `expected curated lifecycle tool ${required}`);
|
|
159
|
+
assert.ok(names.has(`${prefix}${snakeCaseName(required)}`), `expected curated lifecycle tool ${required}`);
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
for (const hidden of [
|
|
161
163
|
'SAP', 'ReleaseTransport', 'DeleteTransport', 'DeleteObject', 'ExecuteABAP', 'CallRFC',
|
|
162
164
|
'ListSQLTraces', 'AnalyzeABAPCode', 'CreateObject', 'CreateClassWithTests', 'WriteClass', 'UpdateSource'
|
|
163
165
|
]) {
|
|
164
|
-
assert.equal(names.has(`${prefix}${hidden}`), false, `broad/destructive tool ${hidden} must remain hidden`);
|
|
166
|
+
assert.equal(names.has(`${prefix}${snakeCaseName(hidden)}`), false, `broad/destructive tool ${hidden} must remain hidden`);
|
|
165
167
|
}
|
|
166
168
|
});
|
|
167
169
|
|
|
168
170
|
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
171
|
const client = await withLiveClient(t);
|
|
170
|
-
const prefix = `${SLUG}
|
|
172
|
+
const prefix = `${TOOL_NAME_PREFIX}${SLUG}_`;
|
|
171
173
|
await client.request('tools/list', {});
|
|
172
174
|
|
|
173
175
|
const lint = await client.request('tools/call', {
|
|
174
|
-
name: `${prefix}
|
|
176
|
+
name: `${prefix}lint_abap`,
|
|
175
177
|
arguments: { files: [{ filename: 'zlive_probe.prog.abap', source: "REPORT zlive_probe.\nWRITE / 'ok'.\n" }] }
|
|
176
178
|
});
|
|
177
179
|
const lintJson = JSON.parse(textPayload(lint));
|
|
@@ -183,19 +185,19 @@ test('live S4H MCP handles safe local and read-only SAP calls', { skip: LIVE ? f
|
|
|
183
185
|
['GetFeatures', {}],
|
|
184
186
|
['GetInstalledComponents', {}]
|
|
185
187
|
]) {
|
|
186
|
-
const result = await client.request('tools/call', { name: `${prefix}${tool}`, arguments: args });
|
|
188
|
+
const result = await client.request('tools/call', { name: `${prefix}${snakeCaseName(tool)}`, arguments: args });
|
|
187
189
|
assert.notEqual(result.isError, true, `${tool} returned an MCP tool error: ${textPayload(result)}`);
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
const query = await client.request('tools/call', {
|
|
191
|
-
name: `${prefix}
|
|
193
|
+
name: `${prefix}run_query`,
|
|
192
194
|
arguments: { sql_query: 'SELECT * FROM T000', max_rows: 1 }
|
|
193
195
|
});
|
|
194
196
|
assert.notEqual(query.isError, true, `RunQuery returned an MCP tool error: ${textPayload(query)}`);
|
|
195
197
|
assert.match(textPayload(query), /T000|MANDT|client|rows|\[/iu);
|
|
196
198
|
|
|
197
199
|
const appLog = await client.request('tools/call', {
|
|
198
|
-
name: `${prefix}
|
|
200
|
+
name: `${prefix}get_application_log`,
|
|
199
201
|
arguments: { max_results: 1 }
|
|
200
202
|
});
|
|
201
203
|
assert.notEqual(appLog.isError, true, `GetApplicationLog returned an MCP tool error: ${textPayload(appLog)}`);
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
|
-
import { buildMcpEntries, buildSapDevelopmentMcpEntries, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, repairManagedMcpConfig, resolveMcpConfigPath } from '../src/mcp-config.mjs';
|
|
6
|
+
import { buildMcpEntries, buildSapDevelopmentMcpEntries, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, generatedServerName, installMcpConfig, readMcpConfig, repairManagedMcpConfig, resolveMcpConfigPath } from '../src/mcp-config.mjs';
|
|
7
7
|
|
|
8
8
|
const bas = {
|
|
9
9
|
source: 'bas', name: 'shared', serverName: 'shared', url: 'http://shared.dest',
|
|
@@ -38,11 +38,11 @@ test('writes source-qualified CF entries beside same-named BAS destinations', ()
|
|
|
38
38
|
const second = cfDestination('space-one', 'instance-two', 'key-two');
|
|
39
39
|
const entries = buildMcpEntries([bas, first, second], { H2O_URL: 'http://h2o.example' });
|
|
40
40
|
assert.deepEqual(Object.keys(entries).sort(), [
|
|
41
|
-
'cf
|
|
42
|
-
'cf
|
|
41
|
+
'cf-space-one-instance-one-shared',
|
|
42
|
+
'cf-space-one-instance-two-shared',
|
|
43
43
|
'shared'
|
|
44
44
|
]);
|
|
45
|
-
assert.deepEqual(entries['cf
|
|
45
|
+
assert.deepEqual(entries['cf-space-one-instance-one-shared'].env, {
|
|
46
46
|
H2O_URL: 'http://h2o.example',
|
|
47
47
|
SAP_ALLOW_TRANSPORTABLE_EDITS: 'true',
|
|
48
48
|
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
@@ -58,7 +58,7 @@ test('writes source-qualified CF entries beside same-named BAS destinations', ()
|
|
|
58
58
|
assert.equal(JSON.stringify(entries).includes('Password'), false);
|
|
59
59
|
|
|
60
60
|
const onPremise = buildMcpEntries([cfDestination('space-one', 'onprem-instance', 'destination-key', 'OnPremise')], { H2O_URL: 'http://h2o.example' });
|
|
61
|
-
assert.equal(onPremise['cf
|
|
61
|
+
assert.equal(onPremise['cf-space-one-onprem-instance-shared'].env.BAS_CF_CONNECTIVITY_KEY, 'connectivity-key');
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
test('stores and reconciles the MCP launcher executable location', async t => {
|
|
@@ -99,7 +99,7 @@ test('doctor repair updates only discovered toolkit BAS entries and is idempoten
|
|
|
99
99
|
const strictDestination = { ...bas, name: 'strict', serverName: 'strict', url: 'http://strict.dest' };
|
|
100
100
|
const managed = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
|
|
101
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];
|
|
102
|
+
const cfEntry = buildMcpEntries([cf], { H2O_URL: 'http://cf-h2o.example' })[generatedServerName(cf.serverName)];
|
|
103
103
|
const companion = buildSapDevelopmentMcpEntries(['sap-fiori-tools'])['sap-fiori-tools'];
|
|
104
104
|
const unrelated = { type: 'stdio', command: 'external-server' };
|
|
105
105
|
const unverifiedManaged = {
|
|
@@ -112,7 +112,7 @@ test('doctor repair updates only discovered toolkit BAS entries and is idempoten
|
|
|
112
112
|
shared: { ...managed, env: { ...managed.env, H2O_URL: 'http://old-h2o.example' } },
|
|
113
113
|
strict: { ...strictManaged, env: { ...strictManaged.env, SAP_ALLOW_TRANSPORTABLE_EDITS: 'false' } },
|
|
114
114
|
'temporarily-unavailable': unverifiedManaged,
|
|
115
|
-
[cf.serverName]: cfEntry,
|
|
115
|
+
[generatedServerName(cf.serverName)]: cfEntry,
|
|
116
116
|
'sap-fiori-tools': companion,
|
|
117
117
|
ActionS4D_100: unrelated
|
|
118
118
|
}
|
|
@@ -128,7 +128,7 @@ test('doctor repair updates only discovered toolkit BAS entries and is idempoten
|
|
|
128
128
|
assert.equal(repaired.servers.shared.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'true');
|
|
129
129
|
assert.equal(repaired.servers.strict.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'false', "doctor must preserve a user's restricted write setting");
|
|
130
130
|
assert.deepEqual(repaired.servers['temporarily-unavailable'], unverifiedManaged);
|
|
131
|
-
assert.deepEqual(repaired.servers[cf.serverName], cfEntry);
|
|
131
|
+
assert.deepEqual(repaired.servers[generatedServerName(cf.serverName)], cfEntry);
|
|
132
132
|
assert.deepEqual(repaired.servers['sap-fiori-tools'], companion);
|
|
133
133
|
assert.deepEqual(repaired.servers.ActionS4D_100, unrelated);
|
|
134
134
|
assert.equal(repaired.servers.newDestination, undefined, 'repair must not add unselected destinations');
|
|
@@ -209,7 +209,7 @@ test('doctor repair refreshes stale toolkit launchers without changing npx behav
|
|
|
209
209
|
test('extracts key references only from package-managed Cloud Foundry entries', () => {
|
|
210
210
|
const cloudFoundry = buildMcpEntries([
|
|
211
211
|
cfDestination('space-one', 'instance-one', 'destination-key', 'OnPremise')
|
|
212
|
-
], { H2O_URL: 'http://h2o.example' })['cf
|
|
212
|
+
], { H2O_URL: 'http://h2o.example' })['cf-space-one-instance-one-shared'];
|
|
213
213
|
const managedNpx = {
|
|
214
214
|
...cloudFoundry,
|
|
215
215
|
command: 'npx',
|
|
@@ -320,10 +320,10 @@ test('installs and removes CF entries without deleting unrelated MCP servers', a
|
|
|
320
320
|
command: 'npx',
|
|
321
321
|
args: ['--yes', '--package=sap-ai-dev-toolkit@0.1.0', 'sap-ai-dev']
|
|
322
322
|
});
|
|
323
|
-
assert.deepEqual(Object.keys(result.servers).sort(), ['cf
|
|
323
|
+
assert.deepEqual(Object.keys(result.servers).sort(), ['cf-space-one-instance-one-shared', 'shared']);
|
|
324
324
|
const written = await readMcpConfig(path);
|
|
325
325
|
assert.equal(written.unrelated, true);
|
|
326
|
-
assert.deepEqual(Object.keys(written.servers).sort(), ['cf
|
|
326
|
+
assert.deepEqual(Object.keys(written.servers).sort(), ['cf-space-one-instance-one-shared', 'shared', 'userServer']);
|
|
327
327
|
assert.deepEqual(collectManagedCloudFoundryKeyReferences(written), [
|
|
328
328
|
{ kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'managed-key' }
|
|
329
329
|
]);
|
|
@@ -359,7 +359,7 @@ test('wizard replaces legacy launcher entries with the branded command and envir
|
|
|
359
359
|
});
|
|
360
360
|
|
|
361
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
|
|
362
|
+
const current = buildMcpEntries([cfDestination('space-one', 'instance-one', 'old-key')], { H2O_URL: 'http://h2o.example' })['cf-space-one-instance-one-shared'];
|
|
363
363
|
const legacy = {
|
|
364
364
|
...current,
|
|
365
365
|
command: 'npx',
|
|
@@ -405,3 +405,71 @@ test('setup removes untagged legacy bas-mcp-addon launchers that expose the full
|
|
|
405
405
|
assert.equal(migrated.servers.shared.command, 'sap-ai-dev');
|
|
406
406
|
assert.equal(migrated.servers.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
|
|
407
407
|
});
|
|
408
|
+
|
|
409
|
+
test('install registers mixed-case destinations under lowercase server names', async t => {
|
|
410
|
+
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-lowercase-install-'));
|
|
411
|
+
const path = join(directory, 'mcp.json');
|
|
412
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
413
|
+
const destination = { ...bas, name: 'ActionS4D', serverName: 'ActionS4D', url: 'http://ActionS4D.dest' };
|
|
414
|
+
await writeFile(path, JSON.stringify({ servers: {} }));
|
|
415
|
+
|
|
416
|
+
const result = await installMcpConfig([destination], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
417
|
+
assert.deepEqual(Object.keys(result.servers), ['actions4d']);
|
|
418
|
+
assert.match(Object.keys(result.servers)[0], /^[a-z0-9-]+$/, 'server names must be lowercase slugs');
|
|
419
|
+
assert.equal(result.servers.actions4d.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'ActionS4D');
|
|
420
|
+
|
|
421
|
+
// Re-running setup replaces a legacy mixed-case managed entry with the slug key.
|
|
422
|
+
const legacy = { ...result.servers.actions4d };
|
|
423
|
+
await writeFile(path, JSON.stringify({ servers: { ActionS4D: legacy, userServer: { command: 'other' } } }));
|
|
424
|
+
await installMcpConfig([destination], { env: { H2O_URL: 'http://h2o.example' }, path });
|
|
425
|
+
const written = await readMcpConfig(path);
|
|
426
|
+
assert.deepEqual(Object.keys(written.servers).sort(), ['actions4d', 'userServer']);
|
|
427
|
+
|
|
428
|
+
// Destination names that only differ in case or punctuation now collide.
|
|
429
|
+
assert.throws(
|
|
430
|
+
() => buildMcpEntries([destination, { ...bas, name: 'actions4d' }], { H2O_URL: 'http://h2o.example' }),
|
|
431
|
+
/normalize to the same lowercase server name/
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('doctor repair renames legacy mixed-case managed entries to the lowercase slug', async t => {
|
|
436
|
+
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-rename-'));
|
|
437
|
+
const path = join(directory, 'mcp.json');
|
|
438
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
439
|
+
const destination = { ...bas, name: 'ActionS4D', serverName: 'ActionS4D', url: 'http://ActionS4D.dest' };
|
|
440
|
+
const legacy = buildMcpEntries([destination], { H2O_URL: 'http://old-h2o.example' }).actions4d;
|
|
441
|
+
const unrelated = { type: 'stdio', command: 'third-party' };
|
|
442
|
+
await writeFile(path, JSON.stringify({ servers: { ActionS4D: legacy, ActionS4D_100: unrelated } }));
|
|
443
|
+
|
|
444
|
+
const first = await repairManagedMcpConfig([destination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
445
|
+
assert.equal(first.changed, true);
|
|
446
|
+
assert.equal(first.repaired, 1);
|
|
447
|
+
const renamed = await readMcpConfig(path);
|
|
448
|
+
assert.equal(renamed.servers.ActionS4D, undefined, 'the legacy mixed-case key must be removed');
|
|
449
|
+
assert.equal(renamed.servers.actions4d.env.H2O_URL, 'http://current-h2o.example');
|
|
450
|
+
assert.equal(renamed.servers.actions4d.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'ActionS4D');
|
|
451
|
+
assert.deepEqual(renamed.servers.ActionS4D_100, unrelated);
|
|
452
|
+
|
|
453
|
+
const beforeSecond = await readFile(path, 'utf8');
|
|
454
|
+
const second = await repairManagedMcpConfig([destination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
455
|
+
assert.equal(second.changed, false);
|
|
456
|
+
assert.equal(second.repaired, 0);
|
|
457
|
+
assert.equal(await readFile(path, 'utf8'), beforeSecond, 'the rename must be idempotent');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('doctor repair keeps the legacy entry when a user-owned server holds the slug key', async t => {
|
|
461
|
+
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-rename-blocked-'));
|
|
462
|
+
const path = join(directory, 'mcp.json');
|
|
463
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
464
|
+
const destination = { ...bas, name: 'ActionS4D', serverName: 'ActionS4D', url: 'http://ActionS4D.dest' };
|
|
465
|
+
const legacy = buildMcpEntries([destination], { H2O_URL: 'http://old-h2o.example' }).actions4d;
|
|
466
|
+
const userOwned = { type: 'stdio', command: 'user-server' };
|
|
467
|
+
const initial = { servers: { ActionS4D: legacy, actions4d: userOwned } };
|
|
468
|
+
await writeFile(path, JSON.stringify(initial));
|
|
469
|
+
|
|
470
|
+
const result = await repairManagedMcpConfig([destination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
471
|
+
assert.equal(result.repaired, 0, 'a user-owned entry on the slug key must block the rename');
|
|
472
|
+
const config = await readMcpConfig(path);
|
|
473
|
+
assert.deepEqual(config, initial);
|
|
474
|
+
});
|
|
475
|
+
|