sap-ai-dev-toolkit 0.4.2 → 0.4.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/README.md CHANGED
@@ -538,7 +538,9 @@ For a transport report, include exactly one `GetTransport` check whose `transpor
538
538
 
539
539
  For an ABAP Cloud assessment, get object URIs from `SearchObject` and pass them to `PlanABAPCloudMigration`. It reports release evidence; it does not guess replacement APIs. Save the JSON from `GenerateRAPRegressionSuite` and pass it to `RunRAPRegressionSuite` after relevant service changes.
540
540
 
541
- `sap-ai-dev-toolkit --doctor` probes each discovered destination, starts VSP, calls `GetSystemInfo` when available, and lists MCP tools. `--doctor --json` prints redacted results and exits unsuccessfully when a required check fails.
541
+ `sap-ai-dev --doctor` probes each discovered destination, checks that each managed MCP entry still matches its selected BAS destination, starts VSP, lists MCP tools, and preflights `/sap/bc/adt/datapreview/freestyle` through the runtime relay before calling `GetSystemInfo`. It repairs only tagged toolkit-owned BAS entries that can be verified against complete destination discovery; it does not add destinations, remove stale or third-party entries, or overwrite malformed configuration. `--doctor --json` reports redacted session diagnostics (HTTP status, cookie count, retry/fallback counters; never token or cookie values) and exits unsuccessfully when a required check fails.
542
+
543
+ `tools/list` confirms what this MCP server exposes; it does not prove that VS Code attached the server to the active chat. Start the selected destination in **MCP: List Servers**, enable it in the Chat tools picker, and reload/reselect the agent if the host binding is stale. This add-on cannot inspect or repair another MCP server's registration or a host-wide tool budget.
542
544
 
543
545
  `sap-ai-dev-toolkit --demo` starts an isolated server with sample ABAP objects, test and ATC results, a sample transport, and synthetic OData metadata and rows. Source edits and transport creation stay in process memory and disappear on exit. The demo does not discover or contact BAS or SAP destinations.
544
546
 
@@ -727,29 +729,29 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
727
729
  | `SAP_AI_DEV_TOOLKIT_MODE` | VSP child mode (`expert` by default; `focused` omits `ActivateMultiple`, `GetUserTransports`, and `GetTransportInfo`). The proxy exposes its curated tool subset and local workflow tools, including `LintABAP`. |
728
730
  | `SAP_ALLOW_TRANSPORTABLE_EDITS` | Generated MCP entries set this to `true` to permit source edits in transportable packages; VSP safety checks and SAP authorizations still apply. |
729
731
  | `SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY=true` | Disable the built-in BAS destination relay. By default the add-on self-heals `.dest` destinations through a local relay that keeps all access destination-based while handling ADT CSRF fetch/retry behavior before VSP calls SAP. |
730
- | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic (falls back to `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct except for OnPremise credential overrides, which require a BAS proxy tunnel). |
731
- | `SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES` | Bounded CSRF/session self-healing retries per unsafe request (default 3). |
732
+ | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct except for OnPremise credential overrides, which require a BAS proxy tunnel. |
733
+ | `SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES` | Bounded retries after explicit CSRF-session rejection per unsafe request (default 3, maximum 10). Invalid values use the default. |
734
+ | `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
735
+ | `SAP_AI_DEV_TOOLKIT_MCP_CONFIG` | Branded compatibility alias for the MCP configuration path. |
736
+ | `BAS_VSP_MCP_CONFIG` | Backward-compatible alias for the MCP configuration path. |
737
+ | `BAS_VSP_BINARY` | Trusted prebuilt VSP executable; skips Go and binary provisioning. |
738
+ | `BAS_VSP_BINARY_URL` | Alternate VSP binary download URL. |
739
+ | `BAS_VSP_CACHE_DIR` | Binary cache directory. |
740
+ | `GO_BINARY` | Explicit Go executable used for provisioning when automatic Go installation is unavailable. |
741
+ | `SAP_AI_DEV_TOOLKIT_SKIP_PROBE=true` | Skip destination probes; useful for controlled diagnostics or fixtures. |
742
+ | `HTTP_PROXY` / `HTTPS_PROXY` | BAS proxy settings used for destination-list requests, destination probing, and child processes. |
743
+ | `NO_PROXY` | Proxy bypass list for BAS destination-list requests; `.dest` hosts remain routed through the BAS proxy. |
732
744
 
733
745
  #### Self-healing modes
734
746
 
735
747
  The relay between the VSP child and each BAS destination recovers from the failure modes that break ADT writes over `.dest` proxies:
736
748
 
737
- 1. **CSRF session pairing** — every token is stored together with the `Set-Cookie` state SAP returned alongside it, and both are replayed on POST/PUT/PATCH/DELETE. This fixes `403 CSRF token validation failed` caused by token/session separation.
738
- 2. **Session refresh** — when SAP rejects or rotates a session (401/403 after a token was already accepted), the cached session is dropped, a fresh token+cookie pair is fetched, and the request retried, bounded by the retry limit.
749
+ 1. **CSRF session pairing** — a scoped cookie jar retains session cookies received during safe reads and token fetches, applies updates and expirations, and replays the matching token/cookie pair on POST/PUT/PATCH/DELETE. If SAP returns a token without a usable session cookie, the relay fails closed and does not send the unsafe request.
750
+ 2. **Session refresh** — when SAP explicitly rejects a CSRF token, the cached session is dropped, a fresh token+cookie pair is fetched, and the request is retried within the configured bound. Generic authorization 401/403 responses are returned without being mislabeled or retried as CSRF failures.
739
751
  3. **Proxy tunnel fallback** — when the BAS proxy refuses absolute-form requests (502/504 or transport errors), the relay switches to a CONNECT tunnel through the same proxy and keeps going.
740
752
  4. **Child crash recovery** — a crashed VSP child is restarted transparently, re-initialized, tools re-registered, and the interrupted `tools/call` retried once before any error reaches the client.
741
753
  5. **Direct connect for Internet destinations** — the BAS `.dest` proxy strips SAP `Set-Cookie` headers, which makes CSRF token/session binding impossible for ADT writes (`403 CSRF token validation failed`). When you select an Internet destination with BasicAuthentication, setup asks whether to override its credentials. A Yes prompts for SAP user/password and stores them in `sap-ai-dev-toolkit-credentials.json` **next to** `mcp.json` (never inside it, permissions `0600`). The relay connects straight to the backend host so cookies survive and ADT writes can use the paired CSRF session.
742
754
  6. **Cloud Connector credential overrides** — setup also offers per-destination overrides for BasicAuthentication OnPremise destinations. BAS OnPremise credentials travel through an HTTP CONNECT tunnel to the BAS `.dest` endpoint, retaining its Cloud Connector mapping; setup saves the override only after a read-only probe returns both a CSRF token and a session cookie. If that route check fails, the override is not saved. Cloud Foundry OnPremise overrides replace only the SAP backend user/password; Connectivity service-key authentication and its proxy route remain unchanged. PrincipalPropagation is never overridden. Rerun `sap-ai-dev --setup` to change credentials or answer No to disable a stored override. Deselecting a destination (or selecting none) removes stale overrides.
743
- | `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP user configuration path. |
744
- | `BAS_VSP_MCP_CONFIG` | Backward-compatible alias for the MCP user configuration path. |
745
- | `BAS_VSP_BINARY` | Trusted prebuilt VSP executable; skips Go and binary provisioning. |
746
- | `BAS_VSP_BINARY_URL` | Alternate VSP binary download URL. |
747
- | `BAS_VSP_CACHE_DIR` | Binary cache directory. |
748
- | `GO_BINARY` | Explicit Go executable used for provisioning when automatic Go installation is unavailable. |
749
- | `SAP_AI_DEV_TOOLKIT_SKIP_PROBE=true` | Skip destination probes; useful for controlled diagnostics or fixtures. |
750
- | `HTTP_PROXY` / `HTTPS_PROXY` | BAS proxy settings used for destination-list requests, destination probing, and child processes. |
751
- | `NO_PROXY` | Proxy bypass list for BAS destination-list requests; `.dest` hosts remain routed through the BAS proxy. |
752
-
753
755
  Existing installs that still set the previous `BAS_VSP_*` environment variables remain supported. The setup wizard writes new MCP entries with the `SAP_AI_DEV_TOOLKIT_*` names.
754
756
 
755
757
  <a id="troubleshooting"></a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
@@ -12,6 +12,7 @@
12
12
  "@modelcontextprotocol/sdk": "1.30.1",
13
13
  "@sap/hana-client": "2.30.27",
14
14
  "terminal-kit": "^3.1.4",
15
+ "tough-cookie": "^5.1.2",
15
16
  "undici": "^6.21.3",
16
17
  "zod": "4.6.5"
17
18
  },
@@ -1,5 +1,7 @@
1
1
  import http from 'node:http';
2
2
  import { fetch as undiciFetch, ProxyAgent } from 'undici';
3
+ import { CookieJar } from 'tough-cookie';
4
+ import { brandedEnvValue } from './branding.mjs';
3
5
 
4
6
  const DEFAULT_PROXY = 'http://127.0.0.1:8887';
5
7
  const MAX_BODY_BYTES = 64 * 1024 * 1024;
@@ -14,7 +16,8 @@ const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
14
16
  // request. Without this pairing SAP sees a token from a foreign session and
15
17
  // answers "CSRF token validation failed" with HTTP 403.
16
18
  const TOKEN_TTL_MS = 15 * 60 * 1000;
17
- const MAX_TOKEN_FAILURES = 3;
19
+ const DEFAULT_MAX_TOKEN_FAILURES = 3;
20
+ const MAX_CONFIGURED_TOKEN_FAILURES = 10;
18
21
  const MAX_TUNNEL_FALLBACKS = 2;
19
22
  // Carrying a POST's content-length on the token-fetch GET would make the
20
23
  // proxy wait for a body that never arrives, so the relay drops body headers.
@@ -63,23 +66,49 @@ function csrfFailure(status, headers, body) {
63
66
  );
64
67
  }
65
68
 
66
- function authFailure(status) {
67
- return status === 401 || status === 403;
69
+ function maxTokenFailures(env, log, destinationName) {
70
+ const configured = brandedEnvValue(env, 'MAX_CSRF_RETRIES');
71
+ if (configured === undefined || configured === '') return DEFAULT_MAX_TOKEN_FAILURES;
72
+ const parsed = Number(configured);
73
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
74
+ log(`[${destinationName}] invalid CSRF retry setting; using the default of ${DEFAULT_MAX_TOKEN_FAILURES}`);
75
+ return DEFAULT_MAX_TOKEN_FAILURES;
76
+ }
77
+ return Math.min(parsed, MAX_CONFIGURED_TOKEN_FAILURES);
78
+ }
79
+
80
+ function copyCookieJar(jar) {
81
+ return CookieJar.deserializeSync(jar.serializeSync());
68
82
  }
69
83
 
70
- function cookieHeader(jar) {
71
- return jar.size ? [...jar].map(([name, value]) => `${name}=${value}`).join('; ') : undefined;
84
+ async function cookieHeader(jar, target) {
85
+ return await jar.getCookieString(target.href) || undefined;
72
86
  }
73
87
 
74
- function recordCookies(jar, headers) {
75
- for (const value of headers.getSetCookie?.() || []) {
76
- const [pair] = String(value).split(';');
88
+ async function seedRequestCookies(jar, requestCookie, target) {
89
+ for (const pair of String(requestCookie || '').split(';')) {
77
90
  const separator = pair.indexOf('=');
78
91
  if (separator <= 0) continue;
79
92
  const name = pair.slice(0, separator).trim();
93
+ const value = pair.slice(separator + 1).trim();
80
94
  if (!name) continue;
81
- if (/expires=thu, 01 jan 1970/i.test(String(value))) jar.delete(name);
82
- else jar.set(name, pair.slice(separator + 1).trim());
95
+ try {
96
+ await jar.setCookie(`${name}=${value}; Path=/`, target.href, { ignoreError: true });
97
+ } catch {
98
+ // Invalid caller-provided cookie fragments are ignored; SAP response
99
+ // cookies remain authoritative for the destination session.
100
+ }
101
+ }
102
+ }
103
+
104
+ async function recordCookies(jar, headers, target, log, destinationName) {
105
+ const values = headers.getSetCookie?.() || (headers.get('set-cookie') ? [headers.get('set-cookie')] : []);
106
+ for (const value of values) {
107
+ try {
108
+ await jar.setCookie(String(value), target.href, { ignoreError: true });
109
+ } catch {
110
+ log(`[${destinationName}] BAS relay ignored an invalid Set-Cookie header`);
111
+ }
83
112
  }
84
113
  }
85
114
 
@@ -104,6 +133,7 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
104
133
  // must keep the BAS .dest route (and its Cloud Connector mapping), so they
105
134
  // use HTTP CONNECT through the BAS proxy instead of credentials.host.
106
135
  const directBase = directHost ? new URL(String(directHost)) : null;
136
+ const cookieBase = directBase || (destination.backendUrl ? new URL(String(destination.backendUrl)) : base);
107
137
  if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
108
138
  if (tunnelMode) log(`[${destination.name}] BAS relay using a BAS proxy tunnel for the OnPremise destination`);
109
139
  // Proxy resolution: an explicitly configured proxy wins (an explicit empty
@@ -111,7 +141,8 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
111
141
  // imply the default BAS proxy; any other host (tests, direct URLs) is
112
142
  // reached directly so the relay never routes loopback traffic through a
113
143
  // foreign proxy.
114
- const explicitProxy = env.HTTP_PROXY ?? env.http_proxy;
144
+ const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
145
+ const explicitProxy = brandedProxy !== undefined ? brandedProxy : (env.HTTP_PROXY ?? env.http_proxy);
115
146
  const proxyUrl = directBase ? '' : (explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : ''));
116
147
  if (tunnelMode && !proxyUrl) throw new Error('OnPremise credential overrides require the BAS destination proxy to preserve the Cloud Connector route');
117
148
  const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
@@ -121,15 +152,17 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
121
152
  const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
122
153
  let tunneled = tunnelMode;
123
154
  // Observable self-healing counters, surfaced by --doctor and tests.
124
- const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, tunnelFallbacks: 0, direct: Boolean(directBase), proxyTunnel: tunnelMode };
125
- // Per-path CSRF sessions: { token, jar, fetchedAt } where jar holds the
126
- // Set-Cookie state SAP returned together with the token.
155
+ const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct: Boolean(directBase), proxyTunnel: tunnelMode };
156
+ const maxFailures = maxTokenFailures(env, log, destination.name);
157
+ // The shared jar captures SAP cookies from safe reads as well as CSRF fetches.
158
+ // Each cached token gets its own snapshot so later responses cannot silently
159
+ // pair an old token with a different session.
160
+ const sharedCookieJar = new CookieJar();
127
161
  const tokenCache = new Map();
162
+ const tokenFetches = new Map();
128
163
 
129
- function rememberSession(key, token, headers, httpStatus) {
130
- const jar = new Map();
131
- recordCookies(jar, headers);
132
- tokenCache.set(key, { token, jar, fetchedAt: Date.now(), httpStatus });
164
+ function resolveCookieTarget(target) {
165
+ return new URL(`${target.pathname}${target.search}`, cookieBase);
133
166
  }
134
167
 
135
168
  async function send(target, method, headers, body) {
@@ -158,23 +191,48 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
158
191
  return send(target, method, headers, body);
159
192
  }
160
193
 
161
- async function fetchToken(requestUrl, requestHeaders) {
194
+ async function fetchTokenFresh(requestUrl, requestHeaders) {
162
195
  const key = requestUrl.pathname;
163
- const cached = tokenCache.get(key);
164
- if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
165
196
  stats.csrfFetches += 1;
166
- const response = await sendWithFallback(resolveTarget(requestUrl), 'GET', authHeaders(fetchHeaders(requestHeaders, {
197
+ const target = resolveTarget(requestUrl);
198
+ const cookieTarget = resolveCookieTarget(target);
199
+ const jar = copyCookieJar(sharedCookieJar);
200
+ await seedRequestCookies(jar, requestHeaders.cookie, cookieTarget);
201
+ const outgoingCookie = await cookieHeader(jar, cookieTarget);
202
+ const response = await sendWithFallback(target, 'GET', authHeaders(fetchHeaders(requestHeaders, {
167
203
  'x-csrf-token': 'Fetch',
168
204
  accept: requestHeaders.accept || 'application/xml,*/*',
169
- ...(cached?.jar?.size ? { cookie: cookieHeader(cached.jar) } : {})
205
+ ...(outgoingCookie ? { cookie: outgoingCookie } : {})
170
206
  })));
171
207
  await response.arrayBuffer().catch(() => undefined);
172
208
  const token = response.headers.get('x-csrf-token');
173
- if (token && token.toLowerCase() !== 'required') {
174
- rememberSession(key, token, response.headers, response.status);
175
- return tokenCache.get(key);
209
+ await recordCookies(jar, response.headers, cookieTarget, log, destination.name);
210
+ await recordCookies(sharedCookieJar, response.headers, cookieTarget, log, destination.name);
211
+ const cookies = await jar.getCookies(cookieTarget.href);
212
+ const session = {
213
+ token: response.ok && token && token.toLowerCase() !== 'required' ? token : null,
214
+ jar,
215
+ fetchedAt: Date.now(),
216
+ httpStatus: response.status,
217
+ cookieCount: cookies.length
218
+ };
219
+ if (session.token) tokenCache.set(key, session);
220
+ return session;
221
+ }
222
+
223
+ async function fetchToken(requestUrl, requestHeaders = {}) {
224
+ const key = requestUrl.pathname;
225
+ const cached = tokenCache.get(key);
226
+ if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
227
+ const pending = tokenFetches.get(key);
228
+ if (pending) return pending;
229
+ const fetching = fetchTokenFresh(requestUrl, requestHeaders);
230
+ tokenFetches.set(key, fetching);
231
+ try {
232
+ return await fetching;
233
+ } finally {
234
+ if (tokenFetches.get(key) === fetching) tokenFetches.delete(key);
176
235
  }
177
- return null;
178
236
  }
179
237
 
180
238
  async function probeCsrfSession(path = '/sap/bc/adt/discovery') {
@@ -183,7 +241,8 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
183
241
  return {
184
242
  httpStatus: session?.httpStatus || 0,
185
243
  tokenReceived: Boolean(session?.token),
186
- cookieCount: session?.jar?.size || 0
244
+ cookieCount: session?.cookieCount || 0,
245
+ sessionUsable: Boolean(session?.token && session.cookieCount > 0)
187
246
  };
188
247
  }
189
248
 
@@ -201,13 +260,17 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
201
260
  }
202
261
 
203
262
  async function forward(method, requestUrl, requestHeaders, body, session) {
263
+ const target = resolveTarget(requestUrl);
264
+ const cookieTarget = resolveCookieTarget(target);
265
+ const sessionCookie = session?.jar ? await cookieHeader(session.jar, cookieTarget) : undefined;
204
266
  const headers = authHeaders(headersFrom(requestHeaders, {
205
267
  ...(session?.token ? { 'x-csrf-token': session.token } : {}),
206
- ...(session?.jar?.size ? { cookie: cookieHeader(session.jar) } : {})
268
+ ...(sessionCookie ? { cookie: sessionCookie } : {})
207
269
  }));
208
- const response = await sendWithFallback(resolveTarget(requestUrl), method, headers, body);
270
+ const response = await sendWithFallback(target, method, headers, body);
209
271
  const responseBody = Buffer.from(await response.arrayBuffer());
210
- if (session) recordCookies(session.jar, response.headers);
272
+ if (session) await recordCookies(session.jar, response.headers, cookieTarget, log, destination.name);
273
+ await recordCookies(sharedCookieJar, response.headers, cookieTarget, log, destination.name);
211
274
  return { response, body: responseBody };
212
275
  }
213
276
 
@@ -218,15 +281,27 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
218
281
  async function forwardWithRetry(method, requestUrl, requestHeaders, body, failures = 0) {
219
282
  const unsafe = UNSAFE.has(method);
220
283
  const session = unsafe ? await fetchToken(requestUrl, requestHeaders) : null;
221
- if (unsafe && !session) log(`[${destination.name}] BAS relay could not obtain CSRF token for ${requestUrl.pathname}`);
284
+ if (unsafe && (!session?.token || !session.cookieCount)) {
285
+ stats.csrfSessionFailures += 1;
286
+ const detail = !session?.token
287
+ ? 'could not obtain a CSRF token'
288
+ : 'received a CSRF token without a session cookie';
289
+ const message = `BAS destination relay ${detail} for ${requestUrl.pathname}; no unsafe request was sent`;
290
+ log(`[${destination.name}] ${message}`);
291
+ const response = new Response(message, { status: 503, headers: { 'content-type': 'text/plain; charset=utf-8' } });
292
+ return { response, body: Buffer.from(message) };
293
+ }
222
294
  const { response, body: responseBody } = await forward(method, requestUrl, requestHeaders, body, session);
223
- const rejected = csrfFailure(response.status, response.headers, responseBody) || (authFailure(response.status) && session);
224
- if (unsafe && rejected && failures < MAX_TOKEN_FAILURES) {
225
- log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${MAX_TOKEN_FAILURES})`);
295
+ const rejected = csrfFailure(response.status, response.headers, responseBody);
296
+ if (unsafe && rejected && failures < maxFailures) {
297
+ log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${maxFailures})`);
226
298
  stats.csrfRetries += 1;
227
299
  tokenCache.delete(requestUrl.pathname);
228
300
  return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
229
301
  }
302
+ if (unsafe && rejected && failures >= maxFailures) {
303
+ log(`[${destination.name}] BAS relay stopped after ${maxFailures} CSRF session retries for ${requestUrl.pathname}`);
304
+ }
230
305
  return { response, body: responseBody };
231
306
  }
232
307
 
@@ -120,7 +120,8 @@ function proxyBypasses(url, noProxy) {
120
120
 
121
121
  function proxyDispatcher(target, env) {
122
122
  const url = new URL(target);
123
- const httpProxy = env.http_proxy ?? env.HTTP_PROXY ?? '';
123
+ const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
124
+ const httpProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.http_proxy ?? env.HTTP_PROXY ?? '');
124
125
  const httpsProxy = env.https_proxy ?? env.HTTPS_PROXY ?? '';
125
126
  const proxy = url.protocol === 'https:' ? httpsProxy || httpProxy : httpProxy;
126
127
  const noProxy = env.no_proxy ?? env.NO_PROXY ?? '';
@@ -182,7 +183,7 @@ export async function probeADT(destination, options = {}) {
182
183
  const url = `${destinationUrl(destination.name)}/sap/bc/adt/discovery`;
183
184
  if (options.skipProbe) return { status: 'skipped', available: true, url };
184
185
  try {
185
- const result = options.probeImpl ? await options.probeImpl(url, options) : await requestProbe(url, options.proxyUrl || DEFAULT_PROXY, options.timeoutMs || 5000);
186
+ const result = options.probeImpl ? await options.probeImpl(url, options) : await requestProbe(url, options.proxyUrl ?? DEFAULT_PROXY, options.timeoutMs || 5000);
186
187
  if ([401, 403].includes(result.status) || (result.status >= 200 && result.status < 300)) return { status: result.status === 401 || result.status === 403 ? 'auth-required' : 'available', available: true, httpStatus: result.status, url };
187
188
  if (result.status === 404) return { status: 'not-found', available: false, httpStatus: result.status, url };
188
189
  return { status: 'unavailable', available: false, httpStatus: result.status, url };
@@ -198,7 +199,9 @@ export async function discoverDestinations(options = {}) {
198
199
  if (!list) throw new Error(`Unrecognized BAS destination response shape; top-level keys: ${Object.keys(body || {}).map(String).join(', ') || '<none>'}`);
199
200
  const allow = text(brandedEnvValue(env, 'DESTINATION')).split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
200
201
  const normalized = list.map(normalizeDestination).filter(item => item.name).filter(item => !allow.length || allow.includes(item.name.toLowerCase()));
201
- const proxyUrl = env.HTTP_PROXY || env.http_proxy || DEFAULT_PROXY;
202
+ const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
203
+ const configuredProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.HTTP_PROXY ?? env.http_proxy);
204
+ const proxyUrl = configuredProxy !== undefined ? configuredProxy : DEFAULT_PROXY;
202
205
  const skipProbe = String(brandedEnvValue(env, 'SKIP_PROBE') || '').toLowerCase() === 'true' || options.skipProbe;
203
206
  const result = [];
204
207
  for (const destination of normalized.sort((a, b) => a.name.localeCompare(b.name))) {
package/src/launcher.mjs CHANGED
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { discoverDestinations, remediation, statusRows } from './bas-discovery.mjs';
7
7
  import { findBinary } from './binary.mjs';
8
8
  import { MCPProxy } from './mcp-proxy.mjs';
9
- import { installMcpConfig } from './mcp-config.mjs';
9
+ import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
10
10
  import { runSetup } from './setup.mjs';
11
11
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
12
12
  import { withBrandedEnvironment } from './branding.mjs';
@@ -30,7 +30,7 @@ function usage() {
30
30
  ' --list-destinations List discovered BAS destinations',
31
31
  ' --list-destinations --json Print redacted JSON status',
32
32
  ' --check Probe destination availability',
33
- ' --doctor Check destination, VSP, SAP, and MCP connectivity',
33
+ ' --doctor Check SAP/MCP connectivity and repair safe toolkit config drift',
34
34
  ' --doctor --json Print redacted JSON diagnostics',
35
35
  ' --demo Start an isolated, offline SAP MCP playground',
36
36
  ' --help, -h Show this help and exit',
@@ -58,6 +58,16 @@ async function runDoctor(destinations) {
58
58
  try { binary = await binaryOrError(); }
59
59
  catch (error) { binaryError = error.message; }
60
60
 
61
+
62
+ try {
63
+ const repair = await repairManagedMcpConfig(destinations, { env: runtimeEnv, discoveryComplete: true });
64
+ const detail = repair.skipped
65
+ ? repair.skipped
66
+ : `${repair.repaired} managed BAS entr${repair.repaired === 1 ? 'y' : 'ies'} repaired; ${repair.managedEntries || 0} managed BAS entries recognized`;
67
+ checks.push(doctorRow('-', 'MCP config repair', 'passed', detail));
68
+ } catch (error) {
69
+ checks.push(doctorRow('-', 'MCP config repair', 'failed', redactText(error.message || error).slice(0, 300)));
70
+ }
61
71
  for (const destination of destinations) {
62
72
  const probe = destination.probe;
63
73
  const probeStatus = !probe || probe.status === 'skipped' ? 'skipped' : (probe.available === true ? 'passed' : 'failed');
@@ -78,7 +88,7 @@ async function runDoctor(destinations) {
78
88
  checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
79
89
  const relayedDestination = proxy.children[0]?.destination;
80
90
  if (relayedDestination?.relay) {
81
- checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); CSRF token/session pairing active`));
91
+ checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); runtime session preflight pending`));
82
92
  } else if (destination.source === 'cloud-foundry') {
83
93
  checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Cloud Foundry destinations connect through the connectivity proxy directly.'));
84
94
  } else if (String(runtimeEnv.SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY || '').toLowerCase() === 'true') {
@@ -96,14 +106,37 @@ async function runDoctor(destinations) {
96
106
  const tools = listed.result?.tools || [];
97
107
  const localNames = new Set(['LintABAP', 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration', 'GenerateRAPRegressionSuite', 'RunRAPRegressionSuite']);
98
108
  const upstreamCount = tools.filter(tool => !localNames.has(tool.name.slice(tool.name.indexOf('__') + 2))).length;
99
- checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} VSP tools available`));
109
+ checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} VSP tools and ${tools.length} total MCP tools returned; chat-picker binding is host-managed`));
100
110
  const systemInfo = tools.find(tool => tool.name.endsWith('__GetSystemInfo'));
101
111
  if (!systemInfo) {
102
112
  checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
103
113
  } else {
104
- const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
105
- const failed = inspected?.error || inspected?.result?.isError;
106
- checks.push(doctorRow(destination.name, 'SAP system check', failed ? 'failed' : 'passed', failed ? redactText(inspected?.error?.message || inspected?.result?.content?.[0]?.text || 'GetSystemInfo failed') : 'GetSystemInfo returned successfully'));
114
+ let sessionReady = true;
115
+ if (relayedDestination?.relay?.probeCsrfSession) {
116
+ let session;
117
+ let preflightError;
118
+ try {
119
+ session = await relayedDestination.relay.probeCsrfSession('/sap/bc/adt/datapreview/freestyle');
120
+ } catch (error) {
121
+ preflightError = redactText(error.message || error).slice(0, 250);
122
+ }
123
+ sessionReady = session?.sessionUsable === true;
124
+ const status = sessionReady ? 'passed' : 'failed';
125
+ const stats = relayedDestination.relay.stats || {};
126
+ const detail = preflightError
127
+ ? `preflight request failed: ${preflightError}`
128
+ : `HTTP ${session?.httpStatus || 0}; token=${session?.tokenReceived ? 'received' : 'missing'}; cookies=${session?.cookieCount || 0}; usableSession=${sessionReady}; csrfFetches=${stats.csrfFetches || 0}; csrfRetries=${stats.csrfRetries || 0}; sessionFailures=${stats.csrfSessionFailures || 0}; tunnelFallbacks=${stats.tunnelFallbacks || 0}`;
129
+ checks.push(doctorRow(destination.name, 'BAS relay CSRF preflight', status, detail));
130
+ if (!sessionReady) {
131
+ const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session. For OnPremise BasicAuth, run sap-ai-dev --setup and use a validated BAS proxy tunnel credential override.';
132
+ checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', preflightError ? `${remediation} Relay error: ${preflightError}` : remediation));
133
+ }
134
+ }
135
+ if (sessionReady) {
136
+ const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
137
+ const failed = inspected?.error || inspected?.result?.isError;
138
+ checks.push(doctorRow(destination.name, 'SAP system check', failed ? 'failed' : 'passed', failed ? redactText(inspected?.error?.message || inspected?.result?.content?.[0]?.text || 'GetSystemInfo failed') : 'GetSystemInfo returned successfully'));
139
+ }
107
140
  }
108
141
  } catch (error) {
109
142
  const detail = redactText(error.message || error).slice(0, 300);
@@ -82,7 +82,8 @@ export async function resolveMcpServerCommand(env = process.env) {
82
82
  }
83
83
 
84
84
  export async function resolveMcpConfigPath(env = process.env) {
85
- const configuredPath = brandedEnvValue(env, 'MCP_CONFIG');
85
+ const configuredPath = [env.SAP_AI_DEV_MCP_CONFIG, brandedEnvValue(env, 'MCP_CONFIG')]
86
+ .find(value => typeof value === 'string' && value.length > 0);
86
87
  if (configuredPath) return configuredPath;
87
88
  const home = env.HOME || homedir();
88
89
  const candidates = [
@@ -204,6 +205,22 @@ function destinationSource(env) {
204
205
  return env?.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE ?? env?.BAS_VSP_DESTINATION_SOURCE;
205
206
  }
206
207
 
208
+ function isManagedBasDestinationEntry(entry) {
209
+ const currentCommands = new Set(['sap-ai-dev', 'sap-ai-dev-toolkit']);
210
+ const commandName = typeof entry?.command === 'string'
211
+ ? entry.command.slice(Math.max(entry.command.lastIndexOf('/'), entry.command.lastIndexOf('\\')) + 1).replace(/\.(?:cmd|exe)$/i, '')
212
+ : '';
213
+ const currentCommand = currentCommands.has(entry?.command) || currentCommands.has(commandName);
214
+ const currentNpxLauncher = entry?.command === 'npx'
215
+ && Array.isArray(entry.args)
216
+ && entry.args.some(argument => currentCommands.has(argument))
217
+ && entry.args.some(argument => typeof argument === 'string' && /^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/.test(argument));
218
+ return entry?.BAS_EXT === 'true'
219
+ && (currentCommand || currentNpxLauncher)
220
+ && typeof destinationValue(entry?.env) === 'string'
221
+ && destinationSource(entry.env) === undefined;
222
+ }
223
+
207
224
  function collectCloudFoundryKeyReferences(config, managedOnly) {
208
225
  const refs = new Map();
209
226
  for (const entry of Object.values(config?.servers || {})) {
@@ -292,7 +309,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
292
309
  && typeof destinationValue(entry?.env) === 'string'
293
310
  && (destinationSource(entry.env) === 'cloud-foundry'
294
311
  ? typeof entry.env.BAS_CF_DESTINATION_KEY === 'string'
295
- : entry.env.BAS_VSP_DESTINATION_SOURCE === undefined);
312
+ : destinationSource(entry.env) === undefined);
296
313
  if (!LEGACY_MCP_SERVER_PREFIXES.some(prefix => name.startsWith(prefix)) && !managed && !isCompanionServer(entry)) servers[name] = entry;
297
314
  }
298
315
  for (const name of Object.keys(generated)) {
@@ -305,6 +322,40 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
305
322
  return { path, servers: generated };
306
323
  }
307
324
 
325
+ export async function repairManagedMcpConfig(discoveredDestinations, { env = process.env, path, discoveryComplete = false } = {}) {
326
+ const configPath = path || await resolveMcpConfigPath(env);
327
+ if (!discoveryComplete) {
328
+ return { path: configPath, changed: false, repaired: 0, skipped: 'Destination discovery was incomplete.' };
329
+ }
330
+ if (!Array.isArray(discoveredDestinations)) throw new Error('Destination discovery result must be an array');
331
+ const basDestinations = new Map(discoveredDestinations
332
+ .filter(destination => destination?.source !== 'cloud-foundry' && destination?.name)
333
+ .map(destination => [String(destination.name), destination]));
334
+ const config = await readConfig(configPath);
335
+ const managedEntries = Object.values(config.servers).filter(isManagedBasDestinationEntry).length;
336
+ if (!basDestinations.size) return { path: configPath, changed: false, repaired: 0, managedEntries };
337
+ let repaired = 0;
338
+ for (const [serverName, entry] of Object.entries(config.servers)) {
339
+ if (!isManagedBasDestinationEntry(entry)) continue;
340
+ const destinationName = destinationValue(entry.env);
341
+ if (generatedServerName(destinationName) !== serverName) continue;
342
+ const destination = basDestinations.get(destinationName);
343
+ if (!destination) continue;
344
+ const expected = buildMcpEntries([destination], env)[serverName];
345
+ const nextEnvironment = { ...entry.env, ...expected.env };
346
+ delete nextEnvironment.BAS_VSP_DESTINATION;
347
+ delete nextEnvironment.BAS_VSP_DESTINATION_SOURCE;
348
+ const nextEntry = { ...entry, type: 'stdio', env: nextEnvironment, BAS_EXT: 'true' };
349
+ if (JSON.stringify(nextEntry) === JSON.stringify(entry)) continue;
350
+ config.servers[serverName] = nextEntry;
351
+ repaired += 1;
352
+ }
353
+
354
+ if (!repaired) return { path: configPath, changed: false, repaired: 0, managedEntries };
355
+ await writeConfig(configPath, config);
356
+ return { path: configPath, changed: true, repaired, managedEntries };
357
+ }
358
+
308
359
  export async function readMcpConfig(path) {
309
360
  return readConfig(path);
310
361
  }
package/src/mcp-proxy.mjs CHANGED
@@ -302,7 +302,10 @@ export class MCPProxy {
302
302
  destination = {
303
303
  ...originalDestination,
304
304
  url: relayUrl,
305
- relay: true,
305
+ relay: {
306
+ probeCsrfSession: relay.probeCsrfSession,
307
+ stats: relay.stats
308
+ },
306
309
  async close() {
307
310
  await originalClose?.();
308
311
  await relay.close();
@@ -11,20 +11,46 @@ const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
11
11
  // request carries BOTH the token and the session cookies that were set
12
12
  // together with it. This reproduces the 403 "CSRF token validation failed"
13
13
  // seen on the BAS .dest proxy path before the relay paired sessions.
14
- function sapLikeBackend({ neverAcceptUnsafe = false, rotateOnFetch = false } = {}) {
14
+ function sapLikeBackend({
15
+ neverAcceptUnsafe = false,
16
+ rotateOnFetch = false,
17
+ genericForbidden = false,
18
+ partialRefreshCookies = false,
19
+ omitCookies = false,
20
+ setCookieOnSafeGet = false
21
+ } = {}) {
15
22
  const state = { sessionCounter: 0, requests: [] };
16
23
  const server = http.createServer((req, res) => {
17
24
  state.requests.push({ method: req.method, path: req.url, headers: { ...req.headers } });
18
25
  if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
19
26
  state.sessionCounter += 1;
20
- res.writeHead(200, {
21
- 'x-csrf-token': `token-${state.sessionCounter}`,
22
- 'set-cookie': [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`, 'sap-usercontext=sap-client=100; path=/']
23
- });
27
+ const headers = { 'x-csrf-token': `token-${state.sessionCounter}` };
28
+ if (!omitCookies) {
29
+ headers['set-cookie'] = [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`];
30
+ if (!partialRefreshCookies || state.sessionCounter === 1) {
31
+ headers['set-cookie'].push('sap-usercontext=sap-client=100; path=/');
32
+ }
33
+ if (partialRefreshCookies) {
34
+ headers['set-cookie'].push(state.sessionCounter === 1
35
+ ? 'sap-stale=delete-me; path=/'
36
+ : 'sap-stale=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/');
37
+ }
38
+ }
39
+ res.writeHead(200, headers);
24
40
  res.end('<ok/>');
25
41
  return;
26
42
  }
43
+ if (req.method === 'GET' && req.url === '/sap/bc/adt/discovery' && setCookieOnSafeGet) {
44
+ res.writeHead(200, { 'set-cookie': 'bas-read-session=kept; path=/sap/bc/adt' });
45
+ res.end('<read/>');
46
+ return;
47
+ }
27
48
  if (UNSAFE_METHODS.includes(req.method)) {
49
+ if (genericForbidden) {
50
+ res.writeHead(403);
51
+ res.end('User is not authorized to change this object');
52
+ return;
53
+ }
28
54
  if (neverAcceptUnsafe) {
29
55
  res.writeHead(403, { 'x-csrf-token': 'Required' });
30
56
  res.end('CSRF token validation failed');
@@ -36,7 +62,11 @@ function sapLikeBackend({ neverAcceptUnsafe = false, rotateOnFetch = false } = {
36
62
  // Without rotation any still-live token+cookie pair is accepted; with
37
63
  // rotation only the newest issued session remains valid, matching SAP
38
64
  // server-side session invalidation.
39
- const valid = match && cookie.includes(`sap-session-${match[1]}=`) && (!rotateOnFetch || Number(match[1]) === state.sessionCounter);
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='));
40
70
  if (!valid) {
41
71
  res.writeHead(403, { 'x-csrf-token': 'Required' });
42
72
  res.end('CSRF token validation failed');
@@ -79,7 +109,7 @@ async function withBackend(options, handler) {
79
109
  const relay = createBasDestinationRelay(
80
110
  { name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
81
111
  {
82
- env: { HTTP_PROXY: '' },
112
+ env: { HTTP_PROXY: '', ...(options?.env || {}) },
83
113
  fetchImpl: options?.fetchImpl || ((url, init) => globalThis.fetch(url, init)),
84
114
  log: message => logs.push(message)
85
115
  }
@@ -129,6 +159,46 @@ test('self-heals when SAP rotates the session: refetches token and cookies', asy
129
159
  });
130
160
  });
131
161
 
162
+ test('merges partial session-cookie updates and applies cookie deletion on refresh', async () => {
163
+ await withBackend({ rotateOnFetch: true, partialRefreshCookies: true }, async ({ relayUrl, state }) => {
164
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
165
+ state.sessionCounter += 1;
166
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
167
+ assert.equal(response.status, 201, `partial cookie refresh must heal, got ${response.status}`);
168
+ const posts = state.requests.filter(request => request.method === 'POST');
169
+ assert.match(String(posts[2].headers.cookie), /sap-usercontext=sap-client=100/);
170
+ assert.doesNotMatch(String(posts[2].headers.cookie), /sap-stale=/);
171
+ });
172
+ });
173
+
174
+ test('retains cookies received on a safe GET for later CSRF requests', async () => {
175
+ await withBackend({ setCookieOnSafeGet: true }, async ({ relayUrl, state }) => {
176
+ await relayFetch(relayUrl, '/sap/bc/adt/discovery');
177
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
178
+ assert.equal(response.status, 201);
179
+ const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
180
+ assert.match(String(tokenFetch.headers.cookie), /bas-read-session=kept/);
181
+ });
182
+ });
183
+
184
+ test('does not retry generic authorization failures as CSRF errors', async () => {
185
+ await withBackend({ genericForbidden: true }, async ({ relayUrl, relay, state }) => {
186
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
187
+ assert.equal(response.status, 403);
188
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 1);
189
+ assert.equal(relay.stats.csrfRetries, 0);
190
+ });
191
+ });
192
+
193
+ test('does not repeat a CSRF failure when SAP returns a token without session cookies', async () => {
194
+ await withBackend({ omitCookies: true }, async ({ relayUrl, relay, state }) => {
195
+ 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);
199
+ });
200
+ });
201
+
132
202
  test('stops retrying after bounded attempts and surfaces the failure', async () => {
133
203
  await withBackend({ neverAcceptUnsafe: true }, async ({ relay, relayUrl, state }) => {
134
204
  const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
@@ -139,6 +209,34 @@ test('stops retrying after bounded attempts and surfaces the failure', async ()
139
209
  });
140
210
  });
141
211
 
212
+ test('honors the configured bounded CSRF retry count', async () => {
213
+ await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '1' } }, async ({ relayUrl, relay, state }) => {
214
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
215
+ assert.equal(response.status, 403);
216
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 1 + 1);
217
+ assert.equal(relay.stats.csrfRetries, 1);
218
+ });
219
+ });
220
+
221
+ test('prefers the documented branded proxy setting including an explicit direct-connection override', async t => {
222
+ let dispatchedWithProxy = false;
223
+ const relay = createBasDestinationRelay(
224
+ { name: 'TEST', url: 'http://test.dest', authentication: 'BasicAuthentication' },
225
+ {
226
+ env: { HTTP_PROXY: 'http://127.0.0.1:1', SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '' },
227
+ fetchImpl: async (_url, options = {}) => {
228
+ dispatchedWithProxy = Boolean(options.dispatcher);
229
+ return new Response('<ok/>', { status: 200, headers: { 'x-csrf-token': 'token', 'set-cookie': 'sap-session=s; path=/' } });
230
+ }
231
+ }
232
+ );
233
+ t.after(() => relay.close());
234
+ await relay.ready;
235
+ const probe = await relay.probeCsrfSession();
236
+ assert.equal(probe.sessionUsable, true);
237
+ assert.equal(dispatchedWithProxy, false, 'an explicit empty branded proxy setting means direct traffic');
238
+ });
239
+
142
240
  test('GET requests pass through without CSRF handling', async () => {
143
241
  await withBackend(null, async ({ relayUrl, state, relay }) => {
144
242
  const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery', { headers: { accept: 'application/xml' } });
@@ -224,7 +322,7 @@ test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF ses
224
322
  });
225
323
 
226
324
  const probe = await relay.probeCsrfSession();
227
- assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 2 });
325
+ assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 2, sessionUsable: true });
228
326
  const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
229
327
  assert.equal(response.status, 201, `expected a paired CSRF session, got ${response.status}: ${response.body}`);
230
328
  const post = state.requests.find(request => request.method === 'POST');
@@ -158,6 +158,35 @@ test('probes .dest only through configured HTTP proxy and accepts proxy-auth res
158
158
  assert.deepEqual(requests, [{ host: 'proxy-system.dest', path: 'http://proxy-system.dest/sap/bc/adt/discovery' }]);
159
159
  });
160
160
 
161
+ test('uses the documented branded HTTP proxy for destination probes and respects explicit direct mode', async t => {
162
+ const requests = [];
163
+ const proxy = createServer((request, response) => {
164
+ requests.push({ host: request.headers.host, path: request.url });
165
+ response.writeHead(200);
166
+ response.end('<ok/>');
167
+ });
168
+ await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve));
169
+ t.after(() => new Promise(resolve => proxy.close(resolve)));
170
+ const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
171
+ const found = await discoverDestinations({
172
+ body: [{ Name: 'branded-proxy-system' }],
173
+ env: { HTTP_PROXY: 'http://127.0.0.1:1', SAP_AI_DEV_TOOLKIT_HTTP_PROXY: proxyUrl }
174
+ });
175
+ assert.equal(found[0].probe.status, 'available');
176
+ assert.deepEqual(requests, [{ host: 'branded-proxy-system.dest', path: 'http://branded-proxy-system.dest/sap/bc/adt/discovery' }]);
177
+
178
+ const direct = await discoverDestinations({
179
+ body: [{ Name: 'direct-system' }],
180
+ env: { HTTP_PROXY: proxyUrl, SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '' },
181
+ probeImpl: async (_url, options) => {
182
+ assert.equal(options.proxyUrl, '', 'explicit empty branded proxy must bypass the configured proxy');
183
+ return { status: 200 };
184
+ }
185
+ });
186
+ assert.equal(direct[0].probe.status, 'available');
187
+ assert.equal(requests.length, 1);
188
+ });
189
+
161
190
  test('fetches BAS destination lists through configured HTTP and HTTPS proxies', async t => {
162
191
  const proxyRequests = [];
163
192
  const directRequests = [];
@@ -9,22 +9,31 @@ 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) {
12
+ function requestADTThroughProxy(targetUrl, proxyUrl, { method = 'GET', body = '' } = {}) {
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');
16
16
  const headers = { host: target.host, accept: 'application/xml,text/xml,*/*' };
17
+ if (body) headers['content-type'] = 'application/xml';
17
18
  if (process.env.SAP_USER && process.env.SAP_PASSWORD) {
18
19
  headers.authorization = `Basic ${Buffer.from(`${process.env.SAP_USER}:${process.env.SAP_PASSWORD}`).toString('base64')}`;
19
20
  }
21
+ const loopback = ['127.0.0.1', 'localhost'].includes(target.hostname);
20
22
  return new Promise((resolve, reject) => {
21
- const request = httpRequest({
23
+ const requestOptions = loopback ? {
24
+ hostname: target.hostname,
25
+ port: Number(target.port),
26
+ method,
27
+ path: `${target.pathname}${target.search}`,
28
+ headers
29
+ } : {
22
30
  hostname: proxy.hostname,
23
31
  port: Number(proxy.port),
24
- method: 'GET',
32
+ method,
25
33
  path: target.href,
26
34
  headers
27
- }, response => {
35
+ };
36
+ const request = httpRequest(requestOptions, response => {
28
37
  let body = '';
29
38
  response.setEncoding('utf8');
30
39
  response.on('data', chunk => { body += chunk; });
@@ -32,6 +41,7 @@ function requestADTThroughProxy(targetUrl, proxyUrl) {
32
41
  });
33
42
  request.setTimeout(5_000, () => request.destroy(new Error('fixture ADT request timed out')));
34
43
  request.on('error', reject);
44
+ if (body) request.write(body);
35
45
  request.end();
36
46
  });
37
47
  }
@@ -234,6 +244,15 @@ process.stdin.on('data', async chunk => {
234
244
  } catch {
235
245
  reply(message.id, { content: [{ type: 'text', text: 'fixture ADT request failed' }], isError: true });
236
246
  }
247
+ } else if (process.env.FAKE_RELAY_CSRF_TEST === 'true' && name === 'GetSystemInfo') {
248
+ try {
249
+ const target = new URL('/sap/bc/adt/datapreview/freestyle', url);
250
+ const proxy = process.env.HTTP_PROXY || process.env.http_proxy;
251
+ const adt = await requestADTThroughProxy(target.href, proxy, { method: 'POST', body: '<probe/>' });
252
+ reply(message.id, { content: [{ type: 'text', text: `fixture ADT HTTP ${adt.status}: ${adt.body}` }], isError: adt.status < 200 || adt.status >= 300 });
253
+ } catch {
254
+ reply(message.id, { content: [{ type: 'text', text: 'fixture ADT write failed' }], isError: true });
255
+ }
237
256
  } else {
238
257
  reply(message.id, { content: [{ type: 'text', text: `${destination}:${name}` }], isError: false });
239
258
  }
@@ -134,6 +134,152 @@ test('doctor reports missing BAS configuration as redacted JSON', async () => {
134
134
  });
135
135
  });
136
136
 
137
+ test('doctor preflights the failing ADT endpoint through the runtime relay and repairs its owned config', async t => {
138
+ const directory = await mkdtemp(join(tmpdir(), 'bas-doctor-csrf-'));
139
+ const configPath = join(directory, 'mcp.json');
140
+ const config = {
141
+ servers: {
142
+ 'doctor-system': {
143
+ type: 'stdio',
144
+ command: 'sap-ai-dev',
145
+ env: {
146
+ H2O_URL: 'http://stale-bas.example',
147
+ SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system'
148
+ },
149
+ BAS_EXT: 'true'
150
+ },
151
+ ActionS4D_100: { type: 'stdio', command: 'third-party' }
152
+ }
153
+ };
154
+ await writeFile(configPath, JSON.stringify(config));
155
+ const requests = [];
156
+ const server = createServer((request, response) => {
157
+ let requestPath;
158
+ try { requestPath = new URL(request.url, `http://${request.headers.host}`).pathname; }
159
+ catch { requestPath = request.url; }
160
+ requests.push({ method: request.method, path: requestPath, csrf: request.headers['x-csrf-token'], cookie: request.headers.cookie });
161
+ if (request.url === '/api/listDestinations') {
162
+ response.setHeader('content-type', 'application/json');
163
+ response.end(JSON.stringify([{
164
+ Name: 'doctor-system',
165
+ WebIDEEnabled: true,
166
+ 'HTML5.DynamicDestination': true,
167
+ WebIDEUsage: 'dev_abap',
168
+ Authentication: 'BasicAuthentication'
169
+ }]));
170
+ return;
171
+ }
172
+ 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
+ if (request.method === 'POST'
182
+ && request.headers['x-csrf-token'] === 'doctor-token'
183
+ && String(request.headers.cookie).includes('sap-session=doctor-session')) {
184
+ response.writeHead(200);
185
+ response.end('<written/>');
186
+ return;
187
+ }
188
+ response.writeHead(403, { 'x-csrf-token': 'Required' });
189
+ response.end('CSRF token validation failed');
190
+ return;
191
+ }
192
+ response.writeHead(200);
193
+ response.end('<ok/>');
194
+ });
195
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
196
+ t.after(async () => {
197
+ await new Promise(resolve => server.close(resolve));
198
+ await rm(directory, { recursive: true, force: true });
199
+ });
200
+ const proxy = `http://127.0.0.1:${server.address().port}`;
201
+ const result = await runLauncher(['--doctor', '--json'], {
202
+ ...process.env,
203
+ SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
204
+ FAKE_RELAY_CSRF_TEST: 'true',
205
+ SAP_AI_DEV_MCP_CONFIG: configPath,
206
+ SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system',
207
+ H2O_URL: `http://127.0.0.1:${server.address().port}`,
208
+ HTTP_PROXY: proxy,
209
+ http_proxy: proxy,
210
+ NO_PROXY: '127.0.0.1,localhost',
211
+ no_proxy: '127.0.0.1,localhost'
212
+ });
213
+ assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`);
214
+ const report = JSON.parse(result.stdout);
215
+ assert.equal(report.ok, true);
216
+ assert.ok(report.checks.some(check => check.stage === 'BAS relay CSRF preflight' && check.status === 'passed'));
217
+ assert.ok(report.checks.some(check => check.stage === 'SAP system check' && check.status === 'passed'));
218
+ assert.ok(report.checks.some(check => check.stage === 'MCP config repair' && check.status === 'passed' && /1 managed BAS entry repaired/.test(check.detail)));
219
+ 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 === 'POST' && request.path === '/sap/bc/adt/datapreview/freestyle' && request.csrf === 'doctor-token' && String(request.cookie).includes('sap-session=doctor-session')));
222
+ const repairedConfig = JSON.parse(await readFile(configPath, 'utf8'));
223
+ assert.equal(repairedConfig.servers['doctor-system'].env.H2O_URL, `http://127.0.0.1:${server.address().port}`);
224
+ assert.deepEqual(repairedConfig.servers.ActionS4D_100, config.servers.ActionS4D_100);
225
+ });
226
+
227
+ test('doctor stops before GetSystemInfo when the runtime CSRF preflight has no session cookie', async t => {
228
+ const directory = await mkdtemp(join(tmpdir(), 'bas-doctor-csrf-missing-cookie-'));
229
+ const configPath = join(directory, 'mcp.json');
230
+ await writeFile(configPath, JSON.stringify({ servers: {} }));
231
+ const requests = [];
232
+ const server = createServer((request, response) => {
233
+ let requestPath;
234
+ try { requestPath = new URL(request.url, `http://${request.headers.host}`).pathname; }
235
+ catch { requestPath = request.url; }
236
+ requests.push({ method: request.method, path: requestPath, csrf: request.headers['x-csrf-token'] });
237
+ if (request.url === '/api/listDestinations') {
238
+ response.setHeader('content-type', 'application/json');
239
+ response.end(JSON.stringify([{
240
+ Name: 'doctor-system',
241
+ WebIDEEnabled: true,
242
+ 'HTML5.DynamicDestination': true,
243
+ WebIDEUsage: 'dev_abap',
244
+ Authentication: 'BasicAuthentication'
245
+ }]));
246
+ return;
247
+ }
248
+ if (requestPath === '/sap/bc/adt/datapreview/freestyle') {
249
+ response.writeHead(200, { 'x-csrf-token': 'orphan-token' });
250
+ response.end('<ok/>');
251
+ return;
252
+ }
253
+ response.writeHead(200);
254
+ response.end('<ok/>');
255
+ });
256
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
257
+ t.after(async () => {
258
+ await new Promise(resolve => server.close(resolve));
259
+ await rm(directory, { recursive: true, force: true });
260
+ });
261
+ const proxy = `http://127.0.0.1:${server.address().port}`;
262
+ const result = await runLauncher(['--doctor', '--json'], {
263
+ ...process.env,
264
+ SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
265
+ FAKE_RELAY_CSRF_TEST: 'true',
266
+ SAP_AI_DEV_MCP_CONFIG: configPath,
267
+ SAP_AI_DEV_TOOLKIT_DESTINATION: 'doctor-system',
268
+ H2O_URL: `http://127.0.0.1:${server.address().port}`,
269
+ HTTP_PROXY: proxy,
270
+ http_proxy: proxy,
271
+ NO_PROXY: '127.0.0.1,localhost',
272
+ no_proxy: '127.0.0.1,localhost'
273
+ });
274
+ assert.equal(result.code, 1);
275
+ const report = JSON.parse(result.stdout);
276
+ assert.equal(report.ok, false);
277
+ assert.ok(report.checks.some(check => check.stage === 'BAS relay CSRF preflight' && check.status === 'failed' && /cookies=0; usableSession=false/.test(check.detail)));
278
+ 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);
280
+ 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
+ });
282
+
137
283
  test('list-destinations JSON is redacted and probes through the BAS proxy', async () => {
138
284
  const requests = [];
139
285
  const server = createServer((request, response) => {
@@ -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 } from '../src/mcp-config.mjs';
6
+ import { buildMcpEntries, buildSapDevelopmentMcpEntries, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, 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',
@@ -78,6 +78,90 @@ test('stores and reconciles the MCP launcher executable location', async t => {
78
78
  assert.deepEqual(Object.keys((await readMcpConfig(path)).servers), ['userServer']);
79
79
  });
80
80
 
81
+ test('resolves documented, toolkit, and legacy MCP config paths in order', async () => {
82
+ assert.equal(await resolveMcpConfigPath({
83
+ SAP_AI_DEV_MCP_CONFIG: '/tmp/documented-mcp.json',
84
+ SAP_AI_DEV_TOOLKIT_MCP_CONFIG: '/tmp/toolkit-mcp.json',
85
+ BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json'
86
+ }), '/tmp/documented-mcp.json');
87
+ assert.equal(await resolveMcpConfigPath({
88
+ SAP_AI_DEV_TOOLKIT_MCP_CONFIG: '/tmp/toolkit-mcp.json',
89
+ BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json'
90
+ }), '/tmp/toolkit-mcp.json');
91
+ assert.equal(await resolveMcpConfigPath({ BAS_VSP_MCP_CONFIG: '/tmp/legacy-mcp.json' }), '/tmp/legacy-mcp.json');
92
+ });
93
+
94
+ test('doctor repair updates only discovered toolkit BAS entries and is idempotent', async t => {
95
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-'));
96
+ const path = join(directory, 'mcp.json');
97
+ t.after(() => rm(directory, { recursive: true, force: true }));
98
+ const cf = cfDestination('space-one', 'instance-one', 'cf-key');
99
+ const managed = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
100
+ const cfEntry = buildMcpEntries([cf], { H2O_URL: 'http://cf-h2o.example' })[cf.serverName];
101
+ const companion = buildSapDevelopmentMcpEntries(['sap-fiori-tools'])['sap-fiori-tools'];
102
+ const unrelated = { type: 'stdio', command: 'external-server' };
103
+ const unverifiedManaged = {
104
+ ...managed,
105
+ env: { ...managed.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'temporarily-unavailable' }
106
+ };
107
+ const config = {
108
+ userFlag: true,
109
+ servers: {
110
+ shared: { ...managed, env: { ...managed.env, H2O_URL: 'http://old-h2o.example' } },
111
+ 'temporarily-unavailable': unverifiedManaged,
112
+ [cf.serverName]: cfEntry,
113
+ 'sap-fiori-tools': companion,
114
+ ActionS4D_100: unrelated
115
+ }
116
+ };
117
+ await writeFile(path, JSON.stringify(config, null, 2));
118
+
119
+ const first = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
120
+ assert.equal(first.changed, true);
121
+ assert.equal(first.repaired, 1);
122
+ const repaired = await readMcpConfig(path);
123
+ assert.equal(repaired.userFlag, true);
124
+ assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
125
+ assert.equal(repaired.servers.shared.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'true');
126
+ assert.deepEqual(repaired.servers['temporarily-unavailable'], unverifiedManaged);
127
+ assert.deepEqual(repaired.servers[cf.serverName], cfEntry);
128
+ assert.deepEqual(repaired.servers['sap-fiori-tools'], companion);
129
+ assert.deepEqual(repaired.servers.ActionS4D_100, unrelated);
130
+ assert.equal(repaired.servers.newDestination, undefined, 'repair must not add unselected destinations');
131
+
132
+ const beforeSecondRepair = await readFile(path, 'utf8');
133
+ const second = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
134
+ assert.equal(second.changed, false);
135
+ assert.equal(second.repaired, 0);
136
+ assert.equal(await readFile(path, 'utf8'), beforeSecondRepair, 'an already-healed config must not be rewritten');
137
+ });
138
+
139
+ test('doctor repair leaves malformed config untouched', async t => {
140
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-invalid-'));
141
+ const path = join(directory, 'mcp.json');
142
+ const malformed = '{ "servers": ';
143
+ await writeFile(path, malformed);
144
+ t.after(() => rm(directory, { recursive: true, force: true }));
145
+ await assert.rejects(
146
+ () => repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example' }, path, discoveryComplete: true }),
147
+ /invalid JSON/
148
+ );
149
+ assert.equal(await readFile(path, 'utf8'), malformed);
150
+ });
151
+
152
+ test('doctor repair skips config changes if destination discovery is incomplete', async t => {
153
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-incomplete-'));
154
+ const path = join(directory, 'mcp.json');
155
+ const entry = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
156
+ const initial = JSON.stringify({ servers: { shared: entry } });
157
+ await writeFile(path, initial);
158
+ t.after(() => rm(directory, { recursive: true, force: true }));
159
+ const result = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://new-h2o.example' }, path });
160
+ assert.equal(result.changed, false);
161
+ assert.match(result.skipped, /incomplete/);
162
+ assert.equal(await readFile(path, 'utf8'), initial);
163
+ });
164
+
81
165
  test('extracts key references only from package-managed Cloud Foundry entries', () => {
82
166
  const cloudFoundry = buildMcpEntries([
83
167
  cfDestination('space-one', 'instance-one', 'destination-key', 'OnPremise')
@@ -271,6 +271,32 @@ test('merges paged tools and routes calls to the selected child', async t => {
271
271
  assert.equal(init.env.allowTransportableEdits, 'true');
272
272
  }
273
273
  });
274
+
275
+ test('routes tools/call using the exact name advertised for a BAS destination', async t => {
276
+ const directory = await mkdtemp(join(tmpdir(), 'bas-namespaced-tool-roundtrip-'));
277
+ const log = join(directory, 'children.log');
278
+ const proxy = new MCPProxy({
279
+ binary: fixture,
280
+ destinations: [{ name: 'ActionS4D_100', url: 'http://ActionS4D_100.dest', client: '100' }],
281
+ env: { ...process.env, SAP_AI_DEV_TOOLKIT_MODE: 'expert', FAKE_LOG: log },
282
+ log: () => {}
283
+ });
284
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
285
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
286
+ const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
287
+ const tool = listed.result.tools.find(candidate => candidate.name === 'actions4d-100__WriteSource');
288
+ assert.ok(tool, 'tools/list must publish the normalized destination namespace');
289
+ const called = await proxy.handle({
290
+ jsonrpc: '2.0',
291
+ id: 3,
292
+ method: 'tools/call',
293
+ params: { name: tool.name, arguments: { source: 'REPORT zself_heal.' } }
294
+ });
295
+ assert.equal(called.error, undefined);
296
+ assert.equal(called.result.content[0].text, 'ActionS4D_100:WriteSource');
297
+ const calls = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).filter(entry => entry.event === 'call');
298
+ assert.deepEqual(calls.map(entry => entry.name), ['WriteSource']);
299
+ });
274
300
  test('runs LintABAP locally and returns parser findings', async t => {
275
301
  const { directory, log, proxy } = await fixtureProxy();
276
302
  t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });