sap-ai-dev-toolkit 0.4.5 → 0.5.0

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
@@ -456,9 +456,10 @@ Credentials, cookies, SAP usernames, passwords, and raw BAS destination payloads
456
456
  The configuration path is selected in this order:
457
457
 
458
458
  1. `SAP_AI_DEV_MCP_CONFIG`, when set.
459
- 2. `BAS_VSP_MCP_CONFIG`, when set for compatibility with earlier releases.
460
- 3. An existing MCP user configuration detected automatically.
461
- 4. The default MCP user configuration location.
459
+ 2. `SAP_AI_DEV_TOOLKIT_MCP_CONFIG`, when set.
460
+ 3. `BAS_VSP_MCP_CONFIG`, when set for compatibility with earlier releases.
461
+ 4. An existing MCP user configuration detected automatically.
462
+ 5. The default MCP user configuration location.
462
463
 
463
464
  Set `SAP_AI_DEV_MCP_CONFIG` to use a specific configuration file:
464
465
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
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",
@@ -133,7 +133,16 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
133
133
  // must keep the BAS .dest route (and its Cloud Connector mapping), so they
134
134
  // use HTTP CONNECT through the BAS proxy instead of credentials.host.
135
135
  const directBase = directHost ? new URL(String(directHost)) : null;
136
- const cookieBase = directBase || (destination.backendUrl ? new URL(String(destination.backendUrl)) : base);
136
+ const cookieBase = directBase || (destination.backendUrl
137
+ ? new URL(String(destination.backendUrl))
138
+ : (() => {
139
+ const target = new URL(base);
140
+ // BAS .dest URLs are local HTTP routing aliases for SAP backends that
141
+ // are commonly HTTPS. Use a secure cookie context when BAS metadata
142
+ // omits the backend URL so Secure session cookies are not discarded.
143
+ if (target.protocol === 'http:' && /\.dest$/i.test(target.hostname)) target.protocol = 'https:';
144
+ return target;
145
+ })());
137
146
  if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
138
147
  if (tunnelMode) log(`[${destination.name}] BAS relay using a BAS proxy tunnel for the OnPremise destination`);
139
148
  // Proxy resolution: an explicitly configured proxy wins (an explicit empty
