sap-ai-dev-toolkit 0.4.5 → 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 +21 -14
- package/package.json +1 -1
- package/scripts/postinstall.mjs +3 -2
- package/src/bas-destination-relay.mjs +48 -22
- package/src/bas-discovery.mjs +16 -6
- package/src/launcher.mjs +24 -14
- package/src/mcp-config.mjs +36 -7
- package/src/mcp-proxy.mjs +185 -51
- package/src/setup.mjs +3 -2
- package/test/bas-destination-relay.test.mjs +93 -14
- package/test/cf-runtime.test.mjs +2 -2
- package/test/discovery.test.mjs +45 -0
- package/test/fixtures/fake-vsp.mjs +6 -3
- package/test/launcher.test.mjs +15 -13
- package/test/live-s4h.test.mjs +13 -11
- package/test/mcp-config-cf.test.mjs +127 -15
- package/test/mcp-proxy.test.mjs +232 -33
- package/test/setup-cf.test.mjs +7 -7
- package/tools.md +4 -2
package/src/setup.mjs
CHANGED
|
@@ -167,8 +167,9 @@ function printConnectionInstructions(output, result) {
|
|
|
167
167
|
print(output, '');
|
|
168
168
|
print(output, colorText('📡 MCP servers now available:', 'cyan', output));
|
|
169
169
|
for (const [name, entry] of servers) {
|
|
170
|
-
const
|
|
171
|
-
|
|
170
|
+
const destinationName = entry.env?.SAP_AI_DEV_TOOLKIT_DESTINATION || entry.env?.BAS_VSP_DESTINATION;
|
|
171
|
+
const detail = destinationName
|
|
172
|
+
? `destination: ${destinationName}`
|
|
172
173
|
: (entry.displayName ? `tool server: ${entry.displayName}` : 'tool server');
|
|
173
174
|
print(output, ` • ${name} — ${detail}`);
|
|
174
175
|
}
|
|
@@ -17,11 +17,21 @@ function sapLikeBackend({
|
|
|
17
17
|
genericForbidden = false,
|
|
18
18
|
partialRefreshCookies = false,
|
|
19
19
|
omitCookies = false,
|
|
20
|
-
setCookieOnSafeGet = false
|
|
20
|
+
setCookieOnSafeGet = false,
|
|
21
|
+
secureSessionCookie = false,
|
|
22
|
+
postOnlyPaths = [],
|
|
23
|
+
acceptTokenWithoutCookies = false
|
|
21
24
|
} = {}) {
|
|
22
25
|
const state = { sessionCounter: 0, requests: [] };
|
|
23
26
|
const server = http.createServer((req, res) => {
|
|
24
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
|
+
}
|
|
25
35
|
if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
|
|
26
36
|
state.sessionCounter += 1;
|
|
27
37
|
const headers = { 'x-csrf-token': `token-${state.sessionCounter}` };
|
|
@@ -35,6 +45,7 @@ function sapLikeBackend({
|
|
|
35
45
|
? 'sap-stale=delete-me; path=/'
|
|
36
46
|
: 'sap-stale=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/');
|
|
37
47
|
}
|
|
48
|
+
if (secureSessionCookie) headers['set-cookie'].push('sap-secure=secure-value; Secure; Path=/');
|
|
38
49
|
}
|
|
39
50
|
res.writeHead(200, headers);
|
|
40
51
|
res.end('<ok/>');
|
|
@@ -61,12 +72,16 @@ function sapLikeBackend({
|
|
|
61
72
|
const match = String(token || '').match(/^token-(\d+)$/);
|
|
62
73
|
// Without rotation any still-live token+cookie pair is accepted; with
|
|
63
74
|
// rotation only the newest issued session remains valid, matching SAP
|
|
64
|
-
// server-side session invalidation.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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='));
|
|
70
85
|
if (!valid) {
|
|
71
86
|
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
72
87
|
res.end('CSRF token validation failed');
|
|
@@ -133,6 +148,22 @@ test('pairs CSRF token with its session cookies so unsafe requests succeed', asy
|
|
|
133
148
|
});
|
|
134
149
|
});
|
|
135
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
|
+
|
|
136
167
|
test('reuses the cached CSRF session for subsequent unsafe requests', async () => {
|
|
137
168
|
await withBackend(null, async ({ relay, relayUrl, state }) => {
|
|
138
169
|
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
@@ -145,6 +176,30 @@ test('reuses the cached CSRF session for subsequent unsafe requests', async () =
|
|
|
145
176
|
});
|
|
146
177
|
});
|
|
147
178
|
|
|
179
|
+
test('coalesces concurrent CSRF token fetches for the same ADT endpoint', async () => {
|
|
180
|
+
await withBackend(null, async ({ relayUrl, state }) => {
|
|
181
|
+
const responses = await Promise.all([
|
|
182
|
+
relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' }),
|
|
183
|
+
relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' })
|
|
184
|
+
]);
|
|
185
|
+
assert.deepEqual(responses.map(response => response.status), [201, 201]);
|
|
186
|
+
assert.equal(state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch').length, 1);
|
|
187
|
+
assert.equal(state.requests.filter(request => request.method === 'POST').length, 2);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('seeds the relay cookie jar from the incoming MCP request cookie', async () => {
|
|
192
|
+
await withBackend(null, async ({ relayUrl, state }) => {
|
|
193
|
+
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { cookie: 'sap-initial-session=from-vsp' },
|
|
196
|
+
body: '<x/>'
|
|
197
|
+
});
|
|
198
|
+
const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
199
|
+
assert.match(String(tokenFetch.headers.cookie), /sap-initial-session=from-vsp/);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
148
203
|
test('self-heals when SAP rotates the session: refetches token and cookies', async () => {
|
|
149
204
|
await withBackend({ rotateOnFetch: true }, async ({ relay, relayUrl, state }) => {
|
|
150
205
|
// A first POST establishes session token-1. SAP then invalidates it
|
|
@@ -190,12 +245,22 @@ test('does not retry generic authorization failures as CSRF errors', async () =>
|
|
|
190
245
|
});
|
|
191
246
|
});
|
|
192
247
|
|
|
193
|
-
test('
|
|
248
|
+
test('sends a cookie-less token once and surfaces the rejection without retrying', async () => {
|
|
194
249
|
await withBackend({ omitCookies: true }, async ({ relayUrl, relay, state }) => {
|
|
195
250
|
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
196
|
-
assert.equal(response.status,
|
|
197
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length,
|
|
198
|
-
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);
|
|
199
264
|
});
|
|
200
265
|
});
|
|
201
266
|
|
|
@@ -218,6 +283,17 @@ test('honors the configured bounded CSRF retry count', async () => {
|
|
|
218
283
|
});
|
|
219
284
|
});
|
|
220
285
|
|
|
286
|
+
test('uses the default for invalid retry settings and caps excessive retry counts', async () => {
|
|
287
|
+
await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '1.5' } }, async ({ relayUrl, state }) => {
|
|
288
|
+
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
289
|
+
assert.equal(state.requests.filter(request => request.method === 'POST').length, 4, 'invalid values use the default of three retries');
|
|
290
|
+
});
|
|
291
|
+
await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '100' } }, async ({ relayUrl, state }) => {
|
|
292
|
+
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
293
|
+
assert.equal(state.requests.filter(request => request.method === 'POST').length, 11, 'retry counts are capped at ten');
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
221
297
|
test('prefers the documented branded proxy setting including an explicit direct-connection override', async t => {
|
|
222
298
|
let dispatchedWithProxy = false;
|
|
223
299
|
const relay = createBasDestinationRelay(
|
|
@@ -287,7 +363,7 @@ test('falls back to a proxy tunnel when the absolute-form request is refused', a
|
|
|
287
363
|
});
|
|
288
364
|
|
|
289
365
|
test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF session', async t => {
|
|
290
|
-
const { state, server } = sapLikeBackend();
|
|
366
|
+
const { state, server } = sapLikeBackend({ secureSessionCookie: true });
|
|
291
367
|
const backendPort = await startServer(server);
|
|
292
368
|
const connectTargets = [];
|
|
293
369
|
const proxy = http.createServer();
|
|
@@ -322,13 +398,16 @@ test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF ses
|
|
|
322
398
|
});
|
|
323
399
|
|
|
324
400
|
const probe = await relay.probeCsrfSession();
|
|
325
|
-
assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount:
|
|
401
|
+
assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 3, sessionUsable: true });
|
|
326
402
|
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
327
403
|
assert.equal(response.status, 201, `expected a paired CSRF session, got ${response.status}: ${response.body}`);
|
|
328
404
|
const post = state.requests.find(request => request.method === 'POST');
|
|
329
405
|
assert.match(post.headers.authorization, /^Basic /);
|
|
330
406
|
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
331
|
-
assert.
|
|
407
|
+
assert.match(String(post.headers.cookie), /sap-secure=secure-value/, 'Secure SAP cookies must be sent through the HTTPS-backed BAS .dest route');
|
|
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');
|
|
332
411
|
assert.ok(connectTargets.every(target => target === 'cc-system.dest:80'));
|
|
333
412
|
assert.equal(relay.stats.direct, false);
|
|
334
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
|
{
|
|
@@ -231,6 +256,26 @@ test('fetches BAS destination lists through configured HTTP and HTTPS proxies',
|
|
|
231
256
|
assert.equal(proxyRequests.length, 2);
|
|
232
257
|
});
|
|
233
258
|
|
|
259
|
+
test('branded HTTP proxy takes precedence over HTTPS_PROXY for secure BAS discovery', async () => {
|
|
260
|
+
let capturedDispatcher = 'not-called';
|
|
261
|
+
const body = await fetchDestinationList('https://bas.example', {
|
|
262
|
+
env: {
|
|
263
|
+
SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '',
|
|
264
|
+
HTTPS_PROXY: 'http://127.0.0.1:1',
|
|
265
|
+
https_proxy: 'http://127.0.0.1:1'
|
|
266
|
+
},
|
|
267
|
+
fetchImpl: async (_url, options) => {
|
|
268
|
+
capturedDispatcher = options.dispatcher;
|
|
269
|
+
return new Response(JSON.stringify([{ Name: 'secure-system' }]), {
|
|
270
|
+
status: 200,
|
|
271
|
+
headers: { 'content-type': 'application/json' }
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
assert.deepEqual(body, [{ Name: 'secure-system' }]);
|
|
276
|
+
assert.equal(capturedDispatcher, undefined, 'explicit branded empty proxy must disable inherited HTTPS_PROXY too');
|
|
277
|
+
});
|
|
278
|
+
|
|
234
279
|
test('redacts credential-bearing diagnostics without exposing payloads', () => {
|
|
235
280
|
const value = redact({ name: 'dev', password: 'secret', authorization: 'Bearer abc', nested: { cookie: 'x', ok: 'visible' } });
|
|
236
281
|
assert.deepEqual(value, { name: 'dev', password: '[redacted]', authorization: '[redacted]', nested: { cookie: '[redacted]', ok: 'visible' } });
|
|
@@ -9,7 +9,7 @@ const destination = url.replace(/^https?:\/\//, '').replace(/\.dest$/, '');
|
|
|
9
9
|
const logPath = process.env.FAKE_LOG;
|
|
10
10
|
function log(entry) { if (logPath) appendFileSync(logPath, `${JSON.stringify({ destination, ...entry })}\n`); }
|
|
11
11
|
function reply(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); }
|
|
12
|
-
function requestADTThroughProxy(targetUrl, proxyUrl, { method = 'GET', body = '' } = {}) {
|
|
12
|
+
function requestADTThroughProxy(targetUrl, proxyUrl, { method = 'GET', body = '', bypassProxyForLoopback = false } = {}) {
|
|
13
13
|
const target = new URL(targetUrl);
|
|
14
14
|
const proxy = new URL(proxyUrl);
|
|
15
15
|
if (target.protocol !== 'http:') throw new Error('fixture ADT request supports HTTP only');
|
|
@@ -18,7 +18,7 @@ function requestADTThroughProxy(targetUrl, proxyUrl, { method = 'GET', body = ''
|
|
|
18
18
|
if (process.env.SAP_USER && process.env.SAP_PASSWORD) {
|
|
19
19
|
headers.authorization = `Basic ${Buffer.from(`${process.env.SAP_USER}:${process.env.SAP_PASSWORD}`).toString('base64')}`;
|
|
20
20
|
}
|
|
21
|
-
const loopback = ['127.0.0.1', 'localhost'].includes(target.hostname);
|
|
21
|
+
const loopback = bypassProxyForLoopback && ['127.0.0.1', 'localhost'].includes(target.hostname);
|
|
22
22
|
return new Promise((resolve, reject) => {
|
|
23
23
|
const requestOptions = loopback ? {
|
|
24
24
|
hostname: target.hostname,
|
|
@@ -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'
|
|
@@ -248,7 +251,7 @@ process.stdin.on('data', async chunk => {
|
|
|
248
251
|
try {
|
|
249
252
|
const target = new URL('/sap/bc/adt/datapreview/freestyle', url);
|
|
250
253
|
const proxy = process.env.HTTP_PROXY || process.env.http_proxy;
|
|
251
|
-
const adt = await requestADTThroughProxy(target.href, proxy, { method: 'POST', body: '<probe/>' });
|
|
254
|
+
const adt = await requestADTThroughProxy(target.href, proxy, { method: 'POST', body: '<probe/>', bypassProxyForLoopback: true });
|
|
252
255
|
reply(message.id, { content: [{ type: 'text', text: `fixture ADT HTTP ${adt.status}: ${adt.body}` }], isError: adt.status < 200 || adt.status >= 300 });
|
|
253
256
|
} catch {
|
|
254
257
|
reply(message.id, { content: [{ type: 'text', text: 'fixture ADT write failed' }], isError: true });
|
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)}`);
|