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.
Files changed (38) hide show
  1. package/.github/agents/abap-developer.agent.md +1 -1
  2. package/.github/agents/abap-runtime-debugger.agent.md +1 -1
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
  4. package/.github/agents/rap-service-developer.agent.md +1 -1
  5. package/.github/agents/sap-solution-architect.agent.md +1 -1
  6. package/.github/skills/abap-debugging/SKILL.md +1 -1
  7. package/.github/skills/abap-development/SKILL.md +1 -1
  8. package/.github/skills/abap-runtime-analysis/SKILL.md +1 -1
  9. package/.github/skills/abap-testing-quality/SKILL.md +1 -1
  10. package/.github/skills/cds-development/SKILL.md +1 -1
  11. package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
  12. package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
  13. package/.github/skills/hana-cloud-native-development/SKILL.md +1 -1
  14. package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
  15. package/.github/skills/rap-development/SKILL.md +1 -1
  16. package/.github/skills/rap-service-delivery/SKILL.md +1 -1
  17. package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
  18. package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
  19. package/.github/skills/sap-transport-release/SKILL.md +1 -1
  20. package/README.md +21 -14
  21. package/package.json +1 -1
  22. package/scripts/postinstall.mjs +3 -2
  23. package/src/bas-destination-relay.mjs +48 -22
  24. package/src/bas-discovery.mjs +16 -6
  25. package/src/launcher.mjs +24 -14
  26. package/src/mcp-config.mjs +36 -7
  27. package/src/mcp-proxy.mjs +185 -51
  28. package/src/setup.mjs +3 -2
  29. package/test/bas-destination-relay.test.mjs +93 -14
  30. package/test/cf-runtime.test.mjs +2 -2
  31. package/test/discovery.test.mjs +45 -0
  32. package/test/fixtures/fake-vsp.mjs +6 -3
  33. package/test/launcher.test.mjs +15 -13
  34. package/test/live-s4h.test.mjs +13 -11
  35. package/test/mcp-config-cf.test.mjs +127 -15
  36. package/test/mcp-proxy.test.mjs +232 -33
  37. package/test/setup-cf.test.mjs +7 -7
  38. 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 detail = entry.env?.BAS_VSP_DESTINATION
171
- ? `destination: ${entry.env.BAS_VSP_DESTINATION}`
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
- const valid = match
66
- && cookie.includes(`sap-session-${match[1]}=`)
67
- && (!rotateOnFetch || Number(match[1]) === state.sessionCounter)
68
- && (!partialRefreshCookies || cookie.includes('sap-usercontext=sap-client=100'))
69
- && (!partialRefreshCookies || Number(match[1]) === 1 || !cookie.includes('sap-stale='));
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('does not repeat a CSRF failure when SAP returns a token without session cookies', async () => {
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, 503, `unsafe calls must fail closed without a paired session, got ${response.status}`);
197
- assert.equal(state.requests.filter(request => request.method === 'POST').length, 0);
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: 2, sessionUsable: true });
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.ok(connectTargets.length >= 2);
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);
@@ -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: 'pp-target__GetSystemInfo', arguments: {} } }
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: 'basic-onprem__GetSystemInfo', arguments: {} } }
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}`);
@@ -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 });
@@ -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/datapreview/freestyle' && request.csrf === 'Fetch'));
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 has no session cookie', async t => {
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/datapreview/freestyle') {
249
- response.writeHead(200, { 'x-csrf-token': 'orphan-token' });
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/datapreview/freestyle' && request.csrf === 'Fetch'), true);
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
 
@@ -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.toLowerCase().replace(/[^a-z0-9_-]+/gu, '-');
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, DESTINATION);
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}LintABAP`,
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}RunQuery`,
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}GetApplicationLog`,
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)}`);