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 +4 -3
- package/package.json +1 -1
- package/src/bas-destination-relay.mjs +15 -3
- package/src/bas-discovery.mjs +1 -1
- package/src/launcher.mjs +5 -1
- package/src/mcp-config.mjs +14 -1
- package/test/bas-destination-relay.test.mjs +41 -3
- package/test/discovery.test.mjs +20 -0
- package/test/fixtures/fake-vsp.mjs +3 -3
- package/test/mcp-config-cf.test.mjs +47 -3
- package/test/mcp-proxy.test.mjs +6 -2
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. `
|
|
460
|
-
3.
|
|
461
|
-
4.
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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);
|
package/src/bas-discovery.mjs
CHANGED
|
@@ -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, {
|
|
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`;
|
package/src/mcp-config.mjs
CHANGED
|
@@ -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:
|
|
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);
|
package/test/discovery.test.mjs
CHANGED
|
@@ -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,
|
|
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')
|
package/test/mcp-proxy.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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({
|