sap-ai-dev-toolkit 0.7.5 → 0.7.9

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.
@@ -69,17 +69,43 @@ async function planDestinations(harness, files) {
69
69
  for (const file of files) {
70
70
  const raw = await readFile(file.source);
71
71
  if (!file.target.startsWith(`agents${sep}`)) {
72
- destinations.push({ target: file.target, content: raw });
72
+ destinations.push({ target: file.target, content: raw, aliases: [] });
73
73
  continue;
74
74
  }
75
75
  if (!harness.supportsAgents) continue;
76
- const fileName = harness.agentFileName ? harness.agentFileName(basename(file.target)) : basename(file.target);
76
+ const sourceName = basename(file.target);
77
+ const fileName = harness.agentFileName ? harness.agentFileName(sourceName) : sourceName;
78
+ const prefix = harness.agentTargetPrefix || 'agents';
79
+ const target = join(prefix, fileName);
80
+ const aliasCandidates = [
81
+ file.target,
82
+ join(prefix, sourceName),
83
+ join('agents', fileName)
84
+ ];
85
+ const aliases = [...new Set(aliasCandidates.filter(alias => alias !== target))];
77
86
  const content = harness.agentDocument ? Buffer.from(harness.agentDocument(raw.toString('utf8')), 'utf8') : raw;
78
- destinations.push({ target: join('agents', fileName), content });
87
+ destinations.push({ target, content, aliases });
79
88
  }
80
89
  return destinations;
81
90
  }
82
91
 
92
+ async function reconcileRemovableFile({ targetRoot, target, contentHash, previousFiles }) {
93
+ const path = join(targetRoot, target);
94
+ let existing;
95
+ try {
96
+ existing = await readFile(path);
97
+ } catch (error) {
98
+ if (error.code === 'ENOENT') return 'missing';
99
+ throw error;
100
+ }
101
+ const existingHash = digest(existing);
102
+ if (previousFiles[target] === existingHash || existingHash === contentHash) {
103
+ await rm(path, { force: true });
104
+ return 'removed';
105
+ }
106
+ return 'conflict';
107
+ }
108
+
83
109
  export async function installUserAssets({ harness, home = process.env.HOME || homedir(), root = packageRoot, env = process.env } = {}) {
84
110
  const destinations = await planDestinations(harness, await collectSourceFiles(root));
85
111
  const targetRoot = harnessRoot(harness, { home, env });
@@ -89,15 +115,22 @@ export async function installUserAssets({ harness, home = process.env.HOME || ho
89
115
  const legacyFiles = harness.legacyManifest ? await readManagedFiles(legacyManifestPath) : {};
90
116
  const currentFiles = await readManagedFiles(manifestPath);
91
117
  const previousFiles = { ...legacyFiles, ...currentFiles };
92
- const managedFiles = { ...previousFiles };
118
+ const managedFiles = {};
119
+ const activeTargets = new Set(destinations.flatMap(file => [file.target, ...file.aliases]));
93
120
  const conflicts = [];
94
121
  let installed = 0;
95
122
  let updated = 0;
96
123
  let unchanged = 0;
124
+ let removed = 0;
97
125
 
98
126
  for (const file of destinations) {
99
127
  const destination = join(targetRoot, file.target);
100
128
  const contentHash = digest(file.content);
129
+ for (const alias of file.aliases) {
130
+ const status = await reconcileRemovableFile({ targetRoot, target: alias, contentHash, previousFiles });
131
+ if (status === 'removed') removed += 1;
132
+ if (status === 'conflict') conflicts.push(alias);
133
+ }
101
134
  await mkdir(dirname(destination), { recursive: true, mode: 0o755 });
102
135
 
103
136
  let existing;
@@ -129,9 +162,16 @@ export async function installUserAssets({ harness, home = process.env.HOME || ho
129
162
  }
130
163
  }
131
164
 
165
+ for (const target of Object.keys(previousFiles)) {
166
+ if (activeTargets.has(target) || managedFiles[target]) continue;
167
+ const status = await reconcileRemovableFile({ targetRoot, target, contentHash: '', previousFiles });
168
+ if (status === 'removed') removed += 1;
169
+ if (status === 'conflict') conflicts.push(target);
170
+ }
171
+
132
172
  await replaceFile(manifestPath, `${JSON.stringify({ version: 1, files: managedFiles }, null, 2)}\n`);
133
173
  if (harness.legacyManifest) await rm(legacyManifestPath, { force: true });
134
- return { harness: harness.id, label: harness.label, root: targetRoot, installed, updated, unchanged, conflicts };
174
+ return { harness: harness.id, label: harness.label, root: targetRoot, installed, updated, unchanged, removed, conflicts };
135
175
  }
136
176
 
137
177
  export async function installUserAssetsForHarnesses(ids, { home, root = packageRoot, env = process.env } = {}) {
package/src/launcher.mjs CHANGED
@@ -4,11 +4,13 @@ import { readFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
7
+ import { discoverSapGuiSystems } from './local-sap-gui.mjs';
7
8
  import { findBinary } from './binary.mjs';
8
9
  import { MCPProxy } from './mcp-proxy.mjs';
9
10
  import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
10
11
  import { runSetup } from './setup.mjs';
11
12
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
13
+ import { createWindowsSsoAdtProxy } from './windows-sso-adt-proxy.mjs';
12
14
  import { withBrandedEnvironment } from './branding.mjs';
13
15
  import { redactText } from './redact.mjs';
14
16
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -25,7 +27,7 @@ function usage() {
25
27
  return [
26
28
  'Usage: sap-ai-dev [options]',
27
29
  '',
28
- 'With H2O_URL set and no command, starts the SAP AI Dev Toolkit stdio server for selected BAS destinations.',
30
+ 'With H2O_URL set, starts the SAP AI Dev Toolkit stdio server for selected BAS destinations; with local SAP GUI setup, starts the selected local ADT system.',
29
31
  'Options:',
30
32
  ' --setup Configure generated BAS MCP servers',
31
33
  ' --tools Also offer full-stack SAP companion MCP servers (use with --setup)',
@@ -161,9 +163,46 @@ async function binaryOrError() {
161
163
  async function discoverForCommand() {
162
164
  const env = { ...runtimeEnv };
163
165
  if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
166
+ if (!env.H2O_URL) {
167
+ const systems = await discoverSapGuiSystems({ env });
168
+ if (!systems.length) throw new Error('H2O_URL is required for BAS destination discovery');
169
+ return systems;
170
+ }
164
171
  return discoverDestinations({ env });
165
172
  }
166
173
 
174
+ async function configuredLocalSapGuiDestination(env) {
175
+ const name = env.SAP_AI_DEV_TOOLKIT_DESTINATION || env.SAP_SYSTEM_ID || 'local-sap';
176
+ if (!env.SAP_URL) throw new Error('SAP_URL is required for local SAP GUI MCP entries. Rerun sap-ai-dev --setup and enter the ADT URL.');
177
+ const authMode = String(env.SAP_AUTH_MODE || '').toLowerCase();
178
+ if (authMode === 'windows-sso') {
179
+ const route = await createWindowsSsoAdtProxy({ destinationUrl: env.SAP_URL, env });
180
+ return {
181
+ source: 'sap-gui-local',
182
+ name,
183
+ url: route.url,
184
+ backendUrl: env.SAP_URL,
185
+ client: env.SAP_CLIENT || '001',
186
+ authentication: 'WindowsSSO',
187
+ childEnv: {},
188
+ close: route.close,
189
+ probe: { status: 'configured', available: true }
190
+ };
191
+ }
192
+ return {
193
+ source: 'sap-gui-local',
194
+ name,
195
+ url: env.SAP_URL,
196
+ client: env.SAP_CLIENT || '001',
197
+ authentication: 'Basic',
198
+ childEnv: {
199
+ SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
200
+ SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
201
+ },
202
+ probe: { status: 'configured', available: true }
203
+ };
204
+ }
205
+
167
206
  async function main() {
168
207
  const setup = hasFlag('--setup');
169
208
  const check = hasFlag('--check');
@@ -184,7 +223,9 @@ async function main() {
184
223
  try {
185
224
  const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
186
225
  ? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
187
- : await discoverForCommand();
226
+ : (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'sap-gui-local'
227
+ ? [await configuredLocalSapGuiDestination(runtimeEnv)]
228
+ : await discoverForCommand());
188
229
  report = await runDoctor(destinations);
189
230
  } catch (error) {
190
231
  report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
@@ -219,8 +260,10 @@ async function main() {
219
260
  return;
220
261
  }
221
262
 
222
- if (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') {
223
- const destination = await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv });
263
+ if (['cloud-foundry', 'sap-gui-local'].includes(runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE)) {
264
+ const destination = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
265
+ ? await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })
266
+ : await configuredLocalSapGuiDestination(runtimeEnv);
224
267
  let proxy;
225
268
  try {
226
269
  const binary = await binaryOrError();
@@ -0,0 +1,217 @@
1
+ import { platform, homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { readdir, readFile, stat } from 'node:fs/promises';
4
+
5
+ function text(value) { return value == null ? '' : String(value).trim(); }
6
+ function decodeXml(value) {
7
+ return text(value)
8
+ .replace(/"/g, '"')
9
+ .replace(/'/g, "'")
10
+ .replace(/&lt;/g, '<')
11
+ .replace(/&gt;/g, '>')
12
+ .replace(/&amp;/g, '&');
13
+ }
14
+
15
+ async function exists(path) {
16
+ try { await stat(path); return true; }
17
+ catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
18
+ }
19
+
20
+ function unique(values) {
21
+ return [...new Set(values.filter(Boolean))];
22
+ }
23
+
24
+ export function sapGuiLandscapeCandidates(env = process.env) {
25
+ const home = env.HOME || env.USERPROFILE || homedir();
26
+ const appData = env.APPDATA || (env.USERPROFILE ? join(env.USERPROFILE, 'AppData', 'Roaming') : '');
27
+ const candidates = [];
28
+ if (platform() === 'win32' || appData) {
29
+ candidates.push(
30
+ join(appData || '', 'SAP', 'Common', 'SAPUILandscape.xml'),
31
+ join(appData || '', 'SAP', 'Common', 'SAPUILandscapeGlobal.xml'),
32
+ join(appData || '', 'SAP', 'Common', 'saplogon.ini')
33
+ );
34
+ }
35
+ candidates.push(
36
+ join(home, 'Library', 'Preferences', 'SAP', 'SAPGUILandscape.xml'),
37
+ join(home, 'Library', 'Preferences', 'SAP', 'SAPUILandscape.xml'),
38
+ join(home, 'Library', 'Preferences', 'SAP', 'connections'),
39
+ join(home, '.SAPGUI', 'SAPGUILandscape.xml')
40
+ );
41
+ return unique(candidates);
42
+ }
43
+
44
+ function attributes(fragment) {
45
+ const result = {};
46
+ for (const match of fragment.matchAll(/([A-Za-z_:][\w:.-]*)\s*=\s*"([^"]*)"|([A-Za-z_:][\w:.-]*)\s*=\s*'([^']*)'/g)) {
47
+ result[String(match[1] || match[3]).toLowerCase()] = decodeXml(match[2] ?? match[4]);
48
+ }
49
+ return result;
50
+ }
51
+
52
+ function ssoHintFrom(record) {
53
+ const ssoKeys = ['sncname', 'sncpartnername', 'sncmode', 'snc_mode', 'sncqop', 'snc_qop', 'use_sso', 'sso'];
54
+ if (ssoKeys.some(key => {
55
+ const value = text(record[key]);
56
+ return value && !['0', 'false', 'no', 'off'].includes(value.toLowerCase());
57
+ })) return true;
58
+ const values = [record.authentication, record.auth].map(text).filter(Boolean);
59
+ return values.some(value => /snc|sso|kerberos|spnego|secure\s*login/i.test(value) || ['1', 'true', 'yes'].includes(value.toLowerCase()));
60
+ }
61
+
62
+ function instanceFrom(value) {
63
+ const candidates = [value?.instance, value?.instancenumber, value?.instanceNumber, value?.sysnr, value?.systemnumber];
64
+ for (const candidate of candidates) {
65
+ const match = text(candidate).match(/^\d{2}$/);
66
+ if (match) return match[0];
67
+ }
68
+ return '';
69
+ }
70
+
71
+ function normalizeRecord(record, sourcePath) {
72
+ const type = text(record.type || record.kind).toLowerCase();
73
+ if (type && !['sapgui', 'r/3', 'sap'].some(token => type.includes(token))) return null;
74
+ const name = text(record.name || record.description || record.desc || record.systemid || record.sid || record.uuid);
75
+ const address = text(record.server || record.host || record.applicationserver || record.messageserver || record.mshost);
76
+ // SAP GUI ports are DIAG dispatcher ports, not HTTP ports.
77
+ const endpoint = address.match(/^(\[[^\]]+\]|[^:/]+):(\d+)$/);
78
+ const host = endpoint ? endpoint[1] : address;
79
+ const systemId = text(record.systemid || record.sid || record.r3name).toUpperCase();
80
+ if (!name || !host) return null;
81
+ const port = endpoint ? Number(endpoint[2]) : 0;
82
+ const instance = instanceFrom(record) || (port >= 3200 && port <= 3299 ? String(port - 3200).padStart(2, '0') : '');
83
+ const client = text(record.client || record.sapclient) || '001';
84
+ return {
85
+ source: 'sap-gui-local',
86
+ name,
87
+ serverName: name,
88
+ host,
89
+ systemId,
90
+ instance,
91
+ client,
92
+ authentication: ssoHintFrom(record) ? 'Basic/SSO hint' : 'Basic',
93
+ authMode: 'basic',
94
+ ssoHint: ssoHintFrom(record),
95
+ proxyType: 'Internet',
96
+ landscapePath: sourcePath,
97
+ probe: { status: 'needs-adt-url' }
98
+ };
99
+ }
100
+
101
+ function parseLandscapeXml(content, sourcePath) {
102
+ const records = [];
103
+ // Comments must not turn deleted entries into selectable systems.
104
+ content = content.replace(/<!--[\s\S]*?-->/g, '');
105
+ const messageServers = new Map();
106
+ for (const match of content.matchAll(/<Messageserver\b([^>]*)>/gi)) {
107
+ const record = attributes(match[1]);
108
+ if (record.uuid) messageServers.set(record.uuid, record);
109
+ }
110
+ for (const match of content.matchAll(/<Service\b([^>]*)\/?>(?:\s*<\/Service>)?/gi)) {
111
+ const record = attributes(match[1]);
112
+ if (record.msid) {
113
+ const messageServer = messageServers.get(record.msid);
114
+ // For load-balanced entries `server` is a logon group, not a host.
115
+ if (!messageServer?.host) continue;
116
+ record.server = messageServer.host;
117
+ }
118
+ const normalized = normalizeRecord(record, sourcePath);
119
+ if (normalized) records.push(normalized);
120
+ }
121
+ // SAP GUI for Java files may use generic Item/Connection elements.
122
+ for (const match of content.matchAll(/<(?:Item|Connection)\b([^>]*)\/?>(?:\s*<\/(?:Item|Connection)>)?/gi)) {
123
+ const normalized = normalizeRecord(attributes(match[1]), sourcePath);
124
+ if (normalized) records.push(normalized);
125
+ }
126
+ return records;
127
+ }
128
+
129
+ function parseSapLogonIni(content, sourcePath) {
130
+ const sections = new Map();
131
+ let section = '';
132
+ for (const line of content.split(/\r?\n/)) {
133
+ const header = line.match(/^\s*\[(.+)]\s*$/);
134
+ if (header) { section = header[1].toLowerCase(); if (!sections.has(section)) sections.set(section, {}); continue; }
135
+ const entry = line.match(/^\s*([^=]+?)\s*=\s*(.*?)\s*$/);
136
+ if (entry && section) sections.get(section)[entry[1].toLowerCase()] = entry[2];
137
+ }
138
+ const descriptions = sections.get('description') || sections.get('descriptions') || {};
139
+ const servers = sections.get('server') || sections.get('servers') || {};
140
+ const systems = sections.get('system') || sections.get('systems') || {};
141
+ const databases = sections.get('database') || sections.get('databases') || {};
142
+ const clients = sections.get('client') || sections.get('clients') || {};
143
+ const instances = sections.get('systemnumber') || {};
144
+ const sncNames = sections.get('sncname') || {};
145
+ const sncModes = sections.get('snc') || sections.get('sncmode') || {};
146
+ const keys = unique([...Object.keys(descriptions), ...Object.keys(servers)]);
147
+ return keys.map(key => normalizeRecord({
148
+ name: descriptions[key] || systems[key] || key,
149
+ server: servers[key],
150
+ systemid: databases[key] || systems[key],
151
+ client: clients[key],
152
+ systemnumber: instances[key],
153
+ sncname: sncNames[key],
154
+ sncmode: sncModes[key]
155
+ }, sourcePath)).filter(Boolean);
156
+ }
157
+
158
+ async function readGuiFile(path) {
159
+ const bytes = await readFile(path);
160
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) return bytes.subarray(2).toString('utf16le');
161
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) return bytes.subarray(2).swap16().toString('utf16le');
162
+ return bytes.toString('utf8').replace(/^\uFEFF/, '');
163
+ }
164
+
165
+ async function parseConnectionsFile(path) {
166
+ const content = await readGuiFile(path);
167
+ if (content.includes('<')) return parseLandscapeXml(content, path);
168
+ return content.split(/\r?\n/).map((line, index) => {
169
+ const parts = line.split(/[;,\t]/).map(text).filter(Boolean);
170
+ if (parts.length < 2) return null;
171
+ return normalizeRecord({ name: parts[0] || `SAP GUI ${index + 1}`, server: parts.find(part => /[A-Za-z]/.test(part) && part.includes('.')) || parts[1], systemid: parts.find(part => /^[A-Za-z0-9]{3}$/.test(part)) }, path);
172
+ }).filter(Boolean);
173
+ }
174
+
175
+ export async function discoverSapGuiSystems({ env = process.env, paths = sapGuiLandscapeCandidates(env) } = {}) {
176
+ const systems = [];
177
+ for (const path of unique(paths)) {
178
+ if (!path) continue;
179
+ try {
180
+ if (!await exists(path)) continue;
181
+ const info = await stat(path);
182
+ if (info.isDirectory()) {
183
+ for (const name of await readdir(path)) {
184
+ const child = join(path, name);
185
+ try {
186
+ if ((await stat(child)).isFile()) systems.push(...await parseConnectionsFile(child));
187
+ } catch {
188
+ // One unreadable child must not hide the rest of the directory.
189
+ }
190
+ }
191
+ } else if (/saplogon\.ini$/i.test(path)) {
192
+ systems.push(...parseSapLogonIni(await readGuiFile(path), path));
193
+ } else {
194
+ systems.push(...await parseConnectionsFile(path));
195
+ }
196
+ } catch {
197
+ // Ignore unreadable or unfamiliar SAP GUI files; discovery is best-effort.
198
+ }
199
+ }
200
+ const seen = new Set();
201
+ return systems.filter(system => {
202
+ const key = `${system.name}\0${system.host}\0${system.systemId}\0${system.instance}\0${system.client}`.toLowerCase();
203
+ if (seen.has(key)) return false;
204
+ seen.add(key);
205
+ return true;
206
+ }).sort((a, b) => a.name.localeCompare(b.name));
207
+ }
208
+
209
+ export function defaultAdtUrl(system) {
210
+ const host = text(system.host);
211
+ const instance = text(system.instance);
212
+ if (!host) return '';
213
+ // Router strings and other DIAG routes cannot be used as HTTP hosts.
214
+ if (/[\s/@?#]/.test(host)) return '';
215
+ const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
216
+ return /^\d{2}$/.test(instance) ? `https://${authority}:443${instance}` : `https://${authority}`;
217
+ }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
- import { homedir } from 'node:os';
4
+ import { homedir, platform } from 'node:os';
5
5
  import { basename, delimiter, dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { brandedEnvValue } from './branding.mjs';
@@ -103,11 +103,22 @@ export async function resolveMcpConfigPath(env = process.env) {
103
103
  .find(value => typeof value === 'string' && value.length > 0);
104
104
  if (configuredPath) return configuredPath;
105
105
  const home = env.HOME || homedir();
106
- const candidates = [
106
+ const appData = env.APPDATA || (env.USERPROFILE ? join(env.USERPROFILE, 'AppData', 'Roaming') : '');
107
+ const localVsCodeCandidates = [
108
+ ...(appData ? [join(appData, 'Code', 'User', 'mcp.json'), join(appData, 'Code - Insiders', 'User', 'mcp.json')] : []),
109
+ ...(platform() === 'darwin' ? [
110
+ join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
111
+ join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'mcp.json')
112
+ ] : []),
113
+ join(home, '.config', 'Code', 'User', 'mcp.json'),
114
+ join(home, '.config', 'Code - Insiders', 'User', 'mcp.json')
115
+ ];
116
+ const basCandidates = [
107
117
  join(home, '.vscode', 'data', 'User', 'mcp.json'),
108
118
  join(home, '.vscode-server', 'data', 'User', 'mcp.json'),
109
119
  join(home, '.code-server', 'data', 'User', 'mcp.json')
110
120
  ];
121
+ const candidates = env.H2O_URL ? [...basCandidates, ...localVsCodeCandidates] : [...localVsCodeCandidates, ...basCandidates];
111
122
  for (const candidate of candidates) if (await exists(candidate)) return candidate;
112
123
  return candidates[0];
113
124
  }
@@ -141,15 +152,17 @@ export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SE
141
152
 
142
153
  export function buildMcpEntries(destinations, env = process.env) {
143
154
  const h2oUrl = env.H2O_URL;
144
- if (!h2oUrl) throw new Error('H2O_URL is required to write SAP AI Dev Toolkit configuration');
155
+ const hasNonLocal = destinations.some(destination => destination?.source !== 'sap-gui-local');
156
+ if (destinations.length && hasNonLocal && !h2oUrl) throw new Error('H2O_URL is required to write SAP AI Dev Toolkit configuration');
145
157
  const entries = Object.create(null);
146
158
  const selected = [...destinations].sort((a, b) => String(a.serverName || a.name).localeCompare(String(b.serverName || b.name)));
147
159
  for (const destination of selected) {
148
160
  const isCf = destination.source === 'cloud-foundry';
161
+ const isLocalSapGui = destination.source === 'sap-gui-local';
149
162
  const name = generatedServerName(isCf ? destination.serverName : destination.name);
150
163
  if (Object.hasOwn(entries, name)) throw new Error(`Duplicate MCP destination server name: ${name} (two destination names normalize to the same lowercase server name)`);
151
164
  const entryEnv = {
152
- H2O_URL: String(h2oUrl),
165
+ ...(h2oUrl ? { H2O_URL: String(h2oUrl) } : {}),
153
166
  SAP_ALLOW_TRANSPORTABLE_EDITS: 'true'
154
167
  };
155
168
  if (isCf) {
@@ -171,6 +184,16 @@ export function buildMcpEntries(destinations, env = process.env) {
171
184
  BAS_CF_CONNECTIVITY_KEY: String(cf.connectivityKeyName)
172
185
  });
173
186
  }
187
+ } else if (isLocalSapGui) {
188
+ if (!destination.url) throw new Error(`Local SAP GUI destination "${destination.name}" is missing an ADT URL`);
189
+ Object.assign(entryEnv, {
190
+ SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'sap-gui-local',
191
+ SAP_AI_DEV_TOOLKIT_DESTINATION: String(destination.name),
192
+ SAP_URL: String(destination.url),
193
+ SAP_CLIENT: String(destination.client || '001'),
194
+ ...(destination.systemId ? { SAP_SYSTEM_ID: String(destination.systemId) } : {}),
195
+ ...(destination.childEnv || {})
196
+ });
174
197
  } else {
175
198
  entryEnv.SAP_AI_DEV_TOOLKIT_DESTINATION = String(destination.name);
176
199
  }
@@ -316,6 +339,12 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
316
339
  ...buildMcpEntries(destinations, env),
317
340
  ...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [], { packageVersions: companionVersions })
318
341
  };
342
+ const generatedInputs = new Map();
343
+ for (const destination of destinations) {
344
+ for (const input of Array.isArray(destination?.inputs) ? destination.inputs : []) {
345
+ if (input?.id) generatedInputs.set(input.id, input);
346
+ }
347
+ }
319
348
  const launcherCommand = options.command || (destinations.length ? await resolveMcpServerCommand(env) : null);
320
349
  if (launcherCommand) {
321
350
  for (const entry of Object.values(generated)) {
@@ -325,12 +354,19 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
325
354
  }
326
355
  }
327
356
  const servers = Object.create(null);
357
+ const managedInputIds = new Set();
328
358
  for (const [name, entry] of Object.entries(config.servers)) {
329
359
  const managed = isPackageLauncher(entry)
330
360
  && typeof destinationValue(entry?.env) === 'string'
331
361
  && (destinationSource(entry.env) === 'cloud-foundry'
332
362
  ? typeof entry.env.BAS_CF_DESTINATION_KEY === 'string'
333
- : destinationSource(entry.env) === undefined);
363
+ : (destinationSource(entry.env) === undefined || destinationSource(entry.env) === 'sap-gui-local'));
364
+ if (managed && destinationSource(entry.env) === 'sap-gui-local') {
365
+ for (const value of Object.values(entry.env || {})) {
366
+ const match = typeof value === 'string' ? value.match(/^\$\{input:([^}]+)}$/) : null;
367
+ if (match) managedInputIds.add(match[1]);
368
+ }
369
+ }
334
370
  if (!LEGACY_MCP_SERVER_PREFIXES.some(prefix => name.startsWith(prefix)) && !managed && !isCompanionServer(entry)) servers[name] = entry;
335
371
  }
336
372
  for (const name of Object.keys(generated)) {
@@ -339,6 +375,10 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
339
375
  }
340
376
  }
341
377
  config.servers = { ...servers, ...generated };
378
+ if (generatedInputs.size || managedInputIds.size) {
379
+ const existingInputs = Array.isArray(config.inputs) ? config.inputs.filter(input => !generatedInputs.has(input?.id) && !managedInputIds.has(input?.id)) : [];
380
+ config.inputs = [...existingInputs, ...generatedInputs.values()];
381
+ }
342
382
  await writeConfig(path, config);
343
383
  return { path, servers: generated };
344
384
  }
package/src/mcp-proxy.mjs CHANGED
@@ -168,6 +168,7 @@ function closeDestinationRoute(destination) {
168
168
 
169
169
 
170
170
  function useProxyAuthentication(destination) {
171
+ if (destination.source === 'sap-gui-local') return destination.authentication === 'WindowsSSO';
171
172
  return destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation';
172
173
  }
173
174