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