abap-local-client 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/basic-auth.mjs +197 -0
- package/bin/cli.cjs +8 -0
- package/bin/config.cjs +8 -0
- package/config-cli.mjs +325 -0
- package/config-manager.mjs +278 -0
- package/package.json +46 -0
- package/saml-login.mjs +372 -0
- package/sap-rfc-client.js +852 -0
- package/snc-auth.mjs +194 -0
- package/spnego-auth.mjs +362 -0
- package/sso-sap-client.mjs +687 -0
- package/x509-auth.mjs +395 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SSO SAP Client — WebSocket Bridge with Basic, SPNEGO, X.509, SAML & SNC
|
|
4
|
+
*
|
|
5
|
+
* Connects to abap-mcp via WebSocket and routes SAP ADT calls through
|
|
6
|
+
* the configured authentication mechanism:
|
|
7
|
+
*
|
|
8
|
+
* Auth Mode Description
|
|
9
|
+
* ────────────────── ──────────────────────────────────────────────────
|
|
10
|
+
* basic HTTP Basic Auth (username + password)
|
|
11
|
+
* The most common scenario. Supports SAProuter RFC.
|
|
12
|
+
*
|
|
13
|
+
* spnego Kerberos SPNEGO HTTP Negotiate (RFC 4559)
|
|
14
|
+
* Uses Windows SSPI on Windows, GSSAPI on Linux.
|
|
15
|
+
*
|
|
16
|
+
* x509 X.509 mTLS Principal Propagation
|
|
17
|
+
* Ephemeral client certificates signed by CA.
|
|
18
|
+
*
|
|
19
|
+
* saml SAML 2.0 Browser Login (S/4HANA Public Cloud)
|
|
20
|
+
* Opens Chromium via Puppeteer, captures session
|
|
21
|
+
* cookies after SAML flow completes.
|
|
22
|
+
*
|
|
23
|
+
* snc SNC Kerberos via RFC (SAPGUI-like SSO)
|
|
24
|
+
* Uses sapnwrfc.dll with SNC_MODE=1.
|
|
25
|
+
*
|
|
26
|
+
* Systems are configured via the encrypted config manager.
|
|
27
|
+
* Run: node config-cli.mjs --help
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* node sso-sap-client.mjs
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import WebSocket from 'ws';
|
|
34
|
+
import axios from 'axios';
|
|
35
|
+
import { parseSAPResponse } from './xml-utils.js';
|
|
36
|
+
import { BasicSession } from './basic-auth.mjs';
|
|
37
|
+
import { SpnegoSession, resolveServicePrincipal } from './spnego-auth.mjs';
|
|
38
|
+
import { X509Session, loadCA, generateCA } from './x509-auth.mjs';
|
|
39
|
+
import { SamlSession } from './saml-login.mjs';
|
|
40
|
+
import { SNCSession, getWindowsIdentity, getKerberosRealm } from './snc-auth.mjs';
|
|
41
|
+
import { SAPRFCClient } from './sap-rfc-client.js';
|
|
42
|
+
import * as config from './config-manager.mjs';
|
|
43
|
+
|
|
44
|
+
// Load .env as fallback if config is not initialized
|
|
45
|
+
try { process.loadEnvFile?.(); } catch { /* no .env */ }
|
|
46
|
+
const env = process.env;
|
|
47
|
+
|
|
48
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
49
|
+
// Configuration — from encrypted store, with .env fallback
|
|
50
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
51
|
+
|
|
52
|
+
let MCP_API_KEY = '';
|
|
53
|
+
let RECONNECT_DELAY = 5000;
|
|
54
|
+
let WS_URL = env.WS_URL || 'ws://localhost:8080';
|
|
55
|
+
|
|
56
|
+
function loadGlobalConfig() {
|
|
57
|
+
if (config.isConfigInitialized()) {
|
|
58
|
+
MCP_API_KEY = config.getMcpApiKey();
|
|
59
|
+
RECONNECT_DELAY = config.getReconnectDelay();
|
|
60
|
+
WS_URL = config.getWsUrl();
|
|
61
|
+
console.log('🔑 Config loaded from encrypted store.');
|
|
62
|
+
} else {
|
|
63
|
+
// Fallback to .env
|
|
64
|
+
MCP_API_KEY = env.API_KEY || '';
|
|
65
|
+
RECONNECT_DELAY = parseInt(env.RECONNECT_DELAY_MS || '5000', 10);
|
|
66
|
+
WS_URL = env.WS_URL || 'ws://localhost:8080';
|
|
67
|
+
console.log('⚠️ Encrypted config not found — using .env fallback.');
|
|
68
|
+
console.log(' Run: node config-cli.mjs --help to set up the config.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!MCP_API_KEY) {
|
|
72
|
+
console.error('❌ No MCP API key configured.');
|
|
73
|
+
console.error(' Run: node config-cli.mjs set-mcp-key <your-api-key>');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
79
|
+
// Auth Session Cache (per systemId)
|
|
80
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Each entry: {
|
|
84
|
+
* httpSession: SpnegoSession|X509Session|SamlSession|BasicSession|null,
|
|
85
|
+
* rfcClient: SAPRFCClient|null,
|
|
86
|
+
* authMode: string,
|
|
87
|
+
* }
|
|
88
|
+
* For RFC modes (snc, or any mode with saprouter), rfcClient is set.
|
|
89
|
+
* For HTTP modes, httpSession is set.
|
|
90
|
+
*/
|
|
91
|
+
const sessions = new Map();
|
|
92
|
+
const pendingSessions = new Map(); // in-flight init promises to prevent race
|
|
93
|
+
|
|
94
|
+
// Default system ID (used when MCP message has no "system" field)
|
|
95
|
+
let defaultSystemId = null;
|
|
96
|
+
|
|
97
|
+
function getDefaultSystemId() {
|
|
98
|
+
if (defaultSystemId) return defaultSystemId;
|
|
99
|
+
const allSystems = config.listSystems();
|
|
100
|
+
if (allSystems.length === 1) {
|
|
101
|
+
defaultSystemId = allSystems[0].systemId;
|
|
102
|
+
}
|
|
103
|
+
return defaultSystemId;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get or create the auth session for a system ID.
|
|
108
|
+
* @returns {{ useRfc: boolean, rfcClient: SAPRFCClient|null, httpSession: object|null }}
|
|
109
|
+
*/
|
|
110
|
+
async function getSessionForSystem(systemId) {
|
|
111
|
+
// Resolve system ID
|
|
112
|
+
let resolvedId = systemId;
|
|
113
|
+
let sysConfig = null;
|
|
114
|
+
|
|
115
|
+
if (systemId) {
|
|
116
|
+
sysConfig = config.getSystem(systemId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// If no systemId in message, try default
|
|
120
|
+
if (!sysConfig && !systemId) {
|
|
121
|
+
resolvedId = getDefaultSystemId();
|
|
122
|
+
if (resolvedId) {
|
|
123
|
+
sysConfig = config.getSystem(resolvedId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!sysConfig) {
|
|
128
|
+
// Try Arcanum fallback if enabled
|
|
129
|
+
if (config.getArcanumFallback()) {
|
|
130
|
+
console.log(` ⚡ System "${systemId || resolvedId}" not found locally — Arcanum fallback not yet implemented`);
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`Unknown system: "${systemId || resolvedId}". Configure with: node config-cli.mjs add-system <id> <mode>`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!config.isSystemEnabled(systemId || resolvedId)) {
|
|
136
|
+
throw new Error(`System "${systemId || resolvedId}" is disabled.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const cacheKey = resolvedId;
|
|
140
|
+
|
|
141
|
+
// Return cached session if available
|
|
142
|
+
if (sessions.has(cacheKey)) {
|
|
143
|
+
return sessions.get(cacheKey);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Prevent concurrent init for the same system
|
|
147
|
+
if (pendingSessions.has(cacheKey)) {
|
|
148
|
+
return await pendingSessions.get(cacheKey);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const initPromise = _createSessionInternal(cacheKey, sysConfig);
|
|
152
|
+
pendingSessions.set(cacheKey, initPromise);
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const sessionEntry = await initPromise;
|
|
156
|
+
sessions.set(cacheKey, sessionEntry);
|
|
157
|
+
return sessionEntry;
|
|
158
|
+
} finally {
|
|
159
|
+
pendingSessions.delete(cacheKey);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function _createSessionInternal(resolvedId, sysConfig) {
|
|
164
|
+
const conn = sysConfig.connection || {};
|
|
165
|
+
const authMode = sysConfig.authMode || 'basic';
|
|
166
|
+
const hasSaprouter = !!(conn.saprouter);
|
|
167
|
+
|
|
168
|
+
console.log(`\n🔐 Creating auth session for system "${resolvedId}" (${authMode})`);
|
|
169
|
+
|
|
170
|
+
let httpSession = null;
|
|
171
|
+
let rfcClient = null;
|
|
172
|
+
let useRfc = false;
|
|
173
|
+
|
|
174
|
+
// Determine if RFC tunnel should be used.
|
|
175
|
+
// SNC and modes with saprouter always use RFC.
|
|
176
|
+
// For other modes, if saprouter is set, we use RFC.
|
|
177
|
+
if (authMode === 'snc' || hasSaprouter) {
|
|
178
|
+
useRfc = true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
182
|
+
// Instantiate session by auth mode
|
|
183
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
184
|
+
|
|
185
|
+
switch (authMode) {
|
|
186
|
+
// ── BASIC ────────────────────────────────────────────────────────
|
|
187
|
+
case 'basic': {
|
|
188
|
+
const baseUrl = `https://${conn.host}:${conn.port || '44301'}`;
|
|
189
|
+
|
|
190
|
+
if (useRfc) {
|
|
191
|
+
// RFC tunnel with Basic Auth
|
|
192
|
+
rfcClient = new SAPRFCClient({
|
|
193
|
+
host: conn.host,
|
|
194
|
+
sysnr: conn.sysnr || '00',
|
|
195
|
+
client: conn.client || '100',
|
|
196
|
+
username: conn.username || '',
|
|
197
|
+
password: conn.password || '',
|
|
198
|
+
language: conn.language || 'EN',
|
|
199
|
+
saprouter: conn.saprouter || '',
|
|
200
|
+
});
|
|
201
|
+
await rfcClient.connect();
|
|
202
|
+
console.log(` ✅ Basic Auth RFC connection established`);
|
|
203
|
+
} else {
|
|
204
|
+
// Direct HTTP Basic Auth
|
|
205
|
+
httpSession = new BasicSession(
|
|
206
|
+
axios, baseUrl,
|
|
207
|
+
conn.client || '100',
|
|
208
|
+
conn.username || '',
|
|
209
|
+
conn.password || '',
|
|
210
|
+
{ language: conn.language || 'EN' }
|
|
211
|
+
);
|
|
212
|
+
await httpSession.init();
|
|
213
|
+
console.log(` ✅ Basic Auth HTTP session established`);
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── SPNEGO ───────────────────────────────────────────────────────
|
|
219
|
+
case 'spnego': {
|
|
220
|
+
const baseUrl = `https://${conn.host}:${conn.port || '44301'}`;
|
|
221
|
+
|
|
222
|
+
if (useRfc) {
|
|
223
|
+
rfcClient = new SAPRFCClient({
|
|
224
|
+
host: conn.host,
|
|
225
|
+
sysnr: conn.sysnr || '00',
|
|
226
|
+
client: conn.client || '100',
|
|
227
|
+
username: conn.username || '',
|
|
228
|
+
password: conn.password || '',
|
|
229
|
+
language: conn.language || 'EN',
|
|
230
|
+
saprouter: conn.saprouter || '',
|
|
231
|
+
});
|
|
232
|
+
await rfcClient.connect();
|
|
233
|
+
} else {
|
|
234
|
+
let spn = conn.servicePrincipal;
|
|
235
|
+
if (!spn) {
|
|
236
|
+
spn = await resolveServicePrincipal(conn.host);
|
|
237
|
+
}
|
|
238
|
+
console.log(` 🎫 SPNEGO SPN: ${spn}`);
|
|
239
|
+
httpSession = new SpnegoSession(axios, conn.host, {
|
|
240
|
+
baseUrl,
|
|
241
|
+
client: conn.client || '100',
|
|
242
|
+
language: conn.language || 'EN',
|
|
243
|
+
});
|
|
244
|
+
await httpSession.init();
|
|
245
|
+
}
|
|
246
|
+
console.log(` ✅ SPNEGO session established`);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── X.509 ────────────────────────────────────────────────────────
|
|
251
|
+
case 'x509': {
|
|
252
|
+
const baseUrl = `https://${conn.host}:${conn.port || '44301'}`;
|
|
253
|
+
|
|
254
|
+
let caCertPem = conn.caCertPem || '';
|
|
255
|
+
let caKeyPem = conn.caKeyPem || '';
|
|
256
|
+
|
|
257
|
+
if (!caCertPem || !caKeyPem) {
|
|
258
|
+
console.log(' 🔧 Generating self-signed CA for development...');
|
|
259
|
+
const ca = generateCA();
|
|
260
|
+
caCertPem = ca.caCertPem;
|
|
261
|
+
caKeyPem = ca.caKeyPem;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log(` 📜 CA loaded, login identifier: ${conn.loginIdentifier}`);
|
|
265
|
+
httpSession = new X509Session(
|
|
266
|
+
axios, caCertPem, caKeyPem,
|
|
267
|
+
conn.loginIdentifier || conn.username || '',
|
|
268
|
+
{
|
|
269
|
+
baseUrl,
|
|
270
|
+
client: conn.client || '100',
|
|
271
|
+
language: conn.language || 'EN',
|
|
272
|
+
validityMinutes: conn.validityMinutes || 5,
|
|
273
|
+
rejectUnauthorized: env.NODE_TLS_REJECT_UNAUTHORIZED !== '0',
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
await httpSession.init();
|
|
277
|
+
console.log(' ✅ X.509 session established');
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── SAML ─────────────────────────────────────────────────────────
|
|
282
|
+
case 'saml': {
|
|
283
|
+
const baseUrl = conn.baseUrl || `https://${conn.host}:${conn.port || '44301'}`;
|
|
284
|
+
console.log(` 🌐 SAML browser login → ${baseUrl}`);
|
|
285
|
+
httpSession = new SamlSession(axios, baseUrl, conn.client || '100', {
|
|
286
|
+
language: conn.language || 'EN',
|
|
287
|
+
headless: conn.headless !== false,
|
|
288
|
+
timeoutMs: conn.loginTimeoutMs || 600_000,
|
|
289
|
+
onStatus: (msg) => console.log(` ${msg}`),
|
|
290
|
+
});
|
|
291
|
+
await httpSession.init();
|
|
292
|
+
console.log(` ✅ SAML session established`);
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── SNC ──────────────────────────────────────────────────────────
|
|
297
|
+
case 'snc': {
|
|
298
|
+
const identity = getWindowsIdentity();
|
|
299
|
+
const realm = getKerberosRealm();
|
|
300
|
+
|
|
301
|
+
console.log(` 🔐 SNC Kerberos — ${identity.domain}\\${identity.user}`);
|
|
302
|
+
console.log(` Realm: ${realm}`);
|
|
303
|
+
|
|
304
|
+
const sncPartnerName = conn.sncPartnerName;
|
|
305
|
+
if (!sncPartnerName) {
|
|
306
|
+
throw new Error('SNC mode requires sncPartnerName in connection config.');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const sncSession = new SNCSession(
|
|
310
|
+
{
|
|
311
|
+
host: conn.host,
|
|
312
|
+
sysnr: conn.sysnr || '00',
|
|
313
|
+
client: conn.client || '100',
|
|
314
|
+
username: identity.user,
|
|
315
|
+
password: '',
|
|
316
|
+
language: conn.language || 'EN',
|
|
317
|
+
saprouter: conn.saprouter || '',
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
sncPartnerName,
|
|
321
|
+
sncMyName: conn.sncMyName || undefined,
|
|
322
|
+
sncQop: conn.sncQop || '3',
|
|
323
|
+
sncLib: conn.sncLib || undefined,
|
|
324
|
+
user: identity.user,
|
|
325
|
+
domain: identity.domain,
|
|
326
|
+
realm,
|
|
327
|
+
},
|
|
328
|
+
SAPRFCClient
|
|
329
|
+
);
|
|
330
|
+
await sncSession.init();
|
|
331
|
+
rfcClient = sncSession.rfc;
|
|
332
|
+
useRfc = true; // SNC always uses RFC
|
|
333
|
+
console.log(' ✅ SNC Kerberos RFC connection established');
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
default:
|
|
338
|
+
throw new Error(`Unknown auth mode: ${authMode}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const sessionEntry = { useRfc, rfcClient, httpSession, authMode };
|
|
342
|
+
return sessionEntry;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
346
|
+
// Execute a single callspec
|
|
347
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
348
|
+
|
|
349
|
+
async function executeCallspec(callspec, prevExtracted = {}, systemId) {
|
|
350
|
+
let { method, url, headers = {}, body, extract_from_response } = callspec;
|
|
351
|
+
|
|
352
|
+
for (const [k, v] of Object.entries(prevExtracted)) {
|
|
353
|
+
// Convert camelCase key to UPPER_SNAKE_CASE placeholder: lockHandle → LOCK_HANDLE
|
|
354
|
+
const placeholder = k.replace(/([A-Z])/g, '_$1').toUpperCase();
|
|
355
|
+
url = url.replace(`<${placeholder}>`, v);
|
|
356
|
+
// Also replace the raw key (in case it's already the correct format)
|
|
357
|
+
url = url.replace(`<${k.toUpperCase()}>`, v);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const relUrl = url.startsWith('http') ? new URL(url).pathname + (new URL(url).search || '') : url;
|
|
361
|
+
|
|
362
|
+
console.log(` 📡 ${method} ${relUrl}`);
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
// Resolve system & session
|
|
366
|
+
const resolvedSystemId = callspec.system || systemId || '';
|
|
367
|
+
let sessionEntry;
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
sessionEntry = await getSessionForSystem(resolvedSystemId);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
return { success: false, status: 0, error: err.message, data: null };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const { useRfc, rfcClient, httpSession } = sessionEntry;
|
|
376
|
+
|
|
377
|
+
// ── RFC tunnel path ─────────────────────────────────────────────
|
|
378
|
+
if (useRfc && rfcClient) {
|
|
379
|
+
const conn = config.getSystem(resolvedSystemId)?.connection || {};
|
|
380
|
+
const finalHeaders = {
|
|
381
|
+
...headers,
|
|
382
|
+
'sap-client': conn.client || '100',
|
|
383
|
+
'sap-language': conn.language || 'EN',
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (finalHeaders['X-CSRF-Token'] === '<CSRF_TOKEN>') {
|
|
387
|
+
if (!rfcClient.csrfToken) await rfcClient.fetchCsrfToken();
|
|
388
|
+
finalHeaders['X-CSRF-Token'] = rfcClient.csrfToken || '';
|
|
389
|
+
} else if (httpSession?.csrfToken && method !== 'GET') {
|
|
390
|
+
finalHeaders['X-CSRF-Token'] = httpSession.csrfToken;
|
|
391
|
+
}
|
|
392
|
+
delete finalHeaders['Cookie'];
|
|
393
|
+
delete finalHeaders['Authorization'];
|
|
394
|
+
|
|
395
|
+
const resp = await rfcClient.callADT(method, relUrl, finalHeaders, body || null);
|
|
396
|
+
console.log(` 📡 RFC ${resp.status} ${resp.statusText} (${resp.data.length} chars)`);
|
|
397
|
+
if (resp.status >= 400) {
|
|
398
|
+
console.log(` ⚠️ Response body (first 500): ${resp.data?.substring(0, 500)}`);
|
|
399
|
+
console.log(` ⚠️ Response headers: ${JSON.stringify(resp.headers)}`);
|
|
400
|
+
}
|
|
401
|
+
return parseCallspecResult(resp, resp.data, extract_from_response);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Direct HTTP path ────────────────────────────────────────────
|
|
405
|
+
if (!httpSession) {
|
|
406
|
+
return { success: false, status: 0, error: 'No HTTP session available', data: null };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const httpHeaders = { ...headers };
|
|
410
|
+
|
|
411
|
+
if (httpHeaders['X-CSRF-Token'] === '<CSRF_TOKEN>') {
|
|
412
|
+
if (httpSession.csrfToken) {
|
|
413
|
+
httpHeaders['X-CSRF-Token'] = httpSession.csrfToken;
|
|
414
|
+
} else if (httpSession.refreshCsrf) {
|
|
415
|
+
await httpSession.refreshCsrf();
|
|
416
|
+
httpHeaders['X-CSRF-Token'] = httpSession.csrfToken || '';
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let response;
|
|
421
|
+
switch (method.toUpperCase()) {
|
|
422
|
+
case 'GET':
|
|
423
|
+
response = await httpSession.get(relUrl, httpHeaders);
|
|
424
|
+
break;
|
|
425
|
+
case 'POST':
|
|
426
|
+
response = await httpSession.post(relUrl, body || null, httpHeaders);
|
|
427
|
+
break;
|
|
428
|
+
case 'PUT':
|
|
429
|
+
response = await httpSession.put(relUrl, body || null, httpHeaders);
|
|
430
|
+
break;
|
|
431
|
+
default:
|
|
432
|
+
throw new Error(`Unsupported HTTP method: ${method}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const respData = typeof response.data === 'string'
|
|
436
|
+
? response.data
|
|
437
|
+
: JSON.stringify(response.data);
|
|
438
|
+
|
|
439
|
+
console.log(` 📡 HTTP ${response.status} ${response.statusText} (${respData.length} chars)`);
|
|
440
|
+
if (response.status >= 400) {
|
|
441
|
+
console.log(` ⚠️ Response body (first 500): ${respData?.substring(0, 500)}`);
|
|
442
|
+
console.log(` ⚠️ Response headers: ${JSON.stringify(response.headers)}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return parseCallspecResult(
|
|
446
|
+
{ status: response.status, statusText: response.statusText, headers: response.headers },
|
|
447
|
+
respData,
|
|
448
|
+
extract_from_response
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
} catch (err) {
|
|
452
|
+
console.error(` ❌ Error: ${err.message}`);
|
|
453
|
+
if (err.response) {
|
|
454
|
+
console.log(` ⚠️ HTTP ${err.response.status}: ${err.response.statusText}`);
|
|
455
|
+
console.log(` ⚠️ Body (first 500): ${typeof err.response.data === 'string' ? err.response.data.substring(0, 500) : JSON.stringify(err.response.data).substring(0, 500)}`);
|
|
456
|
+
}
|
|
457
|
+
return { success: false, status: err.response?.status || 0, error: err.message, data: null };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function parseCallspecResult(response, data, extract_from_response) {
|
|
462
|
+
const extracted = {};
|
|
463
|
+
if (extract_from_response && typeof data === 'string') {
|
|
464
|
+
if (extract_from_response?.lock_handle) {
|
|
465
|
+
const m = data.match(/<(?:adtcore:)?(?:lock[Hh]andle|LOCK_HANDLE)>(.*?)<\/(?:adtcore:)?(?:lock[Hh]andle|LOCK_HANDLE)>/);
|
|
466
|
+
if (m) extracted.lockHandle = m[1];
|
|
467
|
+
}
|
|
468
|
+
if (extract_from_response?.transport_request) {
|
|
469
|
+
const m = data.match(/<(?:adtcore:)?(?:corrNr|CORRNR)>(.*?)<\/(?:adtcore:)?(?:corrNr|CORRNR)>/);
|
|
470
|
+
if (m) extracted.transportRequest = m[1];
|
|
471
|
+
}
|
|
472
|
+
if (extract_from_response?.function_group) {
|
|
473
|
+
const m = data.match(/PNAME[^>]*>([^<]*)</i);
|
|
474
|
+
if (m) extracted.functionGroup = m[1].replace(/^SAPL/, '');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const { hasErrors, errorMessages } = parseSAPResponse(data);
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
success: !hasErrors, // old client behavior: success based on SAP errors, not HTTP status
|
|
482
|
+
status: response.status,
|
|
483
|
+
statusText: response.statusText,
|
|
484
|
+
headers: response.headers,
|
|
485
|
+
data: { raw: data, ...extracted },
|
|
486
|
+
...(errorMessages.length > 0 && {
|
|
487
|
+
error: `SAP error: ${errorMessages.join('; ')}`,
|
|
488
|
+
errorDetails: errorMessages,
|
|
489
|
+
}),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
494
|
+
// Handle a full MCP request (array of callspecs)
|
|
495
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
496
|
+
|
|
497
|
+
async function handleRequest(payload) {
|
|
498
|
+
const { action, callspecs = [], requestId, system } = payload;
|
|
499
|
+
// Resolve systemId from message root OR from first callspec's system field
|
|
500
|
+
const systemId = system || (callspecs[0]?.system) || '';
|
|
501
|
+
|
|
502
|
+
console.log(`\n📨 Action: ${action} (${callspecs.length} step(s))${systemId ? ` → ${systemId}` : ''}`);
|
|
503
|
+
|
|
504
|
+
// ── switch_system: change the default system ID ───────────────────────────
|
|
505
|
+
if (action === 'switch_system') {
|
|
506
|
+
if (!systemId) {
|
|
507
|
+
return { requestId, success: false, error: 'switch_system requires a "system" field', data: [] };
|
|
508
|
+
}
|
|
509
|
+
const sys = config.getSystem(systemId);
|
|
510
|
+
if (!sys) {
|
|
511
|
+
return { requestId, success: false, error: `Unknown system: "${systemId}"`, data: [] };
|
|
512
|
+
}
|
|
513
|
+
if (!config.isSystemEnabled(systemId)) {
|
|
514
|
+
return { requestId, success: false, error: `System "${systemId}" is disabled`, data: [] };
|
|
515
|
+
}
|
|
516
|
+
defaultSystemId = systemId;
|
|
517
|
+
console.log(` ✅ Switched to system: ${systemId} (${sys.authMode})`);
|
|
518
|
+
// Pre-warm the session for this system
|
|
519
|
+
try { await getSessionForSystem(systemId); } catch { /* will retry on first call */ }
|
|
520
|
+
return { requestId, success: true, data: [{ success: true, data: { system: systemId, authMode: sys.authMode, message: `Switched to ${systemId}` } }] };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ── Direct RFC FM call ─────────────────────────────────────────────────
|
|
524
|
+
if (action === 'invoke_rfc') {
|
|
525
|
+
if (!systemId) {
|
|
526
|
+
return { requestId, success: false, error: 'invoke_rfc requires a "system" field in the message', data: [] };
|
|
527
|
+
}
|
|
528
|
+
let sessionEntry;
|
|
529
|
+
try {
|
|
530
|
+
sessionEntry = await getSessionForSystem(systemId);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
return { requestId, success: false, error: err.message, data: [{ success: false, error: err.message }] };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (!sessionEntry.rfcClient) {
|
|
536
|
+
return { requestId, success: false, error: `System "${systemId}" does not support RFC calls`, data: [] };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const spec = callspecs[0] || {};
|
|
540
|
+
const { fm, importing = {}, tables = [], table_fields = ['LINE'] } = spec;
|
|
541
|
+
console.log(` 🔧 RFC FM: ${fm}`, importing);
|
|
542
|
+
try {
|
|
543
|
+
const tableResult = await sessionEntry.rfcClient.callFM(fm, importing, tables, table_fields);
|
|
544
|
+
const firstTable = tables[0] || '';
|
|
545
|
+
const lines = (tableResult[firstTable] || []).map(l => (l || '').trimEnd());
|
|
546
|
+
const source = lines.join('\n');
|
|
547
|
+
console.log(` ✅ ${fm}: ${lines.length} lines returned`);
|
|
548
|
+
return { requestId, success: true, data: [{ success: true, data: { raw: source } }] };
|
|
549
|
+
} catch (err) {
|
|
550
|
+
console.error(` ❌ RFC FM call failed: ${err.message}`);
|
|
551
|
+
return { requestId, success: false, error: err.message, data: [{ success: false, error: err.message }] };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ── Standard callspec execution ──────────────────────────────────────────
|
|
556
|
+
// Validate that callspecs have the required fields
|
|
557
|
+
if (!Array.isArray(callspecs) || callspecs.length === 0) {
|
|
558
|
+
return { requestId, success: false, error: `No callspecs provided for action "${action}"`, data: [] };
|
|
559
|
+
}
|
|
560
|
+
for (let i = 0; i < callspecs.length; i++) {
|
|
561
|
+
const cs = callspecs[i];
|
|
562
|
+
if (!cs.method && !cs.url && !cs.command) {
|
|
563
|
+
console.error(` ❌ callspec[${i}] has no method, url, or command`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const results = [];
|
|
568
|
+
let extractedValues = {};
|
|
569
|
+
|
|
570
|
+
for (let i = 0; i < callspecs.length; i++) {
|
|
571
|
+
const cs = callspecs[i];
|
|
572
|
+
console.log(`\n Step ${i + 1}/${callspecs.length}: ${cs.name || cs.description || cs.method}`);
|
|
573
|
+
|
|
574
|
+
if (extractedValues.functionGroup) {
|
|
575
|
+
cs.url = cs.url?.replace('<FUNCTION_GROUP>', extractedValues.functionGroup);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const result = await executeCallspec(cs, extractedValues, systemId);
|
|
579
|
+
results.push(result);
|
|
580
|
+
|
|
581
|
+
if (result.data && typeof result.data === 'object') {
|
|
582
|
+
Object.assign(extractedValues, result.data);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (!result.success && !cs.continueOnError) break;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const allOk = results.every(r => r.success);
|
|
589
|
+
return { requestId, success: allOk, data: results };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
593
|
+
// WebSocket connection loop
|
|
594
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
595
|
+
|
|
596
|
+
let ws = null;
|
|
597
|
+
|
|
598
|
+
async function connect() {
|
|
599
|
+
console.log(`\n🔌 Connecting to ${WS_URL} ...`);
|
|
600
|
+
|
|
601
|
+
ws = new WebSocket(WS_URL, {
|
|
602
|
+
headers: { Authorization: `Bearer ${MCP_API_KEY}` },
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
ws.on('open', () => {
|
|
606
|
+
console.log('✅ Connected to abap-mcp WebSocket server');
|
|
607
|
+
|
|
608
|
+
const allSystems = config.listSystems();
|
|
609
|
+
if (allSystems.length > 0) {
|
|
610
|
+
console.log(` Configured systems: ${allSystems.map(s => s.systemId).join(', ')}`);
|
|
611
|
+
// List enabled systems
|
|
612
|
+
const enabled = allSystems.filter(s => s.enabled);
|
|
613
|
+
console.log(` Enabled: ${enabled.map(s => `${s.systemId} (${s.authMode})`).join(', ')}`);
|
|
614
|
+
} else {
|
|
615
|
+
console.log(' No systems configured. Run: node config-cli.mjs add-system');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (defaultSystemId || getDefaultSystemId()) {
|
|
619
|
+
console.log(` Default system: ${defaultSystemId || getDefaultSystemId()}`);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
ws.on('message', async (raw) => {
|
|
624
|
+
let msg;
|
|
625
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
626
|
+
|
|
627
|
+
if (msg.type === 'welcome') {
|
|
628
|
+
console.log(` Server: ${msg.message}`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (msg.type === 'ping') {
|
|
633
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (msg.action || msg.callspecs) {
|
|
638
|
+
// Debug: log full incoming message
|
|
639
|
+
console.log(`\n📨 INCOMING: ${JSON.stringify(msg, null, 2).substring(0, 500)}`);
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
const result = await handleRequest(msg);
|
|
643
|
+
ws.send(JSON.stringify({ type: 'response', ...result }));
|
|
644
|
+
} catch (err) {
|
|
645
|
+
ws.send(JSON.stringify({
|
|
646
|
+
type: 'response',
|
|
647
|
+
requestId: msg.requestId,
|
|
648
|
+
success: false,
|
|
649
|
+
error: err.message,
|
|
650
|
+
}));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
ws.on('error', (err) => {
|
|
656
|
+
console.error(`⚠️ WebSocket error: ${err.message}`);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
ws.on('close', () => {
|
|
660
|
+
console.log(`\n🔴 Disconnected. Reconnecting in ${RECONNECT_DELAY / 1000}s...`);
|
|
661
|
+
// Disconnect all RFC clients
|
|
662
|
+
for (const [, entry] of sessions) {
|
|
663
|
+
if (entry.rfcClient) {
|
|
664
|
+
try { entry.rfcClient.disconnect(); } catch {}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
sessions.clear();
|
|
668
|
+
setTimeout(connect, RECONNECT_DELAY);
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
673
|
+
// Start
|
|
674
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
675
|
+
|
|
676
|
+
console.log('╔═══════════════════════════════════════════════════════════╗');
|
|
677
|
+
console.log('║ SAP Local Client — Multi-System SSO Bridge ║');
|
|
678
|
+
console.log('╚═══════════════════════════════════════════════════════════╝');
|
|
679
|
+
|
|
680
|
+
loadGlobalConfig();
|
|
681
|
+
|
|
682
|
+
console.log(` WS URL: ${WS_URL}`);
|
|
683
|
+
console.log(` API Key: ${MCP_API_KEY.substring(0, 10)}...`);
|
|
684
|
+
console.log(` Store: ${config.getConfigFilePath()}`);
|
|
685
|
+
console.log(` Fallback: ${config.getArcanumFallback() ? 'Arcanum' : 'local config only'}`);
|
|
686
|
+
|
|
687
|
+
connect();
|