sap-ai-dev-toolkit 0.3.0 → 0.3.3

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.
@@ -0,0 +1,271 @@
1
+ import http from 'node:http';
2
+ import { fetch as undiciFetch, ProxyAgent } from 'undici';
3
+
4
+ const DEFAULT_PROXY = 'http://127.0.0.1:8887';
5
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
6
+ const HOP_BY_HOP = new Set([
7
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
8
+ 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host'
9
+ ]);
10
+ const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
11
+ // SAP ties CSRF tokens to the session that fetched them. The BAS .dest proxy
12
+ // authenticates each hop, so the relay pairs every token with the exact
13
+ // Set-Cookie state SAP returned alongside it and replays both on the unsafe
14
+ // request. Without this pairing SAP sees a token from a foreign session and
15
+ // answers "CSRF token validation failed" with HTTP 403.
16
+ const TOKEN_TTL_MS = 15 * 60 * 1000;
17
+ const MAX_TOKEN_FAILURES = 3;
18
+ const MAX_TUNNEL_FALLBACKS = 2;
19
+ // Carrying a POST's content-length on the token-fetch GET would make the
20
+ // proxy wait for a body that never arrives, so the relay drops body headers.
21
+ const BODY_HEADERS = new Set(['content-length', 'content-type']);
22
+ // The forwarded request's content-length must describe the body undici is
23
+ // about to send. Forwarding the inbound value alongside a re-buffered body
24
+ // makes undici reject the request (UND_ERR_INVALID_ARG), so it is always
25
+ // recomputed from the actual payload.
26
+ const LENGTH_HEADERS = new Set(['content-length', 'transfer-encoding']);
27
+ // undici transparently decompresses gzip/deflate/br responses but keeps the
28
+ // content-encoding header, so forwarding the client's accept-encoding would
29
+ // hand VSP a "gzip" body that is actually plain bytes. Dropping the header
30
+ // lets undici request identity encoding and keeps bodies consistent.
31
+ const ENCODING_HEADERS = new Set(['accept-encoding', 'content-encoding']);
32
+
33
+ function headersFrom(requestHeaders, extra = {}) {
34
+ const headers = {};
35
+ for (const [name, value] of Object.entries(requestHeaders || {})) {
36
+ const lower = name.toLowerCase();
37
+ if (HOP_BY_HOP.has(lower) || LENGTH_HEADERS.has(lower) || ENCODING_HEADERS.has(lower)) continue;
38
+ if (value !== undefined) headers[name] = Array.isArray(value) ? value.join(', ') : String(value);
39
+ }
40
+ return { ...headers, ...extra };
41
+ }
42
+
43
+ function fetchHeaders(requestHeaders, extra = {}) {
44
+ const headers = {};
45
+ for (const [name, value] of Object.entries(requestHeaders || {})) {
46
+ const lower = name.toLowerCase();
47
+ if (HOP_BY_HOP.has(lower) || BODY_HEADERS.has(lower) || ENCODING_HEADERS.has(lower)) continue;
48
+ if (value !== undefined) headers[name] = Array.isArray(value) ? value.join(', ') : String(value);
49
+ }
50
+ return { ...headers, ...extra };
51
+ }
52
+
53
+ function tunnelFailure(status) {
54
+ // 502/504 from the proxy itself means the absolute-form .dest request is
55
+ // being refused; worth retrying through a CONNECT tunnel.
56
+ return status === 502 || status === 504;
57
+ }
58
+
59
+ function csrfFailure(status, headers, body) {
60
+ return status === 403 && (
61
+ String(headers.get?.('x-csrf-token') || '').toLowerCase() === 'required' ||
62
+ String(body || '').toLowerCase().includes('csrf token validation failed')
63
+ );
64
+ }
65
+
66
+ function authFailure(status) {
67
+ return status === 401 || status === 403;
68
+ }
69
+
70
+ function cookieHeader(jar) {
71
+ return jar.size ? [...jar].map(([name, value]) => `${name}=${value}`).join('; ') : undefined;
72
+ }
73
+
74
+ function recordCookies(jar, headers) {
75
+ for (const value of headers.getSetCookie?.() || []) {
76
+ const [pair] = String(value).split(';');
77
+ const separator = pair.indexOf('=');
78
+ if (separator <= 0) continue;
79
+ const name = pair.slice(0, separator).trim();
80
+ if (!name) continue;
81
+ if (/expires=thu, 01 jan 1970/i.test(String(value))) jar.delete(name);
82
+ else jar.set(name, pair.slice(separator + 1).trim());
83
+ }
84
+ }
85
+
86
+ async function readRequestBody(req) {
87
+ const chunks = [];
88
+ let size = 0;
89
+ for await (const chunk of req) {
90
+ size += chunk.length;
91
+ if (size > MAX_BODY_BYTES) throw new Error('request body is too large for BAS destination relay');
92
+ chunks.push(chunk);
93
+ }
94
+ return Buffer.concat(chunks);
95
+ }
96
+
97
+ export function createBasDestinationRelay(destination, { env = process.env, fetchImpl = undiciFetch, log = () => {} } = {}) {
98
+ if (!destination?.url) throw new Error('Destination relay requires a BAS destination URL');
99
+ const base = new URL(destination.url);
100
+ // Self-healing mode 5 (direct connect): the BAS .dest proxy strips
101
+ // Set-Cookie, which makes SAP CSRF session binding impossible. When the
102
+ // operator stored credentials for this destination (setup prompts for
103
+ // Internet destinations), the relay connects straight to the backend host
104
+ // with Basic auth — cookies survive, CSRF pairing works, and writes go
105
+ // through exactly like they do from Eclipse/ADT.
106
+ const credentials = destination.credentials;
107
+ const directBase = credentials?.host ? new URL(String(credentials.host)) : null;
108
+ const effectiveBase = directBase || base;
109
+ if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
110
+ // Proxy resolution: an explicitly configured proxy wins (an explicit empty
111
+ // value means "go direct"). Without configuration, BAS virtual .dest hosts
112
+ // imply the default BAS proxy; any other host (tests, direct URLs) is
113
+ // reached directly so the relay never routes loopback traffic through a
114
+ // foreign proxy.
115
+ const explicitProxy = env.HTTP_PROXY ?? env.http_proxy;
116
+ const proxyUrl = directBase ? '' : (explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : ''));
117
+ const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
118
+ // Self-healing mode 2: if the BAS proxy refuses absolute-form proxied
119
+ // requests (its own 502/504), retry through a CONNECT tunnel so the relay
120
+ // keeps working through the same egress.
121
+ const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
122
+ let tunneled = false;
123
+ // Observable self-healing counters, surfaced by --doctor and tests.
124
+ const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, tunnelFallbacks: 0, direct: Boolean(directBase) };
125
+ // Per-path CSRF sessions: { token, jar, fetchedAt } where jar holds the
126
+ // Set-Cookie state SAP returned together with the token.
127
+ const tokenCache = new Map();
128
+
129
+ function rememberSession(key, token, headers) {
130
+ const jar = new Map();
131
+ recordCookies(jar, headers);
132
+ tokenCache.set(key, { token, jar, fetchedAt: Date.now() });
133
+ }
134
+
135
+ async function send(target, method, headers, body) {
136
+ const useTunnel = tunneled && tunnelDispatcher;
137
+ return fetchImpl(target, {
138
+ method,
139
+ headers,
140
+ body: body?.length ? body : undefined,
141
+ ...(useTunnel ? { dispatcher: tunnelDispatcher } : dispatcher ? { dispatcher } : {})
142
+ });
143
+ }
144
+
145
+ // Try absolute-form first; on transport errors or proxy-level failures,
146
+ // flip to tunneled mode once and retry (sticky for later requests).
147
+ async function sendWithFallback(target, method, headers, body) {
148
+ if (tunneled) return send(target, method, headers, body);
149
+ try {
150
+ const response = await send(target, method, headers, body);
151
+ if (!tunnelFailure(response.status)) return response;
152
+ log(`[${destination.name}] BAS relay proxy returned HTTP ${response.status} for ${target.pathname}; switching to proxy tunnel`);
153
+ } catch (error) {
154
+ log(`[${destination.name}] BAS relay absolute-form request failed for ${target.pathname} (${error.message}); switching to proxy tunnel`);
155
+ }
156
+ tunneled = true;
157
+ stats.tunnelFallbacks += 1;
158
+ return send(target, method, headers, body);
159
+ }
160
+
161
+ async function fetchToken(requestUrl, requestHeaders) {
162
+ const key = requestUrl.pathname;
163
+ const cached = tokenCache.get(key);
164
+ if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
165
+ stats.csrfFetches += 1;
166
+ const response = await sendWithFallback(resolveTarget(requestUrl), 'GET', authHeaders(fetchHeaders(requestHeaders, {
167
+ 'x-csrf-token': 'Fetch',
168
+ accept: requestHeaders.accept || 'application/xml,*/*',
169
+ ...(cached?.jar?.size ? { cookie: cookieHeader(cached.jar) } : {})
170
+ })));
171
+ await response.arrayBuffer().catch(() => undefined);
172
+ const token = response.headers.get('x-csrf-token');
173
+ if (token && token.toLowerCase() !== 'required') {
174
+ rememberSession(key, token, response.headers);
175
+ return tokenCache.get(key);
176
+ }
177
+ return null;
178
+ }
179
+
180
+ // Rewrite an incoming .dest target onto the direct backend host when
181
+ // credentials are configured, keeping path and query intact.
182
+ function resolveTarget(target) {
183
+ if (!directBase) return target;
184
+ return new URL(`${target.pathname}${target.search}`, directBase);
185
+ }
186
+
187
+ function authHeaders(extra = {}) {
188
+ if (!credentials?.user || !credentials?.password) return extra;
189
+ const basic = Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64');
190
+ return { authorization: `Basic ${basic}`, ...extra };
191
+ }
192
+
193
+ async function forward(method, requestUrl, requestHeaders, body, session) {
194
+ const headers = authHeaders(headersFrom(requestHeaders, {
195
+ ...(session?.token ? { 'x-csrf-token': session.token } : {}),
196
+ ...(session?.jar?.size ? { cookie: cookieHeader(session.jar) } : {})
197
+ }));
198
+ const response = await sendWithFallback(resolveTarget(requestUrl), method, headers, body);
199
+ const responseBody = Buffer.from(await response.arrayBuffer());
200
+ if (session) recordCookies(session.jar, response.headers);
201
+ return { response, body: responseBody };
202
+ }
203
+
204
+ // Self-healing mode 1: when SAP rejects a token (expired, rotated, or
205
+ // invalidated server-side), drop the cached session and retry with a
206
+ // freshly fetched token paired with its own cookies. Bounded retries keep
207
+ // a failing destination from looping forever.
208
+ async function forwardWithRetry(method, requestUrl, requestHeaders, body, failures = 0) {
209
+ const unsafe = UNSAFE.has(method);
210
+ const session = unsafe ? await fetchToken(requestUrl, requestHeaders) : null;
211
+ if (unsafe && !session) log(`[${destination.name}] BAS relay could not obtain CSRF token for ${requestUrl.pathname}`);
212
+ const { response, body: responseBody } = await forward(method, requestUrl, requestHeaders, body, session);
213
+ const rejected = csrfFailure(response.status, response.headers, responseBody) || (authFailure(response.status) && session);
214
+ if (unsafe && rejected && failures < MAX_TOKEN_FAILURES) {
215
+ log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${MAX_TOKEN_FAILURES})`);
216
+ stats.csrfRetries += 1;
217
+ tokenCache.delete(requestUrl.pathname);
218
+ return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
219
+ }
220
+ return { response, body: responseBody };
221
+ }
222
+
223
+ const server = http.createServer((req, res) => {
224
+ void (async () => {
225
+ const method = String(req.method || 'GET').toUpperCase();
226
+ const requestUrl = new URL(req.url || '/', base);
227
+ stats.requests += 1;
228
+ const body = UNSAFE.has(method) ? await readRequestBody(req) : Buffer.alloc(0);
229
+ const { response, body: responseBody } = await forwardWithRetry(method, requestUrl, req.headers, body);
230
+ const responseHeaders = {};
231
+ for (const [name, value] of response.headers) {
232
+ const lower = name.toLowerCase();
233
+ // undici decompresses response bodies; the stale content-encoding and
234
+ // content-length would misdescribe the plain bytes handed to VSP.
235
+ if (HOP_BY_HOP.has(lower) || lower === 'content-encoding' || lower === 'content-length') continue;
236
+ responseHeaders[name] = value;
237
+ }
238
+ responseHeaders['x-sap-ai-dev-toolkit-relay'] = 'bas-destination';
239
+ res.writeHead(response.status, response.statusText, responseHeaders);
240
+ res.end(responseBody);
241
+ })().catch(error => {
242
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'connection': 'close' });
243
+ res.end(`BAS destination relay failed: ${error.message}\n`);
244
+ });
245
+ });
246
+
247
+ // Bind eagerly and report the real port through `ready`. Callers must
248
+ // await `ready` before handing the URL to a VSP child; binding failures
249
+ // surface as a rejected promise instead of a silently wrong guessed port.
250
+ const listening = new Promise((resolve, reject) => {
251
+ server.once('error', reject);
252
+ server.listen(0, '127.0.0.1', () => {
253
+ server.removeListener('error', reject);
254
+ resolve(server.address().port);
255
+ });
256
+ });
257
+ const ready = listening.then(port => `http://127.0.0.1:${port}`).catch(error => {
258
+ log(`[${destination.name}] BAS relay failed to start: ${error.message}`);
259
+ throw error;
260
+ });
261
+ return {
262
+ ready,
263
+ stats,
264
+ close: async () => {
265
+ await dispatcher?.close?.();
266
+ await tunnelDispatcher?.close?.();
267
+ server.closeAllConnections?.();
268
+ await new Promise(resolve => server.close(() => resolve()));
269
+ }
270
+ };
271
+ }
@@ -52,11 +52,15 @@ export function normalizeDestination(item) {
52
52
  const name = text(values.name || values.destinationname || values.destname);
53
53
  const authentication = text(values.authentication || values.authtype || values.auth);
54
54
  const client = text(values.sapclient || values.client) || '001';
55
+ const backendUrl = text(values.url || values.host || values.webideexposedhost);
56
+ const proxyType = text(values.proxytype || values.proxy);
55
57
  return {
56
58
  name,
57
59
  authentication: authentication || 'Unknown',
58
60
  client,
59
61
  url: name ? destinationUrl(name) : null,
62
+ backendUrl: backendUrl || null,
63
+ proxyType: proxyType || null,
60
64
  rawKeys: Object.keys(item || {}).map(String)
61
65
  };
62
66
  }
