abap-local-client 1.3.1 → 1.4.1

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.
@@ -1,727 +1,835 @@
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 — with inline CLI for setup/config when run directly
674
- // ════════════════════════════════════════════════════════════════════════════
675
-
676
- const args = process.argv.slice(2);
677
- const command = args[0];
678
-
679
- // If the user ran "node sso-sap-client.mjs setup" or "npx abap-local-client config xyz",
680
- // delegate to the config CLI instead of starting the WebSocket client.
681
- if (command === 'setup' || command === 'config' || command === 'add-system' || command === 'set-mcp-key' ||
682
- command === 'list-systems' || command === 'remove-system' || command === 'enable-system' ||
683
- command === 'disable-system' || command === 'show-config' || command === 'arcanum-fallback' ||
684
- command === 'set-ws-url' || command === 'help') {
685
- // Dynamically import the config CLI so it processes the same argv
686
- await import('./config-cli.mjs');
687
- process.exit(0);
688
- }
689
-
690
- // If no config exists, use built-in default (S4D Piracanjuba SNC + API key)
691
- if (!config.isConfigInitialized()) {
692
- console.log('╔═══════════════════════════════════════════════════════════╗');
693
- console.log('║ SAP Local Client — First Run ║');
694
- console.log('║ Using default config: S4D (Piracanjuba) via SNC ║');
695
- console.log('╚═══════════════════════════════════════════════════════════╝');
696
-
697
- // Save the default config so it persists across restarts
698
- config.addOrUpdateSystem('S4D', {
699
- authMode: 'snc',
700
- connection: {
701
- host: 'vhlbvs4dci.sap.piracanjuba.com.br',
702
- sysnr: '00',
703
- client: '100',
704
- username: '',
705
- sncPartnerName: 'p:CN=svc.ssoS4D',
706
- sncQop: '3',
707
- language: 'PT',
708
- },
709
- enabled: true,
710
- });
711
- config.setMcpApiKey('arc_de2fb7965d536a5911ffd7d9ba39d70c');
712
- console.log(' Config saved. Edit with: node sso-sap-client.mjs help');
713
- console.log('');
714
- }
715
-
716
- console.log('╔═══════════════════════════════════════════════════════════╗');
717
- console.log('║ SAP Local Client — Multi-System SSO Bridge ║');
718
- console.log('╚═══════════════════════════════════════════════════════════╝');
719
-
720
- loadGlobalConfig();
721
-
722
- console.log(` WS URL: ${WS_URL}`);
723
- console.log(` API Key: ${MCP_API_KEY.substring(0, 10)}...`);
724
- console.log(` Store: ${config.getConfigFilePath()}`);
725
- console.log(` Fallback: ${config.getArcanumFallback() ? 'Arcanum' : 'local config only'}`);
726
-
727
- connect();
1
+ /**
2
+ * SSO SAP Client — websocket-client.js with multi-auth
3
+ *
4
+ * Uses the proven websocket-client.js SAPClient, LockManager, executeCallspecs
5
+ * (6 months production), with multi-system auth injected via config-manager.
6
+ */
7
+
8
+ import WebSocket from 'ws';
9
+ import axios from 'axios';
10
+ import https from 'https';
11
+ import { parseSAPResponse } from './xml-utils.js';
12
+ import { BasicSession } from './basic-auth.mjs';
13
+ import { SAPRFCClient } from './sap-rfc-client.js';
14
+ import * as config from './config-manager.mjs';
15
+
16
+ try { process.loadEnvFile?.(); } catch {}
17
+ const env = process.env;
18
+
19
+ // ════════════════════════════════════════════════════════════════════════════
20
+ // Multi-system auth session cache
21
+ // ════════════════════════════════════════════════════════════════════════════
22
+
23
+ const sessions = new Map();
24
+ const pendingSessions = new Map();
25
+ let defaultSystemId = null;
26
+
27
+ function getDefaultSystemId() {
28
+ if (defaultSystemId) return defaultSystemId;
29
+ const allSystems = config.listSystems();
30
+ return defaultSystemId;
31
+ }
32
+
33
+ async function getSessionForSystem(systemId) {
34
+ if (!systemId) throw new Error('No system selected. Use switch_system first.');
35
+ const cacheKey = systemId;
36
+ if (sessions.has(cacheKey)) return sessions.get(cacheKey);
37
+ if (pendingSessions.has(cacheKey)) return await pendingSessions.get(cacheKey);
38
+
39
+ const initPromise = _createSession(systemId);
40
+ pendingSessions.set(cacheKey, initPromise);
41
+ try {
42
+ const entry = await initPromise;
43
+ sessions.set(cacheKey, entry);
44
+ return entry;
45
+ } finally {
46
+ pendingSessions.delete(cacheKey);
47
+ }
48
+ }
49
+
50
+ async function _createSession(resolvedId) {
51
+ const sysConfig = config.getSystem(resolvedId);
52
+ if (!sysConfig) throw new Error(`Unknown system: ${resolvedId}`);
53
+ const conn = sysConfig.connection || {};
54
+ const authMode = sysConfig.authMode || 'basic';
55
+ const hasSaprouter = !!(conn.saprouter);
56
+
57
+ console.log(`\n🔐 Creating auth session for "${resolvedId}" (${authMode})`);
58
+
59
+ const baseUrl = `https://${conn.host}:${conn.port || '44301'}`;
60
+
61
+ let httpSession = null;
62
+ let rfcClient = null;
63
+ let useRfc = (authMode === 'snc' || hasSaprouter);
64
+
65
+ if (useRfc) {
66
+ console.log(` 🛰️ RFC mode (${authMode === 'snc' ? 'SNC' : 'Basic'})`);
67
+ const rfcParams = {
68
+ host: conn.host, sysnr: conn.sysnr || '00',
69
+ client: conn.client || '100', username: conn.username || '',
70
+ password: conn.password || '', language: conn.language || 'EN',
71
+ saprouter: conn.saprouter || '',
72
+ };
73
+ if (authMode === 'snc') {
74
+ rfcParams.sncMode = '1';
75
+ if (conn.sncPartnerName) rfcParams.sncPartnerName = conn.sncPartnerName;
76
+ if (conn.sncMyName) rfcParams.sncMyName = conn.sncMyName;
77
+ if (conn.sncQop) rfcParams.sncQop = conn.sncQop;
78
+ if (conn.sncLib) rfcParams.sncLib = conn.sncLib;
79
+ console.log(` 🔐 SNC partner: ${conn.sncPartnerName || '(not set)'}`);
80
+ }
81
+ rfcClient = new SAPRFCClient(rfcParams);
82
+ try {
83
+ await rfcClient.connect();
84
+ console.log(` RFC connection established`);
85
+ } catch (err) {
86
+ console.error(` ⚠️ RFC connection failed: ${err.message}`);
87
+ rfcClient = null;
88
+ useRfc = false;
89
+ }
90
+ }
91
+
92
+ if (!useRfc && authMode === 'basic') {
93
+ httpSession = new BasicSession(axios, baseUrl, conn.client || '100',
94
+ conn.username || '', conn.password || '',
95
+ { language: conn.language || 'EN' });
96
+ await httpSession.init();
97
+ } else if (!useRfc) {
98
+ throw new Error(`RFC connection required for auth mode "${authMode}" but failed to connect`);
99
+ }
100
+
101
+ return { useRfc, rfcClient, httpSession, authMode, sysConfig, resolvedId };
102
+ }
103
+
104
+ // ════════════════════════════════════════════════════════════════════════════
105
+ // SAP Systems Registry — Arcanum
106
+ // ════════════════════════════════════════════════════════════════════════════
107
+
108
+ const ARCANUM_ADMIN_URL = 'https://arcanum.mcps.b2rise.com';
109
+
110
+ const sapSystems = new Map();
111
+
112
+ async function fetchSapSystems() {
113
+ try {
114
+ const response = await axios.post(
115
+ `${ARCANUM_ADMIN_URL}/api/sap-environments/by-api-key`,
116
+ { key: MCP_API_KEY }, { timeout: 15_000 }
117
+ );
118
+ if (!response.data.valid) return;
119
+ for (const [key, cfg] of Object.entries(response.data.systems || {})) {
120
+ sapSystems.set(key, cfg);
121
+ }
122
+ console.log(`🌐 SAP systems loaded from Arcanum: ${sapSystems.size} system(s)`);
123
+ } catch (err) {
124
+ console.warn(`⚠️ Could not fetch SAP systems: ${err.message}`);
125
+ }
126
+ }
127
+
128
+ // ════════════════════════════════════════════════════════════════════════════
129
+ // HTTP Session Manager — cookie jar + CSRF cache (for http_call)
130
+ // ════════════════════════════════════════════════════════════════════════════
131
+
132
+ const SESSION_TTL_MS = 30 * 60 * 1000;
133
+ const SESSION_CLEANUP_MS = 60 * 1000;
134
+
135
+ class SessionManager {
136
+ constructor() {
137
+ this.sessions = new Map();
138
+ this._cleanup = setInterval(() => this._evict(), SESSION_CLEANUP_MS);
139
+ this._cleanup.unref?.();
140
+ }
141
+ _key(sid, sys) { return `${sid}__${sys}`; }
142
+ getOrCreate(sid, sys) {
143
+ const key = this._key(sid, sys);
144
+ if (!this.sessions.has(key))
145
+ this.sessions.set(key, { cookieJar: new Map(), csrfToken: null, system: sys, lastUsedAt: Date.now() });
146
+ const s = this.sessions.get(key);
147
+ s.lastUsedAt = Date.now();
148
+ return s;
149
+ }
150
+ _evict() {
151
+ const now = Date.now();
152
+ for (const [k, s] of this.sessions)
153
+ if (now - s.lastUsedAt > SESSION_TTL_MS) this.sessions.delete(k);
154
+ }
155
+ }
156
+ const sessionManager = new SessionManager();
157
+
158
+ // ════════════════════════════════════════════════════════════════════════════
159
+ // HTTP Call Handler (http_call action)
160
+ // ════════════════════════════════════════════════════════════════════════════
161
+
162
+ function mapNetError(err, timeoutMs) {
163
+ const code = err.code || '';
164
+ if (code === 'ECONNABORTED' || err.message?.includes('timeout')) return `timeout after ${timeoutMs}ms`;
165
+ if (code === 'ENOTFOUND') return `DNS resolution failed`;
166
+ if (code === 'ECONNREFUSED') return 'connection refused';
167
+ if (err.message?.toLowerCase().includes('ssl') || err.message?.toLowerCase().includes('certificate'))
168
+ return `SSL handshake failed`;
169
+ return err.message || 'unknown network error';
170
+ }
171
+
172
+ function makeHttpEnvelope(partial) {
173
+ return { status: 0, status_text: '', headers: {}, body: null, body_raw: '',
174
+ duration_ms: 0, session_id: 'default', csrf_token_cached: false,
175
+ cookies_count: 0, request_url_resolved: '', error: null, ...partial };
176
+ }
177
+
178
+ function wrapHttpResult(env) {
179
+ return { success: env.error == null || env.status > 0, data: { success: env.error == null || env.status > 0, data: env } };
180
+ }
181
+
182
+ function parseResponseBody(res) {
183
+ const ct = (res.headers?.['content-type'] || '').toLowerCase();
184
+ const raw = typeof res.data === 'string' ? res.data : (res.data != null ? JSON.stringify(res.data) : '');
185
+ let parsed = null;
186
+ if (ct.includes('json')) {
187
+ try { parsed = typeof res.data === 'object' ? res.data : JSON.parse(raw); } catch {}
188
+ }
189
+ return { body: parsed, body_raw: raw };
190
+ }
191
+
192
+ async function fetchCsrfForSession(session, sysCfg, httpsAgent) {
193
+ try {
194
+ const res = await axios.get(`${sysCfg.baseUrl}/sap/bc/adt/discovery`, {
195
+ headers: { 'X-CSRF-Token': 'Fetch', 'Accept': 'application/atomsvc+xml', 'sap-client': sysCfg.client,
196
+ 'Cookie': [...session.cookieJar.values()].join('; ') },
197
+ auth: { username: sysCfg.username, password: sysCfg.password },
198
+ httpsAgent, timeout: 15_000, validateStatus: () => true
199
+ });
200
+ const token = res.headers['x-csrf-token'];
201
+ if (token && token.toLowerCase() !== 'required') session.csrfToken = token;
202
+ const setCookies = res.headers['set-cookie'];
203
+ if (Array.isArray(setCookies))
204
+ for (const c of setCookies) { const name = c.split('=')[0].trim(); session.cookieJar.set(name, c.split(';')[0].trim()); }
205
+ } catch {}
206
+ }
207
+
208
+ async function doAxiosCall(callspec, sysCfg, session, httpsAgent) {
209
+ const isAbsolute = callspec.path.startsWith('http');
210
+ const url = isAbsolute ? callspec.path : `${sysCfg.baseUrl}${callspec.path}`;
211
+ const reqHeaders = { ...(callspec.headers || {}) };
212
+ delete reqHeaders['Authorization']; delete reqHeaders['authorization'];
213
+ reqHeaders['sap-client'] = sysCfg.client;
214
+ reqHeaders['sap-language'] = sysCfg.language || 'EN';
215
+ if (session.csrfToken) reqHeaders['X-CSRF-Token'] = session.csrfToken;
216
+ if (session.cookieJar.size > 0) reqHeaders['Cookie'] = [...session.cookieJar.values()].join('; ');
217
+ let bodyData = callspec.body ?? null;
218
+ if (bodyData !== null && typeof bodyData === 'object') {
219
+ if (!reqHeaders['Content-Type'] && !reqHeaders['content-type']) reqHeaders['Content-Type'] = 'application/json';
220
+ bodyData = JSON.stringify(bodyData);
221
+ }
222
+ const res = await axios({
223
+ method: callspec.method.toLowerCase(), url, headers: reqHeaders, data: bodyData,
224
+ auth: { username: sysCfg.username, password: sysCfg.password },
225
+ httpsAgent, timeout: callspec.timeout_ms ?? 30_000,
226
+ maxRedirects: callspec.follow_redirects === false ? 0 : 5,
227
+ validateStatus: () => true
228
+ });
229
+ const setCookies = res.headers['set-cookie'];
230
+ if (Array.isArray(setCookies))
231
+ for (const c of setCookies) { const name = c.split('=')[0].trim(); session.cookieJar.set(name, c.split(';')[0].trim()); }
232
+ if (res.headers['x-csrf-token']) session.csrfToken = res.headers['x-csrf-token'];
233
+ return res;
234
+ }
235
+
236
+ async function handleHttpCall(callspec, httpsAgent) {
237
+ const sessionId = callspec.session_id || 'default';
238
+ const systemKey = callspec.system || '';
239
+ const start = Date.now();
240
+ const sysCfg = sapSystems.get(systemKey);
241
+ if (!sysCfg) return wrapHttpResult(makeHttpEnvelope({ status: 0, error: `Unknown system: ${systemKey}`, duration_ms: 0, session_id: sessionId }));
242
+ if (callspec.path.startsWith('http')) {
243
+ try { if (new URL(callspec.path).host !== new URL(sysCfg.baseUrl).host) return wrapHttpResult(makeHttpEnvelope({ status: 0, error: 'forbidden_url', session_id: sessionId, duration_ms: 0 })); }
244
+ catch { return wrapHttpResult(makeHttpEnvelope({ status: 0, error: 'invalid_path', session_id: sessionId, duration_ms: 0 })); }
245
+ } else if (!callspec.path.startsWith('/'))
246
+ return wrapHttpResult(makeHttpEnvelope({ status: 0, error: 'invalid_path', session_id: sessionId, duration_ms: 0 }));
247
+ const session = sessionManager.getOrCreate(sessionId, systemKey);
248
+ const stateChanging = !['GET', 'HEAD', 'OPTIONS'].includes(callspec.method?.toUpperCase());
249
+ if (callspec.auto_csrf !== false && stateChanging && !session.csrfToken) await fetchCsrfForSession(session, sysCfg, httpsAgent);
250
+ let res;
251
+ try { res = await doAxiosCall(callspec, sysCfg, session, httpsAgent); }
252
+ catch (err) { return wrapHttpResult(makeHttpEnvelope({ status: 0, error: mapNetError(err, callspec.timeout_ms ?? 30_000), duration_ms: Date.now() - start, session_id: sessionId })); }
253
+ const csrfHeader = (res.headers['x-csrf-token'] || '').toLowerCase();
254
+ if (res.status === 403 && csrfHeader === 'required' && callspec.auto_csrf !== false) {
255
+ session.csrfToken = null; await fetchCsrfForSession(session, sysCfg, httpsAgent);
256
+ if (!session.csrfToken) return wrapHttpResult(makeHttpEnvelope({ status: 403, error: 'csrf_failed', duration_ms: Date.now() - start, session_id: sessionId }));
257
+ try { res = await doAxiosCall(callspec, sysCfg, session, httpsAgent); }
258
+ catch (err) { return wrapHttpResult(makeHttpEnvelope({ status: 0, error: mapNetError(err, callspec.timeout_ms ?? 30_000), duration_ms: Date.now() - start, session_id: sessionId })); }
259
+ }
260
+ const { body, body_raw } = parseResponseBody(res);
261
+ return wrapHttpResult(makeHttpEnvelope({ status: res.status, status_text: res.statusText || '', headers: res.headers || {},
262
+ body, body_raw, duration_ms: Date.now() - start, session_id: sessionId,
263
+ csrf_token_cached: !!session.csrfToken, cookies_count: session.cookieJar.size,
264
+ request_url_resolved: callspec.path.startsWith('http') ? callspec.path : `${sysCfg.baseUrl}${callspec.path.split('?')[0]}`, error: null }));
265
+ }
266
+
267
+ // ════════════════════════════════════════════════════════════════════════════
268
+ // Lock Manager — IDENTICAL to websocket-client.js
269
+ // ════════════════════════════════════════════════════════════════════════════
270
+
271
+ class LockManager {
272
+ constructor() { this.locks = new Map(); this.lockTTL = 30 * 60 * 1000; }
273
+ getLock(objectName, objectType) {
274
+ const key = `${objectName.toUpperCase()}:${objectType.toUpperCase()}`;
275
+ const lock = this.locks.get(key);
276
+ if (!lock) return null;
277
+ if (Date.now() - lock.timestamp >= this.lockTTL) { console.log(` Lock expired for ${key}`); this.locks.delete(key); return null; }
278
+ console.log(` 🔓 Found cached lock for ${key}`);
279
+ return lock;
280
+ }
281
+ saveLock(objectName, objectType, lockHandle, transportRequest) {
282
+ this.locks.set(`${objectName.toUpperCase()}:${objectType.toUpperCase()}`, { lockHandle, transportRequest, timestamp: Date.now() });
283
+ }
284
+ clearLock(objectName, objectType) { this.locks.delete(`${objectName.toUpperCase()}:${objectType.toUpperCase()}`); }
285
+ clearAll() { const c = this.locks.size; this.locks.clear(); if (c > 0) console.log(` 🧹 All locks cleared (${c} lock(s))`); }
286
+ getStats() { return { activeLocks: this.locks.size, locks: Array.from(this.locks.keys()) }; }
287
+ }
288
+
289
+ // ════════════════════════════════════════════════════════════════════════════
290
+ // SAP HTTP Client — IDENTICAL to websocket-client.js, but config from session
291
+ // ════════════════════════════════════════════════════════════════════════════
292
+
293
+ class SAPClient {
294
+ constructor(sessionEntry) {
295
+ this.session = sessionEntry;
296
+ this.csrfToken = null;
297
+ this.cookies = new Map();
298
+
299
+ const conn = sessionEntry.sysConfig?.connection || {};
300
+ const baseUrl = `https://${conn.host}:${conn.port || '44301'}`;
301
+
302
+ if (sessionEntry.useRfc && sessionEntry.rfcClient) {
303
+ this.rfcClient = sessionEntry.rfcClient;
304
+ console.log('🔀 SAPClient running in RFC mode (SAProuter)');
305
+ } else {
306
+ this.rfcClient = null;
307
+ }
308
+
309
+ // Keep cookies from HTTP session if available
310
+ const hs = sessionEntry.httpSession;
311
+ if (sessionEntry.httpSession) {
312
+ if (hs.csrfToken) {
313
+ this.csrfToken = hs.csrfToken;
314
+ console.log(` 🔑 CSRF token seeded from HTTP session: ${this.csrfToken.substring(0, 15)}...`);
315
+ }
316
+ if (hs.cookies?.size > 0) {
317
+ // BasicSession stores cookies as Map<name, value> (just the value, no "name=" prefix)
318
+ for (const [name, value] of hs.cookies) {
319
+ // Normalise to "name=value" format (same as what the interceptor stores)
320
+ this.cookies.set(name, value.includes('=') ? value : `${name}=${value}`);
321
+ }
322
+ console.log(` 🍪 ${this.cookies.size} cookie(s) seeded from BasicSession`);
323
+ }
324
+ }
325
+
326
+ this.client = axios.create({
327
+ baseURL: baseUrl,
328
+ headers: {
329
+ 'sap-client': conn.client || '100',
330
+ 'sap-language': conn.language || 'EN',
331
+ 'Accept': 'application/xml,text/plain,*/*',
332
+ 'X-sap-adt-sessiontype': 'stateful'
333
+ },
334
+ httpsAgent: new https.Agent({ rejectUnauthorized: false }),
335
+ auth: { username: conn.username || '', password: conn.password || '' }
336
+ });
337
+
338
+ this.client.interceptors.response.use(
339
+ response => {
340
+ if (response.headers['set-cookie']) {
341
+ response.headers['set-cookie'].forEach(cookieString => {
342
+ const cv = cookieString.split(';')[0];
343
+ this.cookies.set(cv.split('=')[0], cv);
344
+ });
345
+ }
346
+ if (response.headers['x-csrf-token']) {
347
+ this.csrfToken = response.headers['x-csrf-token'];
348
+ console.log(` 🔑 CSRF token obtained: ${this.csrfToken.substring(0, 15)}...`);
349
+ }
350
+ return response;
351
+ },
352
+ error => {
353
+ if (error.response?.status === 401 || error.response?.status === 403) {
354
+ console.error(` ⚠️ Session error (${error.response.status}) - clearing...`);
355
+ this.clearSession();
356
+ }
357
+ return Promise.reject(error);
358
+ }
359
+ );
360
+
361
+ this.client.interceptors.request.use(request => {
362
+ if (this.cookies.size > 0)
363
+ request.headers['Cookie'] = Array.from(this.cookies.values()).join('; ');
364
+ return request;
365
+ });
366
+ }
367
+
368
+ async executeCallspec(callspec) {
369
+ if (this.rfcClient) return this._executeCallspecViaRFC(callspec);
370
+ return this._executeCallspecViaHTTP(callspec);
371
+ }
372
+
373
+ async _executeCallspecViaRFC(callspec) {
374
+ const { method, url, headers, extract_from_response } = callspec;
375
+ const conn = this.session.sysConfig?.connection || {};
376
+ let finalHeaders = { ...headers };
377
+ finalHeaders['sap-client'] = finalHeaders['sap-client'] || conn.client || '100';
378
+ finalHeaders['sap-language'] = finalHeaders['sap-language'] || conn.language || 'EN';
379
+ if (finalHeaders['X-CSRF-Token'] === '<CSRF_TOKEN>') {
380
+ if (!this.rfcClient.csrfToken) {
381
+ this.rfcClient.csrfToken = this.csrfToken;
382
+ }
383
+ if (!this.rfcClient.csrfToken) {
384
+ await this.rfcClient.fetchCsrfToken();
385
+ }
386
+ if (this.rfcClient.csrfToken) {
387
+ finalHeaders['X-CSRF-Token'] = this.rfcClient.csrfToken;
388
+ console.log(` 🔑 RFC CSRF token: "${finalHeaders['X-CSRF-Token'].substring(0,20)}..."`);
389
+ } else {
390
+ delete finalHeaders['X-CSRF-Token'];
391
+ console.log(` ⚠️ No CSRF token available — removing X-CSRF-Token header (RFC uses Basic Auth)`);
392
+ }
393
+ }
394
+ delete finalHeaders['Authorization'];
395
+ if (finalHeaders['Cookie'] === '<SESSION_COOKIE>') delete finalHeaders['Cookie'];
396
+ const creds = Buffer.from(`${conn.username || ''}:${conn.password || ''}`).toString('base64');
397
+ finalHeaders['Authorization'] = `Basic ${creds}`;
398
+
399
+ // ── Substitute body placeholders ────────────────────────────────────────
400
+ // abap-mcp-local sends placeholders like <SAP_LANGUAGE>, <SAP_CLIENT> in
401
+ // the XML body. Replace them with real values from the connection config.
402
+ let body = callspec.body ?? null;
403
+ if (typeof body === 'string') {
404
+ const lang = conn.language || 'EN';
405
+ const client = conn.client || '100';
406
+ const user = (conn.username || '').toUpperCase();
407
+ body = body
408
+ .replace(/<SAP_LANGUAGE>/g, lang)
409
+ .replace(/<SAP_CLIENT>/g, client)
410
+ .replace(/<SAP_USER>/g, user)
411
+ .replace(/<SAP_USERNAME>/g, user)
412
+ .replace(/<SAP_RESPONSIBLE>/g, user)
413
+ .replace(/<SAP_MASTER_LANG>/g, lang)
414
+ .replace(/<SAP_MASTER_SYSTEM>/g, conn.systemId || '');
415
+ }
416
+
417
+ let uriPath = url;
418
+ try { const p = new URL(url); uriPath = p.pathname + (p.search || ''); } catch {}
419
+
420
+ console.log(`\n ╔══════════════════════════════════════════════════════════════`);
421
+ console.log(` ║ 📡 RFC ${method} ${uriPath}`);
422
+ console.log(` ╠══════════════════════════════════════════════════════════════`);
423
+ console.log(` ║ HEADERS:`);
424
+ for (const [k, v] of Object.entries(finalHeaders)) {
425
+ if (k === 'Authorization') console.log(` ║ ${k}: Basic ***`);
426
+ else console.log(` ║ ${k}: ${v}`);
427
+ }
428
+ if (body) {
429
+ console.log(` ╠══════════════════════════════════════════════════════════════`);
430
+ console.log(` ║ BODY (${body.length} chars):`);
431
+ body.split('\n').forEach(line => console.log(` ║ ${line}`));
432
+ }
433
+ console.log(` ╚══════════════════════════════════════════════════════════════\n`);
434
+ try {
435
+ const response = await this.rfcClient.callADT(method, uriPath, finalHeaders, body || null);
436
+ console.log(` ✅ RFC response: ${response.status} ${response.statusText}`);
437
+ if (response.data && response.status >= 400) {
438
+ console.log(` 📄 RFC response body: ${typeof response.data === 'string' ? response.data.substring(0, 500) : JSON.stringify(response.data).substring(0, 500)}`);
439
+ }
440
+ const extractedData = {};
441
+ if (extract_from_response && typeof response.data === 'string') {
442
+ if (extract_from_response.lock_handle) { const m = response.data.match(/<(?:adtcore:)?(?:lock[Hh]andle|LOCK_HANDLE)>(.*?)<\/(?:adtcore:)?(?:lock[Hh]andle|LOCK_HANDLE)>/); if (m) extractedData.lockHandle = m[1]; }
443
+ if (extract_from_response.transport_request) { const m = response.data.match(/<(?:adtcore:)?(?:corrNr|CORRNR)>(.*?)<\/(?:adtcore:)?(?:corrNr|CORRNR)>/); if (m) extractedData.transportRequest = m[1]; }
444
+ if (extract_from_response.function_group) { const m = response.data.match(/PNAME[^>]*>([^<]*)</i); if (m) extractedData.functionGroup = m[1].replace(/^SAPL/, ''); }
445
+ }
446
+ const { hasErrors, errorMessages } = parseSAPResponse(response.data);
447
+ return { success: !hasErrors && response.status >= 200 && response.status < 300, status: response.status, statusText: response.statusText, headers: response.headers, data: { raw: response.data, ...extractedData }, ...(errorMessages.length > 0 && { error: `SAP validation failed: ${errorMessages.join('; ')}`, errorDetails: errorMessages }) };
448
+ } catch (err) {
449
+ console.error(` ❌ RFC error: ${err.message}`);
450
+ return { success: false, status: 0, error: err.message, data: null };
451
+ }
452
+ }
453
+
454
+ async _executeCallspecViaHTTP(callspec) {
455
+ const { method, url, headers, extract_from_response } = callspec;
456
+ let finalUrl = url;
457
+ let finalHeaders = { ...headers };
458
+
459
+ // ── CSRF injection ──────────────────────────────────────────────────────
460
+ // Inject on: (a) explicit <CSRF_TOKEN> placeholder, OR
461
+ // (b) any state-changing request (POST/PUT/PATCH/DELETE) so the
462
+ // client works even when abap-mcp-local omits the placeholder.
463
+ const isStateChanging = !['GET', 'HEAD', 'OPTIONS'].includes((method || '').toUpperCase());
464
+ const hasPlaceholder = finalHeaders['X-CSRF-Token'] === '<CSRF_TOKEN>';
465
+ if (hasPlaceholder || isStateChanging) {
466
+ if (!this.csrfToken) await this.fetchCsrfToken();
467
+ finalHeaders['X-CSRF-Token'] = this.csrfToken;
468
+ }
469
+
470
+ delete finalHeaders['Authorization'];
471
+ if (finalHeaders['Cookie'] === '<SESSION_COOKIE>') delete finalHeaders['Cookie'];
472
+
473
+ // ── Substitute body placeholders (same as RFC path) ────────────────────
474
+ let rawBody = callspec.body ?? undefined;
475
+ if (typeof rawBody === 'string') {
476
+ const conn2 = this.session?.sysConfig?.connection || {};
477
+ const lang2 = conn2.language || 'EN';
478
+ const user2 = (conn2.username || '').toUpperCase();
479
+ rawBody = rawBody
480
+ .replace(/<SAP_LANGUAGE>/g, lang2)
481
+ .replace(/<SAP_CLIENT>/g, conn2.client || '100')
482
+ .replace(/<SAP_USER>/g, user2)
483
+ .replace(/<SAP_USERNAME>/g, user2)
484
+ .replace(/<SAP_RESPONSIBLE>/g, user2)
485
+ .replace(/<SAP_MASTER_LANG>/g, lang2)
486
+ .replace(/<SAP_MASTER_SYSTEM>/g, conn2.systemId || '');
487
+ }
488
+
489
+ // ── body: use ?? so empty-string bodies (LOCK, ACTIVATE) are preserved ──
490
+ const bodyData = rawBody;
491
+
492
+ // ── Full request dump ───────────────────────────────────────────────────
493
+ console.log(`\n ╔══════════════════════════════════════════════════════════════`);
494
+ console.log(` ║ 📤 REQUEST: ${method} ${finalUrl}`);
495
+ console.log(` ╠══════════════════════════════════════════════════════════════`);
496
+ console.log(` ║ HEADERS SENT:`);
497
+ // Merge: instance defaults + per-request headers (axios will do the same)
498
+ const instanceDefaults = this.client.defaults?.headers || {};
499
+ const merged = {
500
+ ...(instanceDefaults.common || {}),
501
+ ...((instanceDefaults[(method || '').toLowerCase()]) || {}),
502
+ ...finalHeaders,
503
+ };
504
+ for (const [k, v] of Object.entries(merged)) {
505
+ if (k === 'Authorization') console.log(` ║ ${k}: Basic ***`);
506
+ else console.log(` ║ ${k}: ${v}`);
507
+ }
508
+ console.log(` ╠══════════════════════════════════════════════════════════════`);
509
+ console.log(` ║ CSRF token in session: ${this.csrfToken ? this.csrfToken.substring(0, 20) + '...' : 'NONE ⚠️'}`);
510
+ console.log(` ║ Cookies in jar: ${this.cookies.size}`);
511
+ if (this.cookies.size > 0) {
512
+ for (const [k] of this.cookies) console.log(` ║ cookie: ${k}`);
513
+ }
514
+ console.log(` ╠══════════════════════════════════════════════════════════════`);
515
+ if (bodyData !== undefined && bodyData !== null) {
516
+ const bodyStr = typeof bodyData === 'string' ? bodyData : JSON.stringify(bodyData);
517
+ console.log(` BODY (${bodyStr.length} chars, type=${typeof bodyData}):`);
518
+ bodyStr.split('\n').slice(0, 40).forEach(line => console.log(` ║ ${line}`));
519
+ if (bodyStr.split('\n').length > 40) console.log(` ║ ... (truncated)`);
520
+ } else {
521
+ console.log(` ║ BODY: (none)`);
522
+ }
523
+ console.log(` ╚══════════════════════════════════════════════════════════════\n`);
524
+
525
+ try {
526
+ const response = await this.client.request({ method, url: finalUrl, headers: finalHeaders, data: bodyData, timeout: 60000 });
527
+ console.log(` ✅ ${response.status} ${response.statusText}`);
528
+
529
+ // Log response headers + body
530
+ console.log(` ║ RESPONSE HEADERS:`);
531
+ for (const [k, v] of Object.entries(response.headers || {})) console.log(` ║ ${k}: ${v}`);
532
+ const resBodyStr = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
533
+ console.log(` ║ RESPONSE BODY (${resBodyStr?.length || 0} chars): ${resBodyStr?.substring(0, 800)}`);
534
+
535
+ const extractedData = {};
536
+ if (extract_from_response && typeof response.data === 'string') {
537
+ if (extract_from_response.lock_handle) { const m = response.data.match(/<(?:adtcore:)?(?:lock[Hh]andle|LOCK_HANDLE)>(.*?)<\/(?:adtcore:)?(?:lock[Hh]andle|LOCK_HANDLE)>/); if (m) extractedData.lockHandle = m[1]; }
538
+ if (extract_from_response.transport_request) { const m = response.data.match(/<(?:adtcore:)?(?:corrNr|CORRNR)>(.*?)<\/(?:adtcore:)?(?:corrNr|CORRNR)>/); if (m) extractedData.transportRequest = m[1]; }
539
+ }
540
+ const { hasErrors, errorMessages } = parseSAPResponse(response.data);
541
+ if (hasErrors) { console.log(` ⚠️ Found ${errorMessages.length} error(s)`); errorMessages.forEach((m, i) => console.log(` ❌ Error ${i + 1}: ${m}`)); }
542
+ return { success: !hasErrors && response.status >= 200 && response.status < 300, status: response.status, statusText: response.statusText, headers: response.headers, data: { raw: response.data, ...extractedData }, ...(errorMessages.length > 0 && { error: `SAP validation failed: ${errorMessages.join('; ')}`, errorDetails: errorMessages }) };
543
+ } catch (error) {
544
+ const status = error.response?.status;
545
+ console.error(` ❌ HTTP ${status || 'network error'}: ${error.message}`);
546
+ if (error.response?.headers) {
547
+ console.error(` RESPONSE HEADERS:`);
548
+ for (const [k, v] of Object.entries(error.response.headers)) console.error(` ❌ ${k}: ${v}`);
549
+ }
550
+ const resBody = typeof error.response?.data === 'string'
551
+ ? error.response.data
552
+ : JSON.stringify(error.response?.data);
553
+ if (resBody) console.error(` ❌ RESPONSE BODY:\n${resBody}`);
554
+ return { success: false, status, error: error.message, data: error.response?.data };
555
+ }
556
+ }
557
+
558
+ async fetchCsrfToken() {
559
+ console.log(' 🔑 Fetching CSRF token...');
560
+ try { await this.client.get('/sap/bc/adt/compatibility/graph', { headers: { 'X-CSRF-Token': 'Fetch' } }); }
561
+ catch (error) { if (error.response?.headers['x-csrf-token']) this.csrfToken = error.response.headers['x-csrf-token']; }
562
+ }
563
+
564
+ clearSession() { this.csrfToken = null; this.cookies.clear(); console.log(' 🧹 Session cleared'); }
565
+
566
+ async ping() {
567
+ try { const r = await this.client.get('/sap/bc/adt/compatibility/graph', { timeout: 5000 }); return { success: true, status: r.status }; }
568
+ catch (e) { return { success: false, error: e.message }; }
569
+ }
570
+ }
571
+
572
+ // ════════════════════════════════════════════════════════════════════════════
573
+ // WebSocket Client — IDENTICAL to websocket-client.js
574
+ // ════════════════════════════════════════════════════════════════════════════
575
+
576
+ class WebSocketClient {
577
+ constructor() {
578
+ this.ws = null;
579
+ this.sapClient = null;
580
+ this.reconnectTimer = null;
581
+ this.keepAliveTimer = null;
582
+ this.isReconnecting = false;
583
+ this.lockManager = new LockManager();
584
+ this.currentSystemId = null;
585
+ }
586
+
587
+ async initSapClient() {
588
+ const sid = this.currentSystemId;
589
+ if (!sid) { console.error('❌ No system selected'); return false; }
590
+ try {
591
+ const sessionEntry = await getSessionForSystem(sid);
592
+ this.sapClient = new SAPClient(sessionEntry);
593
+ console.log(`✅ SAPClient initialized for ${sid}`);
594
+ return true;
595
+ } catch (err) {
596
+ console.error(`❌ Failed to init SAPClient: ${err.message}`);
597
+ return false;
598
+ }
599
+ }
600
+
601
+ connect() {
602
+ if (this.ws) {
603
+ if (this.ws.readyState === WebSocket.CONNECTING) { console.log('⚠️ Already connecting'); return; }
604
+ if (this.ws.readyState === WebSocket.OPEN) { console.log('⚠️ Already connected'); return; }
605
+ }
606
+ if (this.isReconnecting) { console.log('⚠️ Reconnection already scheduled'); return; }
607
+ console.log(`\n🔌 Connecting to MCP Cloud...`);
608
+ console.log(` URL: ${WS_URL}`);
609
+ this.ws = new WebSocket(WS_URL, { headers: { 'Authorization': `Bearer ${MCP_API_KEY}` } });
610
+ this.ws.on('open', () => this.onOpen());
611
+ this.ws.on('message', (data) => this.onMessage(data));
612
+ this.ws.on('error', (error) => this.onError(error));
613
+ this.ws.on('close', () => this.onClose());
614
+ }
615
+
616
+ onOpen() {
617
+ console.log(`✅ Connected to MCP Cloud!\n`);
618
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
619
+ this.isReconnecting = false;
620
+ this.startKeepAlive();
621
+ }
622
+
623
+ startKeepAlive() {
624
+ if (this.keepAliveTimer) clearInterval(this.keepAliveTimer);
625
+ this.keepAliveTimer = setInterval(async () => {
626
+ if (!this.sapClient) return;
627
+ const r = await this.sapClient.ping();
628
+ }, parseInt(env.SAP_KEEPALIVE_INTERVAL || '300000', 10));
629
+ }
630
+
631
+ stopKeepAlive() { if (this.keepAliveTimer) { clearInterval(this.keepAliveTimer); this.keepAliveTimer = null; } }
632
+
633
+ async onMessage(data) {
634
+ let message;
635
+ try { message = JSON.parse(data.toString()); } catch { return; }
636
+ try {
637
+ if (message.type === 'welcome') { console.log(`👋 Welcome:`, message.message); return; }
638
+
639
+ if (message.type === 'command') {
640
+ if (message.command === 'clear_locks') { this.lockManager.clearAll(); return; }
641
+ if (message.command === 'lock_stats') { const s = this.lockManager.getStats(); console.log(`\n📊 Lock Stats: ${s.activeLocks} active`); return; }
642
+ }
643
+
644
+ const { requestId, action, callspecs, system } = message;
645
+ if (!requestId || !action) { console.error('❌ Invalid message format'); return; }
646
+ console.log(`\n📥 Received: ${action} (request: ${requestId})`);
647
+
648
+ // ── switch_system ──────────────────────────────────────────────────
649
+ if (action === 'switch_system') {
650
+ const sid = system || (callspecs?.[0]?.system) || '';
651
+ if (!sid) { this.sendResponse(requestId, false, { error: 'system required' }); return; }
652
+ const sys = config.getSystem(sid);
653
+ if (!sys) { this.sendResponse(requestId, false, { error: `Unknown system: ${sid}` }); return; }
654
+ this.currentSystemId = sid;
655
+ this.lockManager.clearAll();
656
+ const ok = await this.initSapClient();
657
+ if (!ok) { this.sendResponse(requestId, false, { error: `Failed to connect to ${sid}. Check RFC/SNC config and logs.` }); return; }
658
+ this.sendResponse(requestId, true, [{ success: true, data: { system: sid, authMode: sys.authMode } }]);
659
+ return;
660
+ }
661
+
662
+ // ── invoke_rfc ─────────────────────────────────────────────────────
663
+ if (action === 'invoke_rfc') {
664
+ if (!this.sapClient?.rfcClient) { this.sendResponse(requestId, false, { error: 'No RFC session' }); return; }
665
+ const spec = Array.isArray(callspecs) ? callspecs[0] : callspecs || {};
666
+ const { fm, importing = {}, tables = [], table_fields = ['LINE'] } = spec;
667
+ try {
668
+ const tr = await this.sapClient.rfcClient.callFM(fm, importing, tables, table_fields);
669
+ const ft = tables[0] || '';
670
+ const lines = (tr[ft] || []).map(l => (l || '').trimEnd());
671
+ this.sendResponse(requestId, true, [{ success: true, data: { raw: lines.join('\n') } }]);
672
+ } catch (err) { this.sendResponse(requestId, false, { error: err.message }); }
673
+ return;
674
+ }
675
+
676
+ // ── http_call ──────────────────────────────────────────────────────
677
+ if (action === 'http_call') {
678
+ const specs = Array.isArray(callspecs) ? callspecs : [callspecs];
679
+ const results = [];
680
+ for (const cs of specs) {
681
+ const r = await handleHttpCall(cs, this.sapClient?.client?.defaults?.httpsAgent);
682
+ results.push(r);
683
+ }
684
+ this.sendResponse(requestId, results.every(r => r.success), results);
685
+ return;
686
+ }
687
+
688
+ // ── Standard callspec execution ────────────────────────────────────
689
+ if (!this.sapClient) {
690
+ if (!await this.initSapClient()) { this.sendResponse(requestId, false, { error: 'No system selected. Use switch_system first.' }); return; }
691
+ }
692
+ const exec = await this.executeCallspecs(action, callspecs);
693
+ this.sendResponse(requestId, exec.allSuccessful, exec.results);
694
+ console.log(`✅ Done: ${action}\n`);
695
+ } catch (error) {
696
+ console.error(`❌ Error: ${error.message}`);
697
+ if (message?.requestId) this.sendResponse(message.requestId, false, { error: error.message });
698
+ }
699
+ }
700
+
701
+ async executeCallspecs(action, callspecs) {
702
+ console.log(` Executing ${action} on SAP...`);
703
+ console.log(` 📦 RAW CALLSPECS (first 500): ${JSON.stringify(callspecs).substring(0, 500)}`);
704
+ if (Array.isArray(callspecs)) {
705
+ const results = [];
706
+ const extractedValues = {};
707
+ let allSuccessful = true;
708
+ let objectName = null, objectType = null;
709
+ if (callspecs.length > 0 && callspecs[0].url) {
710
+ const m = callspecs[0].url.match(/\/adt\/(?:ddic\/(?:tables|structures)|oo\/(?:classes|interfaces)|programs\/(?:programs|includes))\/([^\/\?]+)/i);
711
+ if (m) {
712
+ objectName = m[1];
713
+ if (callspecs[0].url.includes('/ddic/tables/')) objectType = 'TABL';
714
+ else if (callspecs[0].url.includes('/ddic/structures/')) objectType = 'STRU';
715
+ else if (callspecs[0].url.includes('/oo/classes/')) objectType = 'CLAS';
716
+ else if (callspecs[0].url.includes('/oo/interfaces/')) objectType = 'INTF';
717
+ else if (callspecs[0].url.includes('/programs/programs/')) objectType = 'PROG';
718
+ else if (callspecs[0].url.includes('/programs/includes/')) objectType = 'INCL';
719
+ console.log(` 🏷️ Detected: ${objectName} (${objectType})`);
720
+ }
721
+ }
722
+ for (let i = 0; i < callspecs.length; i++) {
723
+ const cs = callspecs[i];
724
+ console.log(`\n 📋 Step: ${cs.name || cs.step}`);
725
+ const isLockOp = cs.name === 'LOCK' || cs.url?.includes('_action=LOCK');
726
+ if (isLockOp && objectName && objectType) {
727
+ const cached = this.lockManager.getLock(objectName, objectType);
728
+ if (cached) {
729
+ console.log(` ⚡ SKIPPING LOCK - cached!`);
730
+ extractedValues.lockHandle = cached.lockHandle;
731
+ extractedValues.transportRequest = cached.transportRequest;
732
+ results.push({ success: true, status: 200, statusText: 'OK (cached)', data: { lockHandle: cached.lockHandle, transportRequest: cached.transportRequest, cached: true } });
733
+ continue;
734
+ }
735
+ }
736
+ let pcs = { ...cs };
737
+ if (pcs.url) {
738
+ if (extractedValues.lockHandle) pcs.url = pcs.url.replace(/<LOCK_HANDLE>/g, extractedValues.lockHandle);
739
+ if (extractedValues.transportRequest) pcs.url = pcs.url.replace(/<TRANSPORT_REQUEST>/g, extractedValues.transportRequest);
740
+ }
741
+ // For UNLOCK: inject lockHandle from cache if not already present in URL
742
+ if (pcs.url?.includes('_action=UNLOCK') && !pcs.url.includes('lockHandle=') && !pcs.url.includes('<LOCK_HANDLE>')) {
743
+ const cachedLock = objectName && objectType ? this.lockManager.getLock(objectName, objectType) : null;
744
+ if (cachedLock?.lockHandle) {
745
+ pcs.url = pcs.url + (pcs.url.includes('?') ? '&' : '?') + 'lockHandle=' + cachedLock.lockHandle;
746
+ console.log(` 🔓 Injected lockHandle into UNLOCK URL from cache: ${cachedLock.lockHandle.substring(0,10)}...`);
747
+ }
748
+ }
749
+ const result = await this.sapClient.executeCallspec(pcs);
750
+ results.push(result);
751
+ if (result.success && result.data) {
752
+ if (result.data.lockHandle) {
753
+ extractedValues.lockHandle = result.data.lockHandle;
754
+ if (isLockOp && objectName && objectType) this.lockManager.saveLock(objectName, objectType, result.data.lockHandle, result.data.transportRequest || '');
755
+ }
756
+ if (result.data.transportRequest) extractedValues.transportRequest = result.data.transportRequest;
757
+ }
758
+ if (!result.success) { allSuccessful = false; break; }
759
+ }
760
+ return { results, allSuccessful };
761
+ } else {
762
+ const r = await this.sapClient.executeCallspec(callspecs);
763
+ return { results: [r], allSuccessful: r.success };
764
+ }
765
+ }
766
+
767
+ sendResponse(requestId, success, data) {
768
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.error('❌ WS not connected'); return; }
769
+ this.ws.send(JSON.stringify({ type: 'response', requestId, success, data, timestamp: new Date().toISOString() }));
770
+ }
771
+
772
+ onError(error) { console.error(`❌ WS error: ${error.message}`); }
773
+
774
+ onClose() {
775
+ console.log(`\n⚠️ Disconnected`);
776
+ this.ws = null; this.stopKeepAlive();
777
+ if (this.sapClient) this.sapClient.clearSession();
778
+ this.lockManager.clearAll();
779
+ if (!this.isReconnecting && !this.reconnectTimer) {
780
+ console.log(` Reconnecting in 5s...`);
781
+ this.isReconnecting = true;
782
+ this.reconnectTimer = setTimeout(() => { this.isReconnecting = false; this.connect(); }, 5000);
783
+ }
784
+ }
785
+
786
+ disconnect() {
787
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
788
+ this.isReconnecting = false; this.stopKeepAlive();
789
+ if (this.ws) { this.ws.close(); this.ws = null; }
790
+ }
791
+ }
792
+
793
+ // ════════════════════════════════════════════════════════════════════════════
794
+ // Main
795
+ // ════════════════════════════════════════════════════════════════════════════
796
+
797
+ let MCP_API_KEY = '';
798
+ let WS_URL = env.WS_URL || 'ws://localhost:8080';
799
+
800
+ function loadGlobalConfig() {
801
+ if (config.isConfigInitialized()) { MCP_API_KEY = config.getMcpApiKey(); WS_URL = config.getWsUrl(); console.log('🔑 Config from encrypted store.'); }
802
+ else { MCP_API_KEY = env.API_KEY || ''; WS_URL = env.WS_URL || 'ws://localhost:8080'; console.log('⚠️ Using .env fallback.'); }
803
+ if (!MCP_API_KEY) { console.error('❌ No MCP API key'); process.exit(1); }
804
+ }
805
+
806
+ const args = process.argv.slice(2);
807
+ if (args[0] === 'setup' || args[0] === 'config' || args[0] === 'add-system' || args[0] === 'set-mcp-key' ||
808
+ args[0] === 'list-systems' || args[0] === 'remove-system' || args[0] === 'enable-system' ||
809
+ args[0] === 'disable-system' || args[0] === 'show-config' || args[0] === 'arcanum-fallback' ||
810
+ args[0] === 'set-ws-url' || args[0] === 'help') {
811
+ await import('./config-cli.mjs'); process.exit(0);
812
+ }
813
+
814
+ if (!config.isConfigInitialized()) {
815
+ config.addOrUpdateSystem('S4D', { authMode: 'snc', connection: { host: 'vhlbvs4dci.sap.piracanjuba.com.br', sysnr: '00', client: '100', username: '', sncPartnerName: 'p:CN=svc.ssoS4D', sncQop: '3', language: 'PT' }, enabled: true });
816
+ config.setMcpApiKey('arc_de2fb7965d536a5911ffd7d9ba39d70c');
817
+ }
818
+
819
+ console.log('╔═══════════════════════════════════════════════════════════╗');
820
+ console.log('║ SAP Local Client — Multi-System SSO Bridge ║');
821
+ console.log('╚═══════════════════════════════════════════════════════════╝');
822
+ loadGlobalConfig();
823
+ console.log(` WS URL: ${WS_URL}`);
824
+ console.log(` API Key: ${MCP_API_KEY.substring(0, 10)}...`);
825
+ console.log(` Store: ${config.getConfigFilePath()}`);
826
+ const enabled = (config.listSystems() || []).filter(s => s.enabled);
827
+ console.log(` Systems: ${enabled.map(s => `${s.systemId} (${s.authMode})`).join(', ') || 'none'}`);
828
+ console.log(' Use: switch_system <systemId> to select a system');
829
+
830
+ const client = new WebSocketClient();
831
+ await fetchSapSystems();
832
+ client.connect();
833
+
834
+ process.on('SIGINT', () => { client.disconnect(); process.exit(0); });
835
+ process.on('SIGTERM', () => { client.disconnect(); process.exit(0); });