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.
- package/README.md +30 -23
- package/package.json +1 -1
- package/scripts/postinstall.mjs +41 -9
- package/scripts/windows-negotiate-helper.ps1 +182 -0
- package/src/harness-mcp-config.mjs +122 -0
- package/src/harnesses.mjs +29 -1
- package/src/install-user-assets.mjs +45 -5
- package/src/launcher.mjs +47 -4
- package/src/local-sap-gui.mjs +217 -0
- package/src/mcp-config.mjs +45 -5
- package/src/mcp-proxy.mjs +1 -0
- package/src/setup.mjs +125 -26
- package/src/windows-sso-adt-proxy.mjs +224 -0
|
@@ -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
|
|
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
|
|
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 = {
|
|
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
|
|
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
|
-
:
|
|
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 (
|
|
223
|
-
const destination =
|
|
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(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/&/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
|
+
}
|
package/src/mcp-config.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|