@@ -0,0 +1,114 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { brandedEnvValue } from './branding.mjs';
6
+
7
+ // Credentials for direct-connect destinations live next to the MCP config
8
+ // (never inside it), with 0600 permissions and no tokens or cookies. The
9
+ // file maps destination names to { host, user, password, updatedAt }.
10
+ const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
11
+
12
+ const SECRET_PATTERN = /(authorization|cookie|password|secret|token|bearer|credential)/i;
13
+
14
+ async function exists(path) {
15
+ try {
16
+ await stat(path);
17
+ return true;
18
+ } catch (error) {
19
+ if (error?.code === 'ENOENT') return false;
20
+ throw error;
21
+ }
22
+ }
23
+
24
+ export function redactCredentialValue(value) {
25
+ return SECRET_PATTERN.test(String(value)) ? '[redacted]' : value;
26
+ }
27
+
28
+ export async function resolveCredentialsPath(env = process.env, mcpConfigPath) {
29
+ const configured = brandedEnvValue(env, 'CREDENTIALS_FILE');
30
+ if (configured) return configured;
31
+ if (mcpConfigPath) return join(dirname(mcpConfigPath), CREDENTIALS_FILENAME);
32
+ const home = env.HOME || homedir();
33
+ const candidates = [
34
+ join(home, '.vscode', 'data', 'User'),
35
+ join(home, '.vscode-server', 'data', 'User'),
36
+ join(home, '.code-server', 'data', 'User')
37
+ ];
38
+ for (const candidate of candidates) if (await exists(candidate)) return join(candidate, CREDENTIALS_FILENAME);
39
+ return join(candidates[0], CREDENTIALS_FILENAME);
40
+ }
41
+
42
+ function sanitizeEntry(entry) {
43
+ const result = {};
44
+ if (typeof entry?.host === 'string' && entry.host) result.host = entry.host;
45
+ if (typeof entry?.user === 'string' && entry.user) result.user = entry.user;
46
+ if (typeof entry?.password === 'string' && entry.password) result.password = entry.password;
47
+ if (typeof entry?.updatedAt === 'string') result.updatedAt = entry.updatedAt;
48
+ return result;
49
+ }
50
+
51
+ export async function readCredentials(path) {
52
+ let raw;
53
+ try {
54
+ raw = await readFile(path, 'utf8');
55
+ } catch (error) {
56
+ if (error?.code === 'ENOENT') return {};
57
+ throw new Error(`Credentials file ${path} could not be read: ${error.message}`);
58
+ }
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(raw);
62
+ } catch (error) {
63
+ throw new Error(`Credentials file ${path} contains invalid JSON: ${error.message}`);
64
+ }
65
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
66
+ throw new Error(`Credentials file ${path} must contain a top-level JSON object`);
67
+ }
68
+ const destinations = {};
69
+ for (const [name, entry] of Object.entries(parsed.destinations || {})) {
70
+ const clean = sanitizeEntry(entry);
71
+ if (clean.user && clean.password) destinations[name] = clean;
72
+ }
73
+ return { destinations };
74
+ }
75
+
76
+ async function writeCredentials(path, destinations) {
77
+ const directory = dirname(path);
78
+ await mkdir(directory, { recursive: true });
79
+ const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
80
+ try {
81
+ await writeFile(temporary, `${JSON.stringify({ version: 1, destinations }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
82
+ await rename(temporary, path);
83
+ await chmod(path, 0o600).catch(() => {});
84
+ } catch (error) {
85
+ await rm(temporary, { force: true }).catch(() => {});
86
+ throw new Error(`Credentials file ${path} could not be written: ${error.message}`);
87
+ }
88
+ }
89
+
90
+ export async function storeDestinationCredentials(path, name, { host, user, password }) {
91
+ const current = await readCredentials(path).catch(error => { throw error; });
92
+ const destinations = { ...current.destinations };
93
+ destinations[String(name)] = { host: String(host || ''), user: String(user || ''), password: String(password || ''), updatedAt: new Date().toISOString() };
94
+ await writeCredentials(path, destinations);
95
+ return { path, name };
96
+ }
97
+
98
+ export async function removeDestinationCredentials(path, names) {
99
+ const current = await readCredentials(path);
100
+ const destinations = { ...current.destinations };
101
+ const removed = [];
102
+ for (const name of names) {
103
+ if (Object.hasOwn(destinations, String(name))) {
104
+ delete destinations[String(name)];
105
+ removed.push(String(name));
106
+ }
107
+ }
108
+ if (removed.length) await writeCredentials(path, destinations);
109
+ return { path, removed };
110
+ }
111
+
112
+ export function listCredentialDestinations(credentials) {
113
+ return Object.entries(credentials?.destinations || {}).map(([name, entry]) => ({ name, host: entry.host, user: entry.user, updatedAt: entry.updatedAt }));
114
+ }
package/src/launcher.mjs CHANGED
@@ -10,10 +10,36 @@ import { installMcpConfig } 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';
13
+ import { readCredentials, resolveCredentialsPath } from './credentials-store.mjs';
13
14
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
14
15
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
15
16
  const runtimeEnv = withBrandedEnvironment(process.env);
16
17
 
18
+ // Attach stored direct-connect credentials (setup prompts for Internet
19
+ // destinations) to matching discovered destinations. Credentials stay in
20
+ // memory only; nothing is logged or written to the MCP config.
21
+ async function enrichWithStoredCredentials(destinations, env = runtimeEnv) {
22
+ const needingCredentials = destinations.filter(destination =>
23
+ destination.source !== 'cloud-foundry'
24
+ && destination.url
25
+ && String(destination.url).includes('.dest'));
26
+ if (!needingCredentials.length) return destinations;
27
+ let stored;
28
+ try {
29
+ const path = await resolveCredentialsPath(env);
30
+ stored = await readCredentials(path);
31
+ } catch (error) {
32
+ console.error(`[sap-ai-dev] stored credentials could not be read: ${String(error.message).slice(0, 200)}`);
33
+ return destinations;
34
+ }
35
+ return destinations.map(destination => {
36
+ const entry = stored.destinations?.[destination.name];
37
+ if (!entry?.host || !entry.user || !entry.password) return destination;
38
+ if (!destination.backendUrl) return destination;
39
+ return { ...destination, credentials: { host: entry.host, user: entry.user, password: entry.password } };
40
+ });
41
+ }
42
+
17
43
  function hasFlag(name) { return process.argv.slice(2).includes(name); }
18
44
  function json(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
19
45
 
@@ -75,6 +101,19 @@ async function runDoctor(destinations) {
75
101
  const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
76
102
  if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
77
103
  checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
104
+ const relayedDestination = proxy.children[0]?.destination;
105
+ if (relayedDestination?.relay) {
106
+ checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); CSRF token/session pairing active`));
107
+ } else if (destination.source === 'cloud-foundry') {
108
+ checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Cloud Foundry destinations connect through the connectivity proxy directly.'));
109
+ } else if (String(runtimeEnv.SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY || '').toLowerCase() === 'true') {
110
+ checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Disabled through SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY.'));
111
+ } else {
112
+ checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
113
+ }
114
+ if (relayedDestination?.credentials) {
115
+ checks.push(doctorRow(destination.name, 'Direct connect', 'passed', `active (${new URL(relayedDestination.credentials.host).origin}); ADT writes use stored credentials`));
116
+ }
78
117
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
79
118
  if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
80
119
  const tools = listed.result?.tools || [];
@@ -165,7 +204,7 @@ async function main() {
165
204
  try {
166
205
  const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
167
206
  ? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
168
- : await discoverForCommand();
207
+ : await enrichWithStoredCredentials(await discoverForCommand());
169
208
  report = await runDoctor(destinations);
170
209
  } catch (error) {
171
210
  report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
@@ -231,12 +270,15 @@ async function main() {
231
270
 
232
271
  const discovered = await discoverDestinations({ env: runtimeEnv });
233
272
  for (const destination of discovered) console.error(probeDiagnostic(destination));
234
- const destinations = discovered;
273
+ const destinations = await enrichWithStoredCredentials(discovered);
235
274
  if (!destinations.length) {
236
275
  console.error('[sap-ai-dev] destination discovery returned no named BAS destinations');
237
276
  throw new Error(remediation);
238
277
  }
239
278
  const binary = await binaryOrError();
279
+ for (const destination of destinations) {
280
+ if (destination.credentials) console.error(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
281
+ }
240
282
  console.error(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
241
283
  const proxy = new MCPProxy({ binary, destinations, env: process.env, log: message => console.error(message) });
242
284
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };