sap-ai-dev-toolkit 0.4.2 → 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 +21 -18
- package/package.json +2 -1
- package/src/bas-destination-relay.mjs +122 -35
- package/src/bas-discovery.mjs +7 -4
- package/src/launcher.mjs +44 -7
- package/src/mcp-config.mjs +66 -2
- package/src/mcp-proxy.mjs +4 -1
- package/test/bas-destination-relay.test.mjs +145 -9
- package/test/discovery.test.mjs +49 -0
- package/test/fixtures/fake-vsp.mjs +23 -4
- package/test/launcher.test.mjs +146 -0
- package/test/mcp-config-cf.test.mjs +129 -1
- package/test/mcp-proxy.test.mjs +30 -0
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
|
|
|
@@ -538,7 +539,9 @@ For a transport report, include exactly one `GetTransport` check whose `transpor
|
|
|
538
539
|
|
|
539
540
|
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
541
|
|
|
541
|
-
`sap-ai-dev
|
|
542
|
+
`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.
|
|
543
|
+
|
|
544
|
+
`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
545
|
|
|
543
546
|
`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
547
|
|
|
@@ -727,29 +730,29 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
727
730
|
| `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
731
|
| `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
732
|
| `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
|
|
731
|
-
| `SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES` | Bounded CSRF
|
|
733
|
+
| `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. |
|
|
734
|
+
| `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. |
|
|
735
|
+
| `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
|
|
736
|
+
| `SAP_AI_DEV_TOOLKIT_MCP_CONFIG` | Branded compatibility alias for the MCP configuration path. |
|
|
737
|
+
| `BAS_VSP_MCP_CONFIG` | Backward-compatible alias for the MCP configuration path. |
|
|
738
|
+
| `BAS_VSP_BINARY` | Trusted prebuilt VSP executable; skips Go and binary provisioning. |
|
|
739
|
+
| `BAS_VSP_BINARY_URL` | Alternate VSP binary download URL. |
|
|
740
|
+
| `BAS_VSP_CACHE_DIR` | Binary cache directory. |
|
|
741
|
+
| `GO_BINARY` | Explicit Go executable used for provisioning when automatic Go installation is unavailable. |
|
|
742
|
+
| `SAP_AI_DEV_TOOLKIT_SKIP_PROBE=true` | Skip destination probes; useful for controlled diagnostics or fixtures. |
|
|
743
|
+
| `HTTP_PROXY` / `HTTPS_PROXY` | BAS proxy settings used for destination-list requests, destination probing, and child processes. |
|
|
744
|
+
| `NO_PROXY` | Proxy bypass list for BAS destination-list requests; `.dest` hosts remain routed through the BAS proxy. |
|
|
732
745
|
|
|
733
746
|
#### Self-healing modes
|
|
734
747
|
|
|
735
748
|
The relay between the VSP child and each BAS destination recovers from the failure modes that break ADT writes over `.dest` proxies:
|
|
736
749
|
|
|
737
|
-
1. **CSRF session pairing** —
|
|
738
|
-
2. **Session refresh** — when SAP rejects
|
|
750
|
+
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.
|
|
751
|
+
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
752
|
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
753
|
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
754
|
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
755
|
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
756
|
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
757
|
|
|
755
758
|
<a id="troubleshooting"></a>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sap-ai-dev-toolkit",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -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
|
|
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
|
|
67
|
-
|
|
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
|
|
84
|
+
async function cookieHeader(jar, target) {
|
|
85
|
+
return await jar.getCookieString(target.href) || undefined;
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
function
|
|
75
|
-
for (const
|
|
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
|
-
|
|
82
|
-
|
|
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,16 @@ 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
|
|
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
|
+
})());
|
|
107
146
|
if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
|
|
108
147
|
if (tunnelMode) log(`[${destination.name}] BAS relay using a BAS proxy tunnel for the OnPremise destination`);
|
|
109
148
|
// Proxy resolution: an explicitly configured proxy wins (an explicit empty
|
|
@@ -111,7 +150,8 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
111
150
|
// imply the default BAS proxy; any other host (tests, direct URLs) is
|
|
112
151
|
// reached directly so the relay never routes loopback traffic through a
|
|
113
152
|
// foreign proxy.
|
|
114
|
-
const
|
|
153
|
+
const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
|
|
154
|
+
const explicitProxy = brandedProxy !== undefined ? brandedProxy : (env.HTTP_PROXY ?? env.http_proxy);
|
|
115
155
|
const proxyUrl = directBase ? '' : (explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : ''));
|
|
116
156
|
if (tunnelMode && !proxyUrl) throw new Error('OnPremise credential overrides require the BAS destination proxy to preserve the Cloud Connector route');
|
|
117
157
|
const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
|
|
@@ -121,15 +161,17 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
121
161
|
const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
|
|
122
162
|
let tunneled = tunnelMode;
|
|
123
163
|
// 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
|
-
|
|
126
|
-
//
|
|
164
|
+
const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct: Boolean(directBase), proxyTunnel: tunnelMode };
|
|
165
|
+
const maxFailures = maxTokenFailures(env, log, destination.name);
|
|
166
|
+
// The shared jar captures SAP cookies from safe reads as well as CSRF fetches.
|
|
167
|
+
// Each cached token gets its own snapshot so later responses cannot silently
|
|
168
|
+
// pair an old token with a different session.
|
|
169
|
+
const sharedCookieJar = new CookieJar();
|
|
127
170
|
const tokenCache = new Map();
|
|
171
|
+
const tokenFetches = new Map();
|
|
128
172
|
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
recordCookies(jar, headers);
|
|
132
|
-
tokenCache.set(key, { token, jar, fetchedAt: Date.now(), httpStatus });
|
|
173
|
+
function resolveCookieTarget(target) {
|
|
174
|
+
return new URL(`${target.pathname}${target.search}`, cookieBase);
|
|
133
175
|
}
|
|
134
176
|
|
|
135
177
|
async function send(target, method, headers, body) {
|
|
@@ -158,23 +200,48 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
158
200
|
return send(target, method, headers, body);
|
|
159
201
|
}
|
|
160
202
|
|
|
161
|
-
async function
|
|
203
|
+
async function fetchTokenFresh(requestUrl, requestHeaders) {
|
|
162
204
|
const key = requestUrl.pathname;
|
|
163
|
-
const cached = tokenCache.get(key);
|
|
164
|
-
if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
|
|
165
205
|
stats.csrfFetches += 1;
|
|
166
|
-
const
|
|
206
|
+
const target = resolveTarget(requestUrl);
|
|
207
|
+
const cookieTarget = resolveCookieTarget(target);
|
|
208
|
+
const jar = copyCookieJar(sharedCookieJar);
|
|
209
|
+
await seedRequestCookies(jar, requestHeaders.cookie, cookieTarget);
|
|
210
|
+
const outgoingCookie = await cookieHeader(jar, cookieTarget);
|
|
211
|
+
const response = await sendWithFallback(target, 'GET', authHeaders(fetchHeaders(requestHeaders, {
|
|
167
212
|
'x-csrf-token': 'Fetch',
|
|
168
213
|
accept: requestHeaders.accept || 'application/xml,*/*',
|
|
169
|
-
...(
|
|
214
|
+
...(outgoingCookie ? { cookie: outgoingCookie } : {})
|
|
170
215
|
})));
|
|
171
216
|
await response.arrayBuffer().catch(() => undefined);
|
|
172
217
|
const token = response.headers.get('x-csrf-token');
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
218
|
+
await recordCookies(jar, response.headers, cookieTarget, log, destination.name);
|
|
219
|
+
await recordCookies(sharedCookieJar, response.headers, cookieTarget, log, destination.name);
|
|
220
|
+
const cookies = await jar.getCookies(cookieTarget.href);
|
|
221
|
+
const session = {
|
|
222
|
+
token: response.ok && token && token.toLowerCase() !== 'required' ? token : null,
|
|
223
|
+
jar,
|
|
224
|
+
fetchedAt: Date.now(),
|
|
225
|
+
httpStatus: response.status,
|
|
226
|
+
cookieCount: cookies.length
|
|
227
|
+
};
|
|
228
|
+
if (session.token) tokenCache.set(key, session);
|
|
229
|
+
return session;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function fetchToken(requestUrl, requestHeaders = {}) {
|
|
233
|
+
const key = requestUrl.pathname;
|
|
234
|
+
const cached = tokenCache.get(key);
|
|
235
|
+
if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
|
|
236
|
+
const pending = tokenFetches.get(key);
|
|
237
|
+
if (pending) return pending;
|
|
238
|
+
const fetching = fetchTokenFresh(requestUrl, requestHeaders);
|
|
239
|
+
tokenFetches.set(key, fetching);
|
|
240
|
+
try {
|
|
241
|
+
return await fetching;
|
|
242
|
+
} finally {
|
|
243
|
+
if (tokenFetches.get(key) === fetching) tokenFetches.delete(key);
|
|
176
244
|
}
|
|
177
|
-
return null;
|
|
178
245
|
}
|
|
179
246
|
|
|
180
247
|
async function probeCsrfSession(path = '/sap/bc/adt/discovery') {
|
|
@@ -183,7 +250,8 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
183
250
|
return {
|
|
184
251
|
httpStatus: session?.httpStatus || 0,
|
|
185
252
|
tokenReceived: Boolean(session?.token),
|
|
186
|
-
cookieCount: session?.
|
|
253
|
+
cookieCount: session?.cookieCount || 0,
|
|
254
|
+
sessionUsable: Boolean(session?.token && session.cookieCount > 0)
|
|
187
255
|
};
|
|
188
256
|
}
|
|
189
257
|
|
|
@@ -201,13 +269,19 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
201
269
|
}
|
|
202
270
|
|
|
203
271
|
async function forward(method, requestUrl, requestHeaders, body, session) {
|
|
272
|
+
const target = resolveTarget(requestUrl);
|
|
273
|
+
const cookieTarget = resolveCookieTarget(target);
|
|
274
|
+
const cookieJar = session?.jar || sharedCookieJar;
|
|
275
|
+
if (!session) await seedRequestCookies(cookieJar, requestHeaders.cookie, cookieTarget);
|
|
276
|
+
const sessionCookie = await cookieHeader(cookieJar, cookieTarget);
|
|
204
277
|
const headers = authHeaders(headersFrom(requestHeaders, {
|
|
205
278
|
...(session?.token ? { 'x-csrf-token': session.token } : {}),
|
|
206
|
-
...(
|
|
279
|
+
...(sessionCookie ? { cookie: sessionCookie } : {})
|
|
207
280
|
}));
|
|
208
|
-
const response = await sendWithFallback(
|
|
281
|
+
const response = await sendWithFallback(target, method, headers, body);
|
|
209
282
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
210
|
-
if (session) recordCookies(session.jar, response.headers);
|
|
283
|
+
if (session) await recordCookies(session.jar, response.headers, cookieTarget, log, destination.name);
|
|
284
|
+
await recordCookies(sharedCookieJar, response.headers, cookieTarget, log, destination.name);
|
|
211
285
|
return { response, body: responseBody };
|
|
212
286
|
}
|
|
213
287
|
|
|
@@ -218,15 +292,28 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
218
292
|
async function forwardWithRetry(method, requestUrl, requestHeaders, body, failures = 0) {
|
|
219
293
|
const unsafe = UNSAFE.has(method);
|
|
220
294
|
const session = unsafe ? await fetchToken(requestUrl, requestHeaders) : null;
|
|
221
|
-
if (unsafe && !session
|
|
295
|
+
if (unsafe && (!session?.token || !session.cookieCount)) {
|
|
296
|
+
stats.csrfSessionFailures += 1;
|
|
297
|
+
const detail = !session?.token
|
|
298
|
+
? 'could not obtain a CSRF token'
|
|
299
|
+
: 'received a CSRF token without a session cookie';
|
|
300
|
+
const message = `BAS destination relay ${detail} for ${requestUrl.pathname}; no unsafe request was sent`;
|
|
301
|
+
log(`[${destination.name}] ${message}`);
|
|
302
|
+
const status = session?.httpStatus >= 400 ? session.httpStatus : 503;
|
|
303
|
+
const response = new Response(message, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
304
|
+
return { response, body: Buffer.from(message) };
|
|
305
|
+
}
|
|
222
306
|
const { response, body: responseBody } = await forward(method, requestUrl, requestHeaders, body, session);
|
|
223
|
-
const rejected = csrfFailure(response.status, response.headers, responseBody)
|
|
224
|
-
if (unsafe && rejected && failures <
|
|
225
|
-
log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${
|
|
307
|
+
const rejected = csrfFailure(response.status, response.headers, responseBody);
|
|
308
|
+
if (unsafe && rejected && failures < maxFailures) {
|
|
309
|
+
log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${maxFailures})`);
|
|
226
310
|
stats.csrfRetries += 1;
|
|
227
311
|
tokenCache.delete(requestUrl.pathname);
|
|
228
312
|
return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
|
|
229
313
|
}
|
|
314
|
+
if (unsafe && rejected && failures >= maxFailures) {
|
|
315
|
+
log(`[${destination.name}] BAS relay stopped after ${maxFailures} CSRF session retries for ${requestUrl.pathname}`);
|
|
316
|
+
}
|
|
230
317
|
return { response, body: responseBody };
|
|
231
318
|
}
|
|
232
319
|
|
package/src/bas-discovery.mjs
CHANGED
|
@@ -120,8 +120,9 @@ function proxyBypasses(url, noProxy) {
|
|
|
120
120
|
|
|
121
121
|
function proxyDispatcher(target, env) {
|
|
122
122
|
const url = new URL(target);
|
|
123
|
-
const
|
|
124
|
-
const
|
|
123
|
+
const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
|
|
124
|
+
const httpProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.http_proxy ?? env.HTTP_PROXY ?? '');
|
|
125
|
+
const httpsProxy = brandedProxy !== undefined ? String(brandedProxy) : (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 ?? '';
|
|
127
128
|
if (!proxy || proxyBypasses(url, noProxy)) return undefined;
|
|
@@ -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
|
|
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
|
|
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
|
|
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,20 @@ 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, {
|
|
64
|
+
env: runtimeEnv,
|
|
65
|
+
discoveryComplete: true,
|
|
66
|
+
packageVersion: pkg.version
|
|
67
|
+
});
|
|
68
|
+
const detail = repair.skipped
|
|
69
|
+
? repair.skipped
|
|
70
|
+
: `${repair.repaired} managed BAS entr${repair.repaired === 1 ? 'y' : 'ies'} repaired; ${repair.managedEntries || 0} managed BAS entries recognized`;
|
|
71
|
+
checks.push(doctorRow('-', 'MCP config repair', 'passed', detail));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
checks.push(doctorRow('-', 'MCP config repair', 'failed', redactText(error.message || error).slice(0, 300)));
|
|
74
|
+
}
|
|
61
75
|
for (const destination of destinations) {
|
|
62
76
|
const probe = destination.probe;
|
|
63
77
|
const probeStatus = !probe || probe.status === 'skipped' ? 'skipped' : (probe.available === true ? 'passed' : 'failed');
|
|
@@ -78,7 +92,7 @@ async function runDoctor(destinations) {
|
|
|
78
92
|
checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
|
|
79
93
|
const relayedDestination = proxy.children[0]?.destination;
|
|
80
94
|
if (relayedDestination?.relay) {
|
|
81
|
-
checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url});
|
|
95
|
+
checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); runtime session preflight pending`));
|
|
82
96
|
} else if (destination.source === 'cloud-foundry') {
|
|
83
97
|
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Cloud Foundry destinations connect through the connectivity proxy directly.'));
|
|
84
98
|
} else if (String(runtimeEnv.SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY || '').toLowerCase() === 'true') {
|
|
@@ -96,14 +110,37 @@ async function runDoctor(destinations) {
|
|
|
96
110
|
const tools = listed.result?.tools || [];
|
|
97
111
|
const localNames = new Set(['LintABAP', 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration', 'GenerateRAPRegressionSuite', 'RunRAPRegressionSuite']);
|
|
98
112
|
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
|
|
113
|
+
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
114
|
const systemInfo = tools.find(tool => tool.name.endsWith('__GetSystemInfo'));
|
|
101
115
|
if (!systemInfo) {
|
|
102
116
|
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
|
|
103
117
|
} else {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
let sessionReady = true;
|
|
119
|
+
if (relayedDestination?.relay?.probeCsrfSession) {
|
|
120
|
+
let session;
|
|
121
|
+
let preflightError;
|
|
122
|
+
try {
|
|
123
|
+
session = await relayedDestination.relay.probeCsrfSession('/sap/bc/adt/datapreview/freestyle');
|
|
124
|
+
} catch (error) {
|
|
125
|
+
preflightError = redactText(error.message || error).slice(0, 250);
|
|
126
|
+
}
|
|
127
|
+
sessionReady = session?.sessionUsable === true;
|
|
128
|
+
const status = sessionReady ? 'passed' : 'failed';
|
|
129
|
+
const stats = relayedDestination.relay.stats || {};
|
|
130
|
+
const detail = preflightError
|
|
131
|
+
? `preflight request failed: ${preflightError}`
|
|
132
|
+
: `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}`;
|
|
133
|
+
checks.push(doctorRow(destination.name, 'BAS relay CSRF preflight', status, detail));
|
|
134
|
+
if (!sessionReady) {
|
|
135
|
+
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.';
|
|
136
|
+
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', preflightError ? `${remediation} Relay error: ${preflightError}` : remediation));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (sessionReady) {
|
|
140
|
+
const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
|
|
141
|
+
const failed = inspected?.error || inspected?.result?.isError;
|
|
142
|
+
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'));
|
|
143
|
+
}
|
|
107
144
|
}
|
|
108
145
|
} catch (error) {
|
|
109
146
|
const detail = redactText(error.message || error).slice(0, 300);
|
package/src/mcp-config.mjs
CHANGED
|
@@ -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
|
|
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,53 @@ 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, packageVersion } = {}) {
|
|
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
|
+
const localCommand = await resolveMcpServerCommand(env);
|
|
338
|
+
let repaired = 0;
|
|
339
|
+
for (const [serverName, entry] of Object.entries(config.servers)) {
|
|
340
|
+
if (!isManagedBasDestinationEntry(entry)) continue;
|
|
341
|
+
const destinationName = destinationValue(entry.env);
|
|
342
|
+
if (generatedServerName(destinationName) !== serverName) continue;
|
|
343
|
+
const destination = basDestinations.get(destinationName);
|
|
344
|
+
if (!destination) continue;
|
|
345
|
+
const expected = buildMcpEntries([destination], env)[serverName];
|
|
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
|
+
}
|
|
350
|
+
delete nextEnvironment.BAS_VSP_DESTINATION;
|
|
351
|
+
delete nextEnvironment.BAS_VSP_DESTINATION_SOURCE;
|
|
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
|
+
}
|
|
362
|
+
if (JSON.stringify(nextEntry) === JSON.stringify(entry)) continue;
|
|
363
|
+
config.servers[serverName] = nextEntry;
|
|
364
|
+
repaired += 1;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!repaired) return { path: configPath, changed: false, repaired: 0, managedEntries };
|
|
368
|
+
await writeConfig(configPath, config);
|
|
369
|
+
return { path: configPath, changed: true, repaired, managedEntries };
|
|
370
|
+
}
|
|
371
|
+
|
|
308
372
|
export async function readMcpConfig(path) {
|
|
309
373
|
return readConfig(path);
|
|
310
374
|
}
|
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:
|
|
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,48 @@ 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({
|
|
14
|
+
function sapLikeBackend({
|
|
15
|
+
neverAcceptUnsafe = false,
|
|
16
|
+
rotateOnFetch = false,
|
|
17
|
+
genericForbidden = false,
|
|
18
|
+
partialRefreshCookies = false,
|
|
19
|
+
omitCookies = false,
|
|
20
|
+
setCookieOnSafeGet = false,
|
|
21
|
+
secureSessionCookie = false
|
|
22
|
+
} = {}) {
|
|
15
23
|
const state = { sessionCounter: 0, requests: [] };
|
|
16
24
|
const server = http.createServer((req, res) => {
|
|
17
25
|
state.requests.push({ method: req.method, path: req.url, headers: { ...req.headers } });
|
|
18
26
|
if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
|
|
19
27
|
state.sessionCounter += 1;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'set-cookie'
|
|
23
|
-
|
|
28
|
+
const headers = { 'x-csrf-token': `token-${state.sessionCounter}` };
|
|
29
|
+
if (!omitCookies) {
|
|
30
|
+
headers['set-cookie'] = [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`];
|
|
31
|
+
if (!partialRefreshCookies || state.sessionCounter === 1) {
|
|
32
|
+
headers['set-cookie'].push('sap-usercontext=sap-client=100; path=/');
|
|
33
|
+
}
|
|
34
|
+
if (partialRefreshCookies) {
|
|
35
|
+
headers['set-cookie'].push(state.sessionCounter === 1
|
|
36
|
+
? 'sap-stale=delete-me; path=/'
|
|
37
|
+
: 'sap-stale=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/');
|
|
38
|
+
}
|
|
39
|
+
if (secureSessionCookie) headers['set-cookie'].push('sap-secure=secure-value; Secure; Path=/');
|
|
40
|
+
}
|
|
41
|
+
res.writeHead(200, headers);
|
|
24
42
|
res.end('<ok/>');
|
|
25
43
|
return;
|
|
26
44
|
}
|
|
45
|
+
if (req.method === 'GET' && req.url === '/sap/bc/adt/discovery' && setCookieOnSafeGet) {
|
|
46
|
+
res.writeHead(200, { 'set-cookie': 'bas-read-session=kept; path=/sap/bc/adt' });
|
|
47
|
+
res.end('<read/>');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
27
50
|
if (UNSAFE_METHODS.includes(req.method)) {
|
|
51
|
+
if (genericForbidden) {
|
|
52
|
+
res.writeHead(403);
|
|
53
|
+
res.end('User is not authorized to change this object');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
28
56
|
if (neverAcceptUnsafe) {
|
|
29
57
|
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
30
58
|
res.end('CSRF token validation failed');
|
|
@@ -36,7 +64,11 @@ function sapLikeBackend({ neverAcceptUnsafe = false, rotateOnFetch = false } = {
|
|
|
36
64
|
// Without rotation any still-live token+cookie pair is accepted; with
|
|
37
65
|
// rotation only the newest issued session remains valid, matching SAP
|
|
38
66
|
// server-side session invalidation.
|
|
39
|
-
const valid = match
|
|
67
|
+
const valid = match
|
|
68
|
+
&& cookie.includes(`sap-session-${match[1]}=`)
|
|
69
|
+
&& (!rotateOnFetch || Number(match[1]) === state.sessionCounter)
|
|
70
|
+
&& (!partialRefreshCookies || cookie.includes('sap-usercontext=sap-client=100'))
|
|
71
|
+
&& (!partialRefreshCookies || Number(match[1]) === 1 || !cookie.includes('sap-stale='));
|
|
40
72
|
if (!valid) {
|
|
41
73
|
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
42
74
|
res.end('CSRF token validation failed');
|
|
@@ -79,7 +111,7 @@ async function withBackend(options, handler) {
|
|
|
79
111
|
const relay = createBasDestinationRelay(
|
|
80
112
|
{ name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
|
|
81
113
|
{
|
|
82
|
-
env: { HTTP_PROXY: '' },
|
|
114
|
+
env: { HTTP_PROXY: '', ...(options?.env || {}) },
|
|
83
115
|
fetchImpl: options?.fetchImpl || ((url, init) => globalThis.fetch(url, init)),
|
|
84
116
|
log: message => logs.push(message)
|
|
85
117
|
}
|
|
@@ -115,6 +147,30 @@ test('reuses the cached CSRF session for subsequent unsafe requests', async () =
|
|
|
115
147
|
});
|
|
116
148
|
});
|
|
117
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
|
+
|
|
118
174
|
test('self-heals when SAP rotates the session: refetches token and cookies', async () => {
|
|
119
175
|
await withBackend({ rotateOnFetch: true }, async ({ relay, relayUrl, state }) => {
|
|
120
176
|
// A first POST establishes session token-1. SAP then invalidates it
|
|
@@ -129,6 +185,46 @@ test('self-heals when SAP rotates the session: refetches token and cookies', asy
|
|
|
129
185
|
});
|
|
130
186
|
});
|
|
131
187
|
|
|
188
|
+
test('merges partial session-cookie updates and applies cookie deletion on refresh', async () => {
|
|
189
|
+
await withBackend({ rotateOnFetch: true, partialRefreshCookies: true }, async ({ relayUrl, state }) => {
|
|
190
|
+
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
191
|
+
state.sessionCounter += 1;
|
|
192
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
|
|
193
|
+
assert.equal(response.status, 201, `partial cookie refresh must heal, got ${response.status}`);
|
|
194
|
+
const posts = state.requests.filter(request => request.method === 'POST');
|
|
195
|
+
assert.match(String(posts[2].headers.cookie), /sap-usercontext=sap-client=100/);
|
|
196
|
+
assert.doesNotMatch(String(posts[2].headers.cookie), /sap-stale=/);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('retains cookies received on a safe GET for later CSRF requests', async () => {
|
|
201
|
+
await withBackend({ setCookieOnSafeGet: true }, async ({ relayUrl, state }) => {
|
|
202
|
+
await relayFetch(relayUrl, '/sap/bc/adt/discovery');
|
|
203
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
204
|
+
assert.equal(response.status, 201);
|
|
205
|
+
const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
206
|
+
assert.match(String(tokenFetch.headers.cookie), /bas-read-session=kept/);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('does not retry generic authorization failures as CSRF errors', async () => {
|
|
211
|
+
await withBackend({ genericForbidden: true }, async ({ relayUrl, relay, state }) => {
|
|
212
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
213
|
+
assert.equal(response.status, 403);
|
|
214
|
+
assert.equal(state.requests.filter(request => request.method === 'POST').length, 1);
|
|
215
|
+
assert.equal(relay.stats.csrfRetries, 0);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('does not repeat a CSRF failure when SAP returns a token without session cookies', async () => {
|
|
220
|
+
await withBackend({ omitCookies: true }, async ({ relayUrl, relay, state }) => {
|
|
221
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
222
|
+
assert.equal(response.status, 503, `unsafe calls must fail closed without a paired session, got ${response.status}`);
|
|
223
|
+
assert.equal(state.requests.filter(request => request.method === 'POST').length, 0);
|
|
224
|
+
assert.equal(relay.stats.csrfRetries, 0);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
132
228
|
test('stops retrying after bounded attempts and surfaces the failure', async () => {
|
|
133
229
|
await withBackend({ neverAcceptUnsafe: true }, async ({ relay, relayUrl, state }) => {
|
|
134
230
|
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
@@ -139,6 +235,45 @@ test('stops retrying after bounded attempts and surfaces the failure', async ()
|
|
|
139
235
|
});
|
|
140
236
|
});
|
|
141
237
|
|
|
238
|
+
test('honors the configured bounded CSRF retry count', async () => {
|
|
239
|
+
await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '1' } }, async ({ relayUrl, relay, state }) => {
|
|
240
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
241
|
+
assert.equal(response.status, 403);
|
|
242
|
+
assert.equal(state.requests.filter(request => request.method === 'POST').length, 1 + 1);
|
|
243
|
+
assert.equal(relay.stats.csrfRetries, 1);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
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
|
+
|
|
258
|
+
test('prefers the documented branded proxy setting including an explicit direct-connection override', async t => {
|
|
259
|
+
let dispatchedWithProxy = false;
|
|
260
|
+
const relay = createBasDestinationRelay(
|
|
261
|
+
{ name: 'TEST', url: 'http://test.dest', authentication: 'BasicAuthentication' },
|
|
262
|
+
{
|
|
263
|
+
env: { HTTP_PROXY: 'http://127.0.0.1:1', SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '' },
|
|
264
|
+
fetchImpl: async (_url, options = {}) => {
|
|
265
|
+
dispatchedWithProxy = Boolean(options.dispatcher);
|
|
266
|
+
return new Response('<ok/>', { status: 200, headers: { 'x-csrf-token': 'token', 'set-cookie': 'sap-session=s; path=/' } });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
t.after(() => relay.close());
|
|
271
|
+
await relay.ready;
|
|
272
|
+
const probe = await relay.probeCsrfSession();
|
|
273
|
+
assert.equal(probe.sessionUsable, true);
|
|
274
|
+
assert.equal(dispatchedWithProxy, false, 'an explicit empty branded proxy setting means direct traffic');
|
|
275
|
+
});
|
|
276
|
+
|
|
142
277
|
test('GET requests pass through without CSRF handling', async () => {
|
|
143
278
|
await withBackend(null, async ({ relayUrl, state, relay }) => {
|
|
144
279
|
const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery', { headers: { accept: 'application/xml' } });
|
|
@@ -189,7 +324,7 @@ test('falls back to a proxy tunnel when the absolute-form request is refused', a
|
|
|
189
324
|
});
|
|
190
325
|
|
|
191
326
|
test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF session', async t => {
|
|
192
|
-
const { state, server } = sapLikeBackend();
|
|
327
|
+
const { state, server } = sapLikeBackend({ secureSessionCookie: true });
|
|
193
328
|
const backendPort = await startServer(server);
|
|
194
329
|
const connectTargets = [];
|
|
195
330
|
const proxy = http.createServer();
|
|
@@ -224,12 +359,13 @@ test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF ses
|
|
|
224
359
|
});
|
|
225
360
|
|
|
226
361
|
const probe = await relay.probeCsrfSession();
|
|
227
|
-
assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount:
|
|
362
|
+
assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 3, sessionUsable: true });
|
|
228
363
|
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
229
364
|
assert.equal(response.status, 201, `expected a paired CSRF session, got ${response.status}: ${response.body}`);
|
|
230
365
|
const post = state.requests.find(request => request.method === 'POST');
|
|
231
366
|
assert.match(post.headers.authorization, /^Basic /);
|
|
232
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');
|
|
233
369
|
assert.ok(connectTargets.length >= 2);
|
|
234
370
|
assert.ok(connectTargets.every(target => target === 'cc-system.dest:80'));
|
|
235
371
|
assert.equal(relay.stats.direct, false);
|
package/test/discovery.test.mjs
CHANGED
|
@@ -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 = [];
|
|
@@ -202,6 +231,26 @@ test('fetches BAS destination lists through configured HTTP and HTTPS proxies',
|
|
|
202
231
|
assert.equal(proxyRequests.length, 2);
|
|
203
232
|
});
|
|
204
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
|
+
|
|
205
254
|
test('redacts credential-bearing diagnostics without exposing payloads', () => {
|
|
206
255
|
const value = redact({ name: 'dev', password: 'secret', authorization: 'Bearer abc', nested: { cookie: 'x', ok: 'visible' } });
|
|
207
256
|
assert.deepEqual(value, { name: 'dev', password: '[redacted]', authorization: '[redacted]', nested: { cookie: '[redacted]', ok: 'visible' } });
|
|
@@ -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 = '', 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');
|
|
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 = bypassProxyForLoopback && ['127.0.0.1', 'localhost'].includes(target.hostname);
|
|
20
22
|
return new Promise((resolve, reject) => {
|
|
21
|
-
const
|
|
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
|
|
32
|
+
method,
|
|
25
33
|
path: target.href,
|
|
26
34
|
headers
|
|
27
|
-
}
|
|
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/>', bypassProxyForLoopback: true });
|
|
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
|
}
|
package/test/launcher.test.mjs
CHANGED
|
@@ -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,134 @@ 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 strictDestination = { ...bas, name: 'strict', serverName: 'strict', url: 'http://strict.dest' };
|
|
100
|
+
const managed = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
|
|
101
|
+
const strictManaged = buildMcpEntries([strictDestination], { H2O_URL: 'http://old-h2o.example' }).strict;
|
|
102
|
+
const cfEntry = buildMcpEntries([cf], { H2O_URL: 'http://cf-h2o.example' })[cf.serverName];
|
|
103
|
+
const companion = buildSapDevelopmentMcpEntries(['sap-fiori-tools'])['sap-fiori-tools'];
|
|
104
|
+
const unrelated = { type: 'stdio', command: 'external-server' };
|
|
105
|
+
const unverifiedManaged = {
|
|
106
|
+
...managed,
|
|
107
|
+
env: { ...managed.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'temporarily-unavailable' }
|
|
108
|
+
};
|
|
109
|
+
const config = {
|
|
110
|
+
userFlag: true,
|
|
111
|
+
servers: {
|
|
112
|
+
shared: { ...managed, env: { ...managed.env, H2O_URL: 'http://old-h2o.example' } },
|
|
113
|
+
strict: { ...strictManaged, env: { ...strictManaged.env, SAP_ALLOW_TRANSPORTABLE_EDITS: 'false' } },
|
|
114
|
+
'temporarily-unavailable': unverifiedManaged,
|
|
115
|
+
[cf.serverName]: cfEntry,
|
|
116
|
+
'sap-fiori-tools': companion,
|
|
117
|
+
ActionS4D_100: unrelated
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
await writeFile(path, JSON.stringify(config, null, 2));
|
|
121
|
+
|
|
122
|
+
const first = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
123
|
+
assert.equal(first.changed, true);
|
|
124
|
+
assert.equal(first.repaired, 2);
|
|
125
|
+
const repaired = await readMcpConfig(path);
|
|
126
|
+
assert.equal(repaired.userFlag, true);
|
|
127
|
+
assert.equal(repaired.servers.shared.env.H2O_URL, 'http://current-h2o.example');
|
|
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");
|
|
130
|
+
assert.deepEqual(repaired.servers['temporarily-unavailable'], unverifiedManaged);
|
|
131
|
+
assert.deepEqual(repaired.servers[cf.serverName], cfEntry);
|
|
132
|
+
assert.deepEqual(repaired.servers['sap-fiori-tools'], companion);
|
|
133
|
+
assert.deepEqual(repaired.servers.ActionS4D_100, unrelated);
|
|
134
|
+
assert.equal(repaired.servers.newDestination, undefined, 'repair must not add unselected destinations');
|
|
135
|
+
|
|
136
|
+
const beforeSecondRepair = await readFile(path, 'utf8');
|
|
137
|
+
const second = await repairManagedMcpConfig([bas, strictDestination], { env: { H2O_URL: 'http://current-h2o.example' }, path, discoveryComplete: true });
|
|
138
|
+
assert.equal(second.changed, false);
|
|
139
|
+
assert.equal(second.repaired, 0);
|
|
140
|
+
assert.equal(await readFile(path, 'utf8'), beforeSecondRepair, 'an already-healed config must not be rewritten');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('doctor repair leaves malformed config untouched', async t => {
|
|
144
|
+
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-invalid-'));
|
|
145
|
+
const path = join(directory, 'mcp.json');
|
|
146
|
+
const malformed = '{ "servers": ';
|
|
147
|
+
await writeFile(path, malformed);
|
|
148
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
149
|
+
await assert.rejects(
|
|
150
|
+
() => repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example' }, path, discoveryComplete: true }),
|
|
151
|
+
/invalid JSON/
|
|
152
|
+
);
|
|
153
|
+
assert.equal(await readFile(path, 'utf8'), malformed);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('doctor repair skips config changes if destination discovery is incomplete', async t => {
|
|
157
|
+
const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-doctor-repair-incomplete-'));
|
|
158
|
+
const path = join(directory, 'mcp.json');
|
|
159
|
+
const entry = buildMcpEntries([bas], { H2O_URL: 'http://old-h2o.example' }).shared;
|
|
160
|
+
const initial = JSON.stringify({ servers: { shared: entry } });
|
|
161
|
+
await writeFile(path, initial);
|
|
162
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
163
|
+
const result = await repairManagedMcpConfig([bas], { env: { H2O_URL: 'http://new-h2o.example' }, path });
|
|
164
|
+
assert.equal(result.changed, false);
|
|
165
|
+
assert.match(result.skipped, /incomplete/);
|
|
166
|
+
assert.equal(await readFile(path, 'utf8'), initial);
|
|
167
|
+
});
|
|
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
|
+
|
|
81
209
|
test('extracts key references only from package-managed Cloud Foundry entries', () => {
|
|
82
210
|
const cloudFoundry = buildMcpEntries([
|
|
83
211
|
cfDestination('space-one', 'instance-one', 'destination-key', 'OnPremise')
|
package/test/mcp-proxy.test.mjs
CHANGED
|
@@ -271,6 +271,36 @@ 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 logs = [];
|
|
279
|
+
const proxy = new MCPProxy({
|
|
280
|
+
binary: fixture,
|
|
281
|
+
destinations: [{ name: 'ActionS4D_100', url: 'http://ActionS4D_100.dest', client: '100' }],
|
|
282
|
+
env: { ...process.env, SAP_AI_DEV_TOOLKIT_MODE: 'expert', FAKE_LOG: log },
|
|
283
|
+
log: message => logs.push(message)
|
|
284
|
+
});
|
|
285
|
+
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|
|
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(' | ')}`);
|
|
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));
|
|
291
|
+
const tool = listed.result.tools.find(candidate => candidate.name === 'actions4d-100__WriteSource');
|
|
292
|
+
assert.ok(tool, 'tools/list must publish the normalized destination namespace');
|
|
293
|
+
const called = await proxy.handle({
|
|
294
|
+
jsonrpc: '2.0',
|
|
295
|
+
id: 3,
|
|
296
|
+
method: 'tools/call',
|
|
297
|
+
params: { name: tool.name, arguments: { source: 'REPORT zself_heal.' } }
|
|
298
|
+
});
|
|
299
|
+
assert.equal(called.error, undefined);
|
|
300
|
+
assert.equal(called.result.content[0].text, 'ActionS4D_100:WriteSource');
|
|
301
|
+
const calls = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).filter(entry => entry.event === 'call');
|
|
302
|
+
assert.deepEqual(calls.map(entry => entry.name), ['WriteSource']);
|
|
303
|
+
});
|
|
274
304
|
test('runs LintABAP locally and returns parser findings', async t => {
|
|
275
305
|
const { directory, log, proxy } = await fixtureProxy();
|
|
276
306
|
t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
|