UNPKG

@mcp-abap-adt/connection

Version:

ABAP connection layer for MCP ABAP ADT server

613 lines (612 loc) 28.3 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.AbstractAbapConnection = void 0; const node_crypto_1 = require("node:crypto"); const node_https_1 = require("node:https"); const interfaces_1 = require("@mcp-abap-adt/interfaces"); const axios_1 = __importStar(require("axios")); const timeouts_js_1 = require("../utils/timeouts.js"); const csrfConfig_js_1 = require("./csrfConfig.js"); class AbstractAbapConnection { config; logger; axiosInstance = null; csrfToken = null; cookies = null; cookieStore = new Map(); baseUrl; sessionId = null; sessionMode = 'stateless'; skipSessionType; constructor(config, logger, sessionId, options) { this.config = config; this.logger = logger; this.skipSessionType = options?.skipSessionType ?? false; // Generate sessionId (used for sap-adt-connection-id header) this.sessionId = sessionId || (0, node_crypto_1.randomUUID)(); // Initialize baseUrl from config (required, will throw if invalid) try { const urlObj = new URL(config.url); this.baseUrl = urlObj.origin; } catch (error) { throw new Error(`Invalid URL in configuration: ${error instanceof Error ? error.message : error}`); } this.logger?.debug(`AbstractAbapConnection - Session ID: ${this.sessionId.substring(0, 8)}...`); } /** * Set session type (stateful or stateless) * Controls whether x-sap-adt-sessiontype: stateful header is added to requests * - stateful: SAP maintains session state between requests (locks, transactions) * - stateless: Each request is independent * * When skipSessionType is enabled (via constructor options), this is a no-op: * the x-sap-adt-sessiontype header will never be sent. This is needed for * older BASIS versions (e.g. 7.40) where the stateful header causes locks * to be stored in ABAP session memory instead of the global enqueue table, * resulting in HTTP 423 on subsequent PUT requests. */ setSessionType(type) { if (this.skipSessionType) { return; } this.sessionMode = type; this.logger?.debug(`Session type set to: ${type}`, { sessionId: this.sessionId?.substring(0, 8), }); } /** * Get current session mode */ getSessionMode() { return this.sessionMode; } /** * Set session ID * @deprecated Session ID is auto-generated, use setSessionType() to control session mode */ setSessionId(sessionId) { this.sessionId = sessionId; this.logger?.debug(`Session ID set to: ${sessionId.substring(0, 8)}...`); } /** * Get current session ID */ getSessionId() { return this.sessionId; } getConfig() { return this.config; } reset() { if (this.axiosInstance) { this.axiosInstance.interceptors.request.clear(); this.axiosInstance.interceptors.response.clear(); this.axiosInstance = null; } this.csrfToken = null; this.cookies = null; this.cookieStore.clear(); // Note: baseUrl is not reset as it's derived from immutable config } async getBaseUrl() { return this.baseUrl; } async getAuthHeaders() { const headers = {}; if (this.config.client) { headers['X-SAP-Client'] = this.config.client; } const authorization = this.buildAuthorizationHeader(); if (authorization) { headers.Authorization = authorization; } return headers; } async makeAdtRequest(options) { const { url: endpoint, method, timeout, data, params, headers: customHeaders, } = options; const normalizedMethod = method.toUpperCase(); // Build full URL: baseUrl + endpoint const requestUrl = `${this.baseUrl}${endpoint}`; // Try to ensure CSRF token is available for POST/PUT/DELETE, but don't fail if it can't be fetched // The retry logic will handle CSRF token errors automatically if (normalizedMethod === 'POST' || normalizedMethod === 'PUT' || normalizedMethod === 'DELETE') { if (!this.csrfToken) { try { await this.ensureFreshCsrfToken(requestUrl); } catch (error) { // If CSRF token can't be fetched upfront, continue anyway // The retry logic will handle CSRF token errors automatically this.logger?.debug(`[DEBUG] BaseAbapConnection - Could not fetch CSRF token upfront, will retry on error: ${error instanceof Error ? error.message : String(error)}`); } } } // Start with default Accept header const requestHeaders = {}; if (!customHeaders || !customHeaders.Accept) { requestHeaders.Accept = 'application/xml, application/json, text/plain, */*'; } // Add custom headers (but they won't override auth/cookies) if (customHeaders) { Object.assign(requestHeaders, customHeaders); } // ALWAYS add sap-adt-connection-id header (connectionId is sent for ALL session types) if (this.sessionId) { requestHeaders['sap-adt-connection-id'] = this.sessionId; } // Add stateful session headers if stateful mode is enabled if (this.sessionMode === 'stateful') { requestHeaders['x-sap-adt-sessiontype'] = 'stateful'; requestHeaders['sap-adt-request-id'] = (0, node_crypto_1.randomUUID)().replace(/-/g, ''); requestHeaders['X-sap-adt-profiling'] = 'server-time'; } // Add auth headers (these MUST NOT be overridden) Object.assign(requestHeaders, await this.getAuthHeaders()); if ((normalizedMethod === 'POST' || normalizedMethod === 'PUT' || normalizedMethod === 'DELETE') && this.csrfToken) { requestHeaders['x-csrf-token'] = this.csrfToken; } // Add cookies LAST (MUST NOT be overridden by custom headers) if (this.cookies) { requestHeaders.Cookie = this.cookies; this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to request (first 100 chars): ${this.cookies.substring(0, 100)}...`); } else { this.logger?.debug(`[DEBUG] BaseAbapConnection - NO COOKIES available for this request to ${requestUrl}`); } if ((normalizedMethod === 'POST' || normalizedMethod === 'PUT') && data) { if (typeof data === 'string' && !requestHeaders['Content-Type']) { if (requestUrl.includes('/usageReferences') && data.includes('usageReferenceRequest')) { requestHeaders['Content-Type'] = 'application/vnd.sap.adt.repository.usagereferences.request.v1+xml'; requestHeaders.Accept = 'application/vnd.sap.adt.repository.usagereferences.result.v1+xml'; } else { requestHeaders['Content-Type'] = 'text/plain; charset=utf-8'; } } } const requestConfig = { method: normalizedMethod, url: requestUrl, headers: requestHeaders, timeout, params, }; if (data !== undefined) { requestConfig.data = data; } this.logger?.debug(`Executing ${normalizedMethod} request to: ${requestUrl}`, { type: 'REQUEST_INFO', url: requestUrl, method: normalizedMethod, }); try { const response = await this.getAxiosInstance()(requestConfig); this.updateCookiesFromResponse(response.headers); this.logger?.debug(`Request succeeded with status ${response.status}`, { type: 'REQUEST_SUCCESS', status: response.status, url: requestUrl, method: normalizedMethod, }); return response; } catch (error) { const errorDetails = { type: 'REQUEST_ERROR', message: error instanceof Error ? error.message : String(error), url: requestUrl, method: normalizedMethod, status: error instanceof axios_1.AxiosError ? error.response?.status : undefined, data: undefined, }; if (error instanceof axios_1.AxiosError && error.response) { errorDetails.data = typeof error.response.data === 'string' ? error.response.data.slice(0, 200) : JSON.stringify(error.response.data).slice(0, 200); this.updateCookiesFromResponse(error.response.headers); } // Check if this is a network error (connection refused, timeout, DNS, etc.) // Don't retry for network errors - these indicate infrastructure/VPN issues const networkError = (0, interfaces_1.isNetworkError)(error); if (networkError) { this.logger?.error(`Network error - cannot connect to SAP system: ${errorDetails.message}`, errorDetails); throw error; } // Log 404 as debug (common for existence checks), other errors as error if (errorDetails.status === 404) { this.logger?.debug(errorDetails.message, errorDetails); } else { this.logger?.error(errorDetails.message, errorDetails); } // Retry logic for CSRF token errors (403 with CSRF message) if (this.shouldRetryCsrf(error)) { this.logger?.debug('CSRF token validation failed, fetching new token and retrying request', { url: requestUrl, method: normalizedMethod, }); this.csrfToken = await this.fetchCsrfToken(requestUrl, 5, 2000); if (this.csrfToken) { requestHeaders['x-csrf-token'] = this.csrfToken; } if (this.cookies) { requestHeaders.Cookie = this.cookies; } const retryResponse = await this.getAxiosInstance()(requestConfig); this.updateCookiesFromResponse(retryResponse.headers); return retryResponse; } // Retry logic for 401 errors on GET requests (authentication issue - need cookies) // Only for basic auth - JWT auth will be handled by refresh logic below if (error instanceof axios_1.AxiosError && error.response?.status === 401 && normalizedMethod === 'GET' && this.config.authType === 'basic' // Only for basic auth ) { // If we already have cookies from error response, retry immediately if (this.cookies) { this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, retrying with cookies from error response`); requestHeaders.Cookie = this.cookies; const retryResponse = await this.getAxiosInstance()(requestConfig); this.updateCookiesFromResponse(retryResponse.headers); return retryResponse; } // If no cookies, try to get them via CSRF token fetch this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, attempting to get cookies via CSRF token fetch`); try { // Try to get CSRF token (this will also get cookies) this.csrfToken = await this.fetchCsrfToken(requestUrl, 3, 1000); if (this.cookies) { requestHeaders.Cookie = this.cookies; this.logger?.debug(`[DEBUG] BaseAbapConnection - Retrying GET request with cookies from CSRF fetch`); const retryResponse = await this.getAxiosInstance()(requestConfig); this.updateCookiesFromResponse(retryResponse.headers); return retryResponse; } } catch (csrfError) { this.logger?.debug(`[DEBUG] BaseAbapConnection - Failed to get CSRF token for 401 retry: ${csrfError instanceof Error ? csrfError.message : String(csrfError)}`); // Fall through to throw original error } } throw error; } } /** * Fetch CSRF token from SAP system * Protected method for use by concrete implementations in their connect() method */ async fetchCsrfToken(url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY) { // Try primary endpoint first, then fallback for older systems const baseUrl = url.includes('/sap/bc/adt/') ? url.split('/sap/bc/adt')[0] : url.endsWith('/') ? url.slice(0, -1) : url; let endpoints; // If the URL already contains a specific endpoint, use only that if (url.includes(csrfConfig_js_1.CSRF_CONFIG.ENDPOINT)) { endpoints = [url]; } else if (url.includes(csrfConfig_js_1.CSRF_CONFIG.FALLBACK_ENDPOINT)) { endpoints = [url]; } else { endpoints = [ `${baseUrl}${csrfConfig_js_1.CSRF_CONFIG.ENDPOINT}`, `${baseUrl}${csrfConfig_js_1.CSRF_CONFIG.FALLBACK_ENDPOINT}`, ]; } let lastError; for (const csrfUrl of endpoints) { try { return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); this.logger?.debug(`CSRF token not available from ${csrfUrl}, trying next endpoint...`); } } // All endpoints exhausted throw lastError ?? new Error('CSRF token fetch failed unexpectedly'); } /** * Fetch CSRF token from a specific endpoint with retries */ async fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay) { this.logger?.debug(`Fetching CSRF token from: ${csrfUrl}`); for (let attempt = 0; attempt <= retryCount; attempt++) { try { if (attempt > 0) { this.logger?.debug(`Retry attempt ${attempt}/${retryCount} for CSRF token`); } const authHeaders = await this.getAuthHeaders(); const headers = { ...authHeaders, ...csrfConfig_js_1.CSRF_CONFIG.REQUIRED_HEADERS, }; // Always add cookies if available - they are needed for session continuity // Even on first attempt, if we have cookies from previous session or error response, use them if (this.cookies) { headers.Cookie = this.cookies; this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to CSRF token request (attempt ${attempt + 1}, first 100 chars): ${this.cookies.substring(0, 100)}...`); } else { this.logger?.debug(`[DEBUG] BaseAbapConnection - No cookies available for CSRF token request (will get fresh cookies from response)`); } // Log request details for debugging (only if debug logging is enabled) this.logger?.debug(`[DEBUG] CSRF Token Request: url=${csrfUrl}, method=GET, hasAuth=${!!authHeaders.Authorization}, hasClient=${!!authHeaders['X-SAP-Client']}, hasCookies=${!!headers.Cookie}, attempt=${attempt + 1}`); const response = await this.getAxiosInstance()({ method: 'GET', url: csrfUrl, headers, timeout: (0, timeouts_js_1.getTimeout)('csrf'), }); this.updateCookiesFromResponse(response.headers); const token = response.headers['x-csrf-token']; if (!token) { this.logger?.error('No CSRF token in response headers', { headers: response.headers, status: response.status, }); if (attempt < retryCount) { await new Promise((resolve) => setTimeout(resolve, retryDelay)); continue; } throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS); } if (response.headers['set-cookie']) { this.updateCookiesFromResponse(response.headers); if (this.cookies) { this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies received from CSRF response (first 100 chars): ${this.cookies.substring(0, 100)}...`); this.logger?.debug('Cookies extracted from response', { cookieLength: this.cookies.length, }); } } this.logger?.debug('CSRF token successfully obtained'); return token; } catch (error) { if (error instanceof axios_1.AxiosError) { // Always try to extract cookies from error response, even on 401 // This ensures cookies are available for subsequent requests if (error.response?.headers) { this.updateCookiesFromResponse(error.response.headers); if (this.cookies) { this.logger?.debug('Cookies extracted from error response', { status: error.response.status, cookieLength: this.cookies.length, }); } } this.logger?.error(`CSRF token error: ${error.message}`, { url: csrfUrl, status: error.response?.status, attempt: attempt + 1, maxAttempts: retryCount + 1, }); if (error.response?.status === 405 && error.response?.headers['x-csrf-token']) { this.logger?.debug('CSRF: SAP returned 405 (Method Not Allowed) — not critical, token found in header'); const token = error.response.headers['x-csrf-token']; if (token) { this.updateCookiesFromResponse(error.response.headers); return token; } } if (error.response?.headers['x-csrf-token']) { this.logger?.debug(`Got CSRF token despite error (status: ${error.response?.status})`); const token = error.response.headers['x-csrf-token']; this.updateCookiesFromResponse(error.response.headers); return token; } if (error.response) { this.logger?.error('CSRF error details', { status: error.response.status, statusText: error.response.statusText, headers: Object.keys(error.response.headers), data: typeof error.response.data === 'string' ? error.response.data.slice(0, 200) : JSON.stringify(error.response.data).slice(0, 200), }); } else if (error.request) { this.logger?.error('CSRF request error - no response received', { request: error.request.path, }); } } else { this.logger?.error('CSRF non-axios error', { error: error instanceof Error ? error.message : String(error), }); } if (attempt < retryCount) { await new Promise((resolve) => setTimeout(resolve, retryDelay)); continue; } // Preserve original error information, especially AxiosError with response if (error instanceof axios_1.AxiosError && error.response) { // Re-throw the original AxiosError to preserve response information throw error; } throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.FETCH_FAILED(retryCount + 1, error instanceof Error ? error.message : String(error))); } } throw new Error('CSRF token fetch failed unexpectedly'); } /** * Get CSRF token (protected for use by subclasses) */ getCsrfToken() { return this.csrfToken; } /** * Set CSRF token (protected for use by subclasses) */ setCsrfToken(token) { this.csrfToken = token; } /** * Get cookies (protected for use by subclasses) */ getCookies() { return this.cookies; } setInitialCookies(cookies) { this.cookies = cookies; } updateCookiesFromResponse(headers) { if (!headers) { return; } const setCookie = headers['set-cookie']; if (!setCookie) { return; } const cookiesArray = Array.isArray(setCookie) ? setCookie : [setCookie]; for (const entry of cookiesArray) { if (typeof entry !== 'string') { continue; } const [nameValue] = entry.split(';'); if (!nameValue) { continue; } const [name, ...rest] = nameValue.split('='); if (!name) { continue; } const trimmedName = name.trim(); const trimmedValue = rest.join('=').trim(); if (!trimmedName) { continue; } this.cookieStore.set(trimmedName, trimmedValue); } // Enforce configured SAP client in sap-usercontext cookie. // SAP may return sap-usercontext=sap-client=<default_client> based on system // default rather than the X-SAP-Client header value, causing requests to be // routed to the wrong client (e.g. a read-only client → 403 on write operations). if (this.config.client) { this.cookieStore.set('sap-usercontext', `sap-client=${this.config.client}`); } if (this.cookieStore.size === 0) { return; } const combined = Array.from(this.cookieStore.entries()) .map(([name, value]) => (value ? `${name}=${value}` : name)) .join('; '); if (!combined) { return; } this.cookies = combined; this.logger?.debug(`[DEBUG] BaseAbapConnection - Updated cookies from response (first 100 chars): ${this.cookies.substring(0, 100)}...`); } getAxiosInstance() { if (!this.axiosInstance) { const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED === '1' || (process.env.TLS_REJECT_UNAUTHORIZED === '1' && process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'); this.logger?.debug(`TLS configuration: rejectUnauthorized=${rejectUnauthorized}`); this.axiosInstance = axios_1.default.create({ httpsAgent: new node_https_1.Agent({ rejectUnauthorized, }), }); } return this.axiosInstance; } async ensureFreshCsrfToken(requestUrl) { // If we already have a CSRF token, reuse it to keep the same SAP session // SAP ties the lock handle to the HTTP session (SAP_SESSIONID cookie) if (this.csrfToken) { this.logger?.debug(`[DEBUG] BaseAbapConnection - Reusing existing CSRF token to maintain session`); return; } try { this.logger?.debug(`[DEBUG] BaseAbapConnection - Fetching NEW CSRF token (will create new SAP session)`); this.csrfToken = await this.fetchCsrfToken(requestUrl); } catch (error) { // fetchCsrfToken handles auth errors // Just re-throw the error with minimal logging to avoid duplicate error messages const errorMsg = error instanceof Error ? error.message : csrfConfig_js_1.CSRF_ERROR_MESSAGES.REQUIRED_FOR_MUTATION; // Only log at DEBUG level to avoid duplicate error messages // (fetchCsrfToken already logged the error at ERROR level if auth failed) this.logger?.debug(`[DEBUG] BaseAbapConnection - ensureFreshCsrfToken failed: ${errorMsg}`); throw error; } } shouldRetryCsrf(error) { if (!(error instanceof axios_1.AxiosError)) { return false; } const responseData = error.response?.data; const responseText = typeof responseData === 'string' ? responseData : JSON.stringify(responseData || ''); // Don't retry for JWT auth - refresh logic will handle it if (this.config.authType === 'jwt') { return false; } // Retry on 403 with CSRF message, or if response mentions CSRF token // Also retry on 401 for POST/PUT/DELETE if we don't have CSRF token yet (might need to get cookies first) const method = error.config?.method?.toUpperCase(); const isPostPutDelete = method && ['POST', 'PUT', 'DELETE'].includes(method); const needsCsrfToken = !!isPostPutDelete && !this.csrfToken; return ((!!error.response && error.response.status === 403 && responseText.includes('CSRF')) || responseText.includes('CSRF token') || (needsCsrfToken && error.response?.status === 401)); } } exports.AbstractAbapConnection = AbstractAbapConnection;