@@ -262,7 +271,9 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
262
271
  async function forward(method, requestUrl, requestHeaders, body, session) {
263
272
  const target = resolveTarget(requestUrl);
264
273
  const cookieTarget = resolveCookieTarget(target);
265
- const sessionCookie = session?.jar ? await cookieHeader(session.jar, cookieTarget) : undefined;
274
+ const cookieJar = session?.jar || sharedCookieJar;
275
+ if (!session) await seedRequestCookies(cookieJar, requestHeaders.cookie, cookieTarget);
276
+ const sessionCookie = await cookieHeader(cookieJar, cookieTarget);
266
277
  const headers = authHeaders(headersFrom(requestHeaders, {
267
278
  ...(session?.token ? { 'x-csrf-token': session.token } : {}),
268
279
  ...(sessionCookie ? { cookie: sessionCookie } : {})
@@ -288,7 +299,8 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
288
299
  : 'received a CSRF token without a session cookie';
289
300
  const message = `BAS destination relay ${detail} for ${requestUrl.pathname}; no unsafe request was sent`;
290
301
  log(`[${destination.name}] ${message}`);
291
- const response = new Response(message, { status: 503, headers: { 'content-type': 'text/plain; charset=utf-8' } });
302
+ const status = session?.httpStatus >= 400 ? session.httpStatus : 503;
303
+ const response = new Response(message, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
292
304
  return { response, body: Buffer.from(message) };
293
305
  }
294
306
  const { response, body: responseBody } = await forward(method, requestUrl, requestHeaders, body, session);
@@ -122,7 +122,7 @@ function proxyDispatcher(target, env) {
122
122
  const url = new URL(target);
123
123
  const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
124
124
  const httpProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.http_proxy ?? env.HTTP_PROXY ?? '');
125
- const httpsProxy = env.https_proxy ?? env.HTTPS_PROXY ?? '';
125
+ const httpsProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.https_proxy ?? env.HTTPS_PROXY ?? '');
126
126
  const proxy = url.protocol === 'https:' ? httpsProxy || httpProxy : httpProxy;
127
127
  const noProxy = env.no_proxy ?? env.NO_PROXY ?? '';
128
128
  if (!proxy || proxyBypasses(url, noProxy)) return undefined;
package/src/launcher.mjs CHANGED
@@ -60,7 +60,11 @@ async function runDoctor(destinations) {
60
60
 
61
61
 
62
62
  try {
63
- const repair = await repairManagedMcpConfig(destinations, { env: runtimeEnv, discoveryComplete: true });
63
+ const repair = await repairManagedMcpConfig(destinations, {
64
+ env: runtimeEnv,
65
+ discoveryComplete: true,
66
+ packageVersion: pkg.version
67
+ });
64
68
  const detail = repair.skipped
65
69
  ? repair.skipped
66
70
  : `${repair.repaired} managed BAS entr${repair.repaired === 1 ? 'y' : 'ies'} repaired; ${repair.managedEntries || 0} managed BAS entries recognized`;
@@ -322,7 +322,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
322
322
  return { path, servers: generated };
323
323
  }
324
324
 
325
- export async function repairManagedMcpConfig(discoveredDestinations, { env = process.env, path, discoveryComplete = false } = {}) {
325
+ export async function repairManagedMcpConfig(discoveredDestinations, { env = process.env, path, discoveryComplete = false, packageVersion } = {}) {
326
326
  const configPath = path || await resolveMcpConfigPath(env);
327
327
  if (!discoveryComplete) {
328
328
  return { path: configPath, changed: false, repaired: 0, skipped: 'Destination discovery was incomplete.' };
@@ -334,6 +334,7 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
334
334
  const config = await readConfig(configPath);
335
335
  const managedEntries = Object.values(config.servers).filter(isManagedBasDestinationEntry).length;
336
336
  if (!basDestinations.size) return { path: configPath, changed: false, repaired: 0, managedEntries };
337
+ const localCommand = await resolveMcpServerCommand(env);
337
338
  let repaired = 0;
338
339
  for (const [serverName, entry] of Object.entries(config.servers)) {
339
340
  if (!isManagedBasDestinationEntry(entry)) continue;
@@ -343,9 +344,21 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
343
344
  if (!destination) continue;
344
345
  const expected = buildMcpEntries([destination], env)[serverName];
345
346
  const nextEnvironment = { ...entry.env, ...expected.env };
347
+ if (Object.hasOwn(entry.env, 'SAP_ALLOW_TRANSPORTABLE_EDITS')) {
348
+ nextEnvironment.SAP_ALLOW_TRANSPORTABLE_EDITS = entry.env.SAP_ALLOW_TRANSPORTABLE_EDITS;
349
+ }
346
350
  delete nextEnvironment.BAS_VSP_DESTINATION;
347
351
  delete nextEnvironment.BAS_VSP_DESTINATION_SOURCE;
348
352
  const nextEntry = { ...entry, type: 'stdio', env: nextEnvironment, BAS_EXT: 'true' };
353
+ if (entry.command === 'npx') {
354
+ if (packageVersion && Array.isArray(entry.args)) {
355
+ nextEntry.args = entry.args.map(argument => typeof argument === 'string' && /^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/.test(argument)
356
+ ? `--package=sap-ai-dev-toolkit@${packageVersion}`
357
+ : argument);
358
+ }
359
+ } else {
360
+ nextEntry.command = localCommand;
361
+ }
349
362
  if (JSON.stringify(nextEntry) === JSON.stringify(entry)) continue;
350
363
  config.servers[serverName] = nextEntry;
351
364
  repaired += 1;
@@ -17,7 +17,8 @@ function sapLikeBackend({
17
17
  genericForbidden = false,
18
18
  partialRefreshCookies = false,
19
19
  omitCookies = false,
20
- setCookieOnSafeGet = false
20
+ setCookieOnSafeGet = false,
21
+ secureSessionCookie = false
21
22
  } = {}) {
22
23
  const state = { sessionCounter: 0, requests: [] };
23
24
  const server = http.createServer((req, res) => {
@@ -35,6 +36,7 @@ function sapLikeBackend({
35
36
  ? 'sap-stale=delete-me; path=/'
36
37
  : 'sap-stale=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/');
37
38
  }
39
+ if (secureSessionCookie) headers['set-cookie'].push('sap-secure=secure-value; Secure; Path=/');
38
40
  }
39
41
  res.writeHead(200, headers);
40
42
  res.end('<ok/>');
@@ -145,6 +147,30 @@ test('reuses the cached CSRF session for subsequent unsafe requests', async () =
145
147
  });
146
148
  });
147
149
 
150
+ test('coalesces concurrent CSRF token fetches for the same ADT endpoint', async () => {
151
+ await withBackend(null, async ({ relayUrl, state }) => {
152
+ const responses = await Promise.all([
153
+ relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' }),
154
+ relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' })
155
+ ]);
156
+ assert.deepEqual(responses.map(response => response.status), [201, 201]);
157
+ assert.equal(state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch').length, 1);
158
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 2);
159
+ });
160
+ });
161
+
162
+ test('seeds the relay cookie jar from the incoming MCP request cookie', async () => {
163
+ await withBackend(null, async ({ relayUrl, state }) => {
164
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', {
165
+ method: 'POST',
166
+ headers: { cookie: 'sap-initial-session=from-vsp' },
167
+ body: '<x/>'
168
+ });
169
+ const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
170
+ assert.match(String(tokenFetch.headers.cookie), /sap-initial-session=from-vsp/);
171
+ });
172
+ });
173
+
148
174
  test('self-heals when SAP rotates the session: refetches token and cookies', async () => {
149
175
  await withBackend({ rotateOnFetch: true }, async ({ relay, relayUrl, state }) => {
150
176
  // A first POST establishes session token-1. SAP then invalidates it
@@ -218,6 +244,17 @@ test('honors the configured bounded CSRF retry count', async () => {
218
244
  });
219
245
  });
220
246
 
247
+ test('uses the default for invalid retry settings and caps excessive retry counts', async () => {
248
+ await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '1.5' } }, async ({ relayUrl, state }) => {
249
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
250
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 4, 'invalid values use the default of three retries');
251
+ });
252
+ await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '100' } }, async ({ relayUrl, state }) => {
253
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
254
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 11, 'retry counts are capped at ten');
255
+ });
256
+ });
257
+
221
258
  test('prefers the documented branded proxy setting including an explicit direct-connection override', async t => {
222
259
  let dispatchedWithProxy = false;
223
260
  const relay = createBasDestinationRelay(
@@ -287,7 +324,7 @@ test('falls back to a proxy tunnel when the absolute-form request is refused', a
287
324
  });
288
325
 
289
326
  test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF session', async t => {
290
- const { state, server } = sapLikeBackend();
327
+ const { state, server } = sapLikeBackend({ secureSessionCookie: true });
291
328
  const backendPort = await startServer(server);
292
329
  const connectTargets = [];
293
330
  const proxy = http.createServer();
@@ -322,12 +359,13 @@ test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF ses
322
359
  });
323
360
 
324
361
  const probe = await relay.probeCsrfSession();
325
- assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 2, sessionUsable: true });
362
+ assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 3, sessionUsable: true });
326
363
  const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
327
364
  assert.equal(response.status, 201, `expected a paired CSRF session, got ${response.status}: ${response.body}`);
328
365
  const post = state.requests.find(request => request.method === 'POST');
329
366
  assert.match(post.headers.authorization, /^Basic /);
330
367
  assert.match(String(post.headers.cookie), /sap-session-\d+=/);
368
+ assert.match(String(post.headers.cookie), /sap-secure=secure-value/, 'Secure SAP cookies must be sent through the HTTPS-backed BAS .dest route');
331
369
  assert.ok(connectTargets.length >= 2);
332
370
  assert.ok(connectTargets.every(target => target === 'cc-system.dest:80'));
333
371
  assert.equal(relay.stats.direct, false);
@@ -231,6 +231,26 @@ test('fetches BAS destination lists through configured HTTP and HTTPS proxies',
231
231
  assert.equal(proxyRequests.length, 2);
232
232
  });
233
233
 
234
+ test('branded HTTP proxy takes precedence over HTTPS_PROXY for secure BAS discovery', async () => {
235
+ let capturedDispatcher = 'not-called';
236
+ const body = await fetchDestinationList('https://bas.example', {
237
+ env: {
238
+ SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '',
239
+ HTTPS_PROXY: 'http://127.0.0.1:1',
240
+ https_proxy: 'http://127.0.0.1:1'
241
+ },
242
+ fetchImpl: async (_url, options) => {
243
+ capturedDispatcher = options.dispatcher;
244
+ return new Response(JSON.stringify([{ Name: 'secure-system' }]), {
245
+ status: 200,
246
+ headers: { 'content-type': 'application/json' }
247
+ });
248
+ }
249
+ });
250
+ assert.deepEqual(body, [{ Name: 'secure-system' }]);
251
+ assert.equal(capturedDispatcher, undefined, 'explicit branded empty proxy must disable inherited HTTPS_PROXY too');
252
+ });
253
+
234
254
  test('redacts credential-bearing diagnostics without exposing payloads', () => {
235
255
  const value = redact({ name: 'dev', password: 'secret', authorization: 'Bearer abc', nested: { cookie: 'x', ok: 'visible' } });
236
256
  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,
@@ -248,7 +248,7 @@ process.stdin.on('data', async chunk => {
248
248
  try {
249
249
  const target = new URL('/sap/bc/adt/datapreview/freestyle', url);
250
250
  const proxy = process.env.HTTP_PROXY || process.env.http_proxy;
251
- const adt = await requestADTThroughProxy(target.href, proxy, { method: 'POST', body: '<probe/>' });
251
+ const adt = await requestADTThroughProxy(target.href, proxy, { method: 'POST', body: '<probe/>', bypassProxyForLoopback: true });
252
252
  reply(message.id, { content: [{ type: 'text', text: `fixture ADT HTTP ${adt.status}: ${adt.body}` }], isError: adt.status < 200 || adt.status >= 300 });
253
253
  } catch {
254
254
  reply(message.id, { content: [{ type: 'text', text: 'fixture ADT write failed' }], isError: true });
@@ -96,7 +96,9 @@ test('doctor repair updates only discovered toolkit BAS entries and is idempoten
96
96
  const path = join(directory, 'mcp.json');
97
97
  t.after(() => rm(directory, { recursive: true, force: true }));
98
98
  const cf = cfDestination('space-one', 'instance-one', 'cf-key');
99
+ const strictDestination = { ...bas, name: 'strict', serverName: 'strict', url: 'http://strict.dest' };
99
100
  const managed = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
101
+ const strictManaged = buildMcpEntries([strictDestination], { H2O_URL: 'http://old-h2o.example' }).strict;
100
102
  const cfEntry = buildMcpEntries([cf], { H2O_URL: 'http://cf-h2o.example' })[cf.serverName];
101
103
  const companion = buildSapDevelopmentMcpEntries(['sap-fiori-tools'])['sap-fiori-tools'];
102
104
  const unrelated = { type: 'stdio', command: 'external-server' };
@@ -108,6 +110,7 @@ test('doctor repair updates only discovered toolkit BAS entries and is idempoten
108
110
  userFlag: true,
109
111
  servers: {
110
112
  shared: { ...managed, env: { ...managed.env, H2O_URL: 'http://old-h2o.example' } },
113
+ strict: { ...strictManaged, env: { ...strictManaged.env, SAP_ALLOW_TRANSPORTABLE_EDITS: 'false' } },
111
114
  'temporarily-unavailable': unverifiedManaged,
112
115
  [cf.serverName]: cfEntry,
113
116
  'sap-fiori-tools': companion,
@@ -116,13 +119,14 @@ test('doctor repair updates only discovered toolkit BAS entries and is idempoten
116
119
  };
117
120
  await writeFile(path, JSON.stringify(config, null, 2));
118
121
 
119
- const first = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
122
+ const first = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
120
123
  assert.equal(first.changed, true);
121
- assert.equal(first.repaired, 1);
124
+ assert.equal(first.repaired, 2);
122
125
  const repaired = await readMcpConfig(path);
123
126
  assert.equal(repaired.userFlag, true);
124
127
  assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
125
128
  assert.equal(repaired.servers.shared.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'true');
129
+ assert.equal(repaired.servers.strict.env.SAP_ALLOW_TRANSPORTABLE_EDITS, 'false', "doctor must preserve a user's restricted write setting");
126
130
  assert.deepEqual(repaired.servers['temporarily-unavailable'], unverifiedManaged);
127
131
  assert.deepEqual(repaired.servers[cf.serverName], cfEntry);
128
132
  assert.deepEqual(repaired.servers['sap-fiori-tools'], companion);
@@ -130,7 +134,7 @@ test('doctor repair updates only discovered toolkit BAS entries and is idempoten
130
134
  assert.equal(repaired.servers.newDestination, undefined, 'repair must not add unselected destinations');
131
135
 
132
136
  const beforeSecondRepair = await readFile(path, 'utf8');
133
- const second = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
137
+ const second = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
134
138
  assert.equal(second.changed, false);
135
139
  assert.equal(second.repaired, 0);
136
140
  assert.equal(await readFile(path, 'utf8'), beforeSecondRepair, 'an already-healed config must not be rewritten');
@@ -162,6 +166,46 @@ test('doctor repair skips config changes if destination discovery is incomplete'
162
166
  assert.equal(await readFile(path, 'utf8'), initial);
163
167
  });
164
168
 
169
+ test('doctor repair refreshes stale toolkit launchers without changing npx behavior', async t => {
170
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-launcher-repair-'));
171
+ const path = join(directory, 'mcp.json');
172
+ const bin = join(directory, 'bin');
173
+ await import('node:fs/promises').then(({ mkdir }) => mkdir(bin));
174
+ const command = join(bin, 'sap-ai-dev');
175
+ await writeFile(command, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
176
+ t.after(() => rm(directory, { recursive: true, force: true }));
177
+ const npx = {
178
+ ...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared,
179
+ command: 'npx',
180
+ args: ['--yes', '--package=sap-ai-dev-toolkit@0.4.2', 'sap-ai-dev'],
181
+ env: {
182
+ ...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared.env,
183
+ SAP_AI_DEV_TOOLKIT_DESTINATION: 'shared',
184
+ OPERATOR_SETTING: 'preserve'
185
+ }
186
+ };
187
+ const direct = {
188
+ ...buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared,
189
+ command: 'stale-sap-ai-dev'
190
+ };
191
+ await writeFile(path, JSON.stringify({ servers: { shared: npx, other: { ...direct, env: { ...direct.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'other' } }, userServer: { command: 'other' } } }));
192
+
193
+ const result = await repairManagedMcpConfig([bas], {
194
+ env: { H2O_URL: 'http://current-h2o.example', PATH: bin },
195
+ path,
196
+ discoveryComplete: true,
197
+ packageVersion: '0.4.5'
198
+ });
199
+ assert.equal(result.repaired, 1, 'only the entry that maps to the discovered destination is repaired');
200
+ const repaired = await readMcpConfig(path);
201
+ assert.equal(repaired.servers.shared.command, 'npx');
202
+ assert.deepEqual(repaired.servers.shared.args, ['--yes', '--package=sap-ai-dev-toolkit@0.4.5', 'sap-ai-dev']);
203
+ assert.equal(repaired.servers.shared.env.OPERATOR_SETTING, 'preserve');
204
+ assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
205
+ assert.equal(repaired.servers.other.command, 'stale-sap-ai-dev', 'unmatched toolkit entry must remain untouched');
206
+ assert.deepEqual(repaired.servers.userServer, { command: 'other' });
207
+ });
208
+
165
209
  test('extracts key references only from package-managed Cloud Foundry entries', () => {
166
210
  const cloudFoundry = buildMcpEntries([
167
211
  cfDestination('space-one', 'instance-one', 'destination-key', 'OnPremise')
@@ -275,15 +275,19 @@ test('merges paged tools and routes calls to the selected child', async t => {
275
275
  test('routes tools/call using the exact name advertised for a BAS destination', async t => {
276
276
  const directory = await mkdtemp(join(tmpdir(), 'bas-namespaced-tool-roundtrip-'));
277
277
  const log = join(directory, 'children.log');
278
+ const logs = [];
278
279
  const proxy = new MCPProxy({
279
280
  binary: fixture,
280
281
  destinations: [{ name: 'ActionS4D_100', url: 'http://ActionS4D_100.dest', client: '100' }],
281
282
  env: { ...process.env, SAP_AI_DEV_TOOLKIT_MODE: 'expert', FAKE_LOG: log },
282
- log: () => {}
283
+ log: message => logs.push(message)
283
284
  });
284
285
  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
+ proxy.start();
287
+ const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
288
+ assert.equal(initialized.error, undefined, `${JSON.stringify(initialized.error)} ${logs.join(' | ')}`);
286
289
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
290
+ assert.equal(listed.error, undefined, JSON.stringify(listed.error));
287
291
  const tool = listed.result.tools.find(candidate => candidate.name === 'actions4d-100__WriteSource');
288
292
  assert.ok(tool, 'tools/list must publish the normalized destination namespace');
289
293
  const called = await proxy.handle({