@mcp-abap-adt/connection
Version:
ABAP connection layer for MCP ABAP ADT server
332 lines (331 loc) • 14.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RfcAbapConnection = void 0;
const node_crypto_1 = require("node:crypto");
function rfcErrorMessage(e) {
if (e instanceof Error)
return e.message;
if (e && typeof e === 'object')
return JSON.stringify(e);
return String(e);
}
/**
* Derive RFC connection parameters from ISapConfig.
* Parses hostname from config.url, system number from port (80XX → XX).
*
* Examples:
* http://saphost:8000 → ashost=saphost, sysnr=00
* http://saphost:8001 → ashost=saphost, sysnr=01
* http://saphost:8042 → ashost=saphost, sysnr=42
*/
function buildRfcParams(config) {
const parsed = new URL(config.url);
const port = Number.parseInt(parsed.port || '8000', 10);
// SAP HTTP port convention: 80XX where XX = system number.
// SAP_SYSNR env var overrides derivation for non-standard ports (e.g. 50400).
const derivedSysnr = String(port - 8000).padStart(2, '0');
const sysnr = process.env.SAP_SYSNR?.trim() || derivedSysnr;
return {
ashost: parsed.hostname,
sysnr,
client: config.client || '000',
user: config.username || '',
passwd: config.password || '',
lang: 'EN',
};
}
/**
* Map ADT exception type names to HTTP status codes.
* These are standard ADT exception types returned in `<exc:exception>` XML.
*/
const EXCEPTION_STATUS_MAP = {
ExceptionResourceNotFound: { code: 404, text: 'Not Found' },
ExceptionResourceNoAuthorization: { code: 403, text: 'Forbidden' },
ExceptionResourceAlreadyExists: { code: 409, text: 'Conflict' },
ExceptionResourceLocked: { code: 423, text: 'Locked' },
ExceptionBadRequest: { code: 400, text: 'Bad Request' },
ExceptionNotSupported: { code: 501, text: 'Not Implemented' },
ExceptionConflict: { code: 409, text: 'Conflict' },
};
/**
* Detect HTTP status from ADT exception XML in response body.
* On some legacy systems, SADT_REST_RFC_ENDPOINT returns errors as XML
* exceptions without setting proper HTTP status codes in STATUS_LINE.
*/
function detectExceptionStatus(body) {
// Extract exception type: <exc:exception ... xmlns:exc="..."><exc:type>ExceptionName</exc:type>
const typeMatch = body.match(/<exc:type[^>]*>([^<]+)<\/exc:type>/);
if (typeMatch) {
const exType = typeMatch[1].trim();
const mapped = EXCEPTION_STATUS_MAP[exType];
if (mapped) {
return { statusCode: mapped.code, statusText: mapped.text };
}
}
// Unknown exception type — return 500 as generic server error
return { statusCode: 500, statusText: 'Internal Server Error' };
}
/**
* RFC-based connection for on-premise SAP systems.
*
* Uses @mcp-abap-adt/sap-rfc-lite to call SADT_REST_RFC_ENDPOINT — the same standard SAP FM
* that Eclipse ADT uses for all on-premise ADT operations via JCo.
*
* RFC connections are inherently stateful: one ABAP session persists for the
* entire connection lifetime. This solves the HTTP 423 "invalid lock handle"
* problem on legacy systems (BASIS < 7.50) where HTTP stateful sessions
* are not supported.
*
* Connection parameters are derived from the standard ISapConfig.url field:
* http://saphost:8000 → ashost=saphost, sysnr=00
*
* Prerequisites:
* - SAP NW RFC SDK installed on the machine
* - @mcp-abap-adt/sap-rfc-lite package installed: npm install @mcp-abap-adt/sap-rfc-lite
*/
class RfcAbapConnection {
config;
logger;
rfcClient = null;
sessionId;
baseUrl;
rfcParams;
sessionType = 'stateless';
sessionCookie = null;
constructor(config, logger = null) {
this.config = config;
this.logger = logger;
RfcAbapConnection.validateConfig(config);
this.sessionId = (0, node_crypto_1.randomUUID)();
this.baseUrl = config.url;
this.rfcParams = buildRfcParams(config);
this.logger?.debug(`RfcAbapConnection created for ${this.rfcParams.ashost}:${this.rfcParams.sysnr}, client ${this.rfcParams.client}`);
}
async connect() {
let Client;
try {
// Dynamic require — @mcp-abap-adt/sap-rfc-lite is NOT a declared dependency.
// Users who need RFC connections must install it manually:
// npm install @mcp-abap-adt/sap-rfc-lite (+ SAP NW RFC SDK on the machine)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const noderfc = require('@mcp-abap-adt/sap-rfc-lite');
Client = noderfc.Client;
}
catch (e) {
throw new Error('@mcp-abap-adt/sap-rfc-lite is not available. To use RFC connections, install SAP NW RFC SDK ' +
'and run: npm install @mcp-abap-adt/sap-rfc-lite. ' +
`Details: ${rfcErrorMessage(e)}`);
}
this.rfcClient = new Client(this.rfcParams);
try {
await this.rfcClient.open();
this.logger?.debug('RFC connection opened (stateful session)');
}
catch (e) {
this.rfcClient = null;
const msg = rfcErrorMessage(e);
this.logger?.error(`RFC connection failed: ${msg}`);
throw new Error(`Failed to open RFC connection: ${msg}`);
}
}
async getBaseUrl() {
return this.baseUrl;
}
getSessionId() {
return this.sessionId;
}
setSessionType(type) {
this.sessionType = type;
if (type === 'stateless') {
this.sessionCookie = null;
this.logger?.debug('RFC session type: stateless (cookie cleared)');
}
else {
this.logger?.debug('RFC session type: stateful (will capture cookies)');
}
}
async makeAdtRequest(options) {
if (!this.rfcClient?.alive) {
throw new Error('RFC connection is not open. Call connect() first.');
}
const method = options.method.toUpperCase();
// Encode query params into URI (axios does this automatically for HTTP,
// but RFC needs it done manually)
let uri = options.url;
if (options.params && typeof options.params === 'object') {
const entries = Object.entries(options.params).filter(([, v]) => v !== undefined && v !== null);
if (entries.length > 0) {
const qs = new URLSearchParams(entries.map(([k, v]) => [k, String(v)]));
uri += (uri.includes('?') ? '&' : '?') + qs.toString();
}
}
// Note: sap-client is NOT added to URI for RFC connections.
// The RFC session is already logged into the correct client
// (via the 'client' param in RFC connection). Adding sap-client
// to the URI can cause "object not found" on some systems.
// Build header fields
const headerFields = [];
if (options.headers) {
for (const [name, value] of Object.entries(options.headers)) {
headerFields.push({ NAME: name, VALUE: value });
}
}
// Inject session type header so SAP creates/maintains an ICM session
if (this.sessionType === 'stateful') {
headerFields.push({ NAME: 'x-sap-adt-sessiontype', VALUE: 'stateful' });
}
// Replay session cookie for stateful operations (cookie captured from LOCK response)
if (this.sessionCookie) {
const existingCookie = headerFields.find((h) => h.NAME.toLowerCase() === 'cookie');
if (!existingCookie) {
headerFields.push({ NAME: 'Cookie', VALUE: this.sessionCookie });
}
}
// Ensure Content-Type for body
const body = options.data !== undefined && options.data !== null
? String(options.data)
: '';
if (body &&
!headerFields.some((h) => h.NAME.toLowerCase() === 'content-type')) {
headerFields.push({
NAME: 'Content-Type',
VALUE: 'text/plain; charset=utf-8',
});
}
this.logger?.debug(`RFC → ${method} ${uri}`);
this.logger?.debug(`RFC → headers: ${JSON.stringify(headerFields)}`);
if (body)
this.logger?.debug(`RFC → body: ${body}`);
try {
const result = await this.rfcClient.call('SADT_REST_RFC_ENDPOINT', {
REQUEST: {
REQUEST_LINE: {
METHOD: method,
URI: uri,
VERSION: 'HTTP/1.1',
},
HEADER_FIELDS: headerFields,
MESSAGE_BODY: body ? Buffer.from(body, 'utf-8') : Buffer.alloc(0),
},
});
const resp = result.RESPONSE || result;
// Log raw RFC response structure for debugging
this.logger?.debug(`RFC raw response keys: ${Object.keys(resp).join(', ')}`);
if (resp.STATUS_LINE) {
this.logger?.debug(`RFC STATUS_LINE: ${JSON.stringify(resp.STATUS_LINE)}`);
}
// Parse status — RFC returns status in STATUS_LINE structure
// Field names: STATUS_CODE (not CODE), REASON_PHRASE (not REASON)
const rawCode = resp.STATUS_LINE?.STATUS_CODE || resp.STATUS_LINE?.CODE || 0;
let statusCode = typeof rawCode === 'string' ? Number.parseInt(rawCode, 10) : rawCode;
let statusText = resp.STATUS_LINE?.REASON_PHRASE || resp.STATUS_LINE?.REASON || '';
// Parse response body
const respBody = resp.MESSAGE_BODY
? Buffer.isBuffer(resp.MESSAGE_BODY)
? resp.MESSAGE_BODY.toString('utf-8')
: String(resp.MESSAGE_BODY)
: '';
// Parse response headers
const respHeaders = {};
const respHeaderFields = resp.HEADER_FIELDS || [];
for (const field of respHeaderFields) {
if (field.NAME && field.VALUE !== undefined) {
respHeaders[field.NAME.toLowerCase()] = field.VALUE;
}
}
// Capture session cookie from LOCK/stateful responses for cookie replay
if (this.sessionType === 'stateful' && respHeaders['set-cookie']) {
const setCookieHeader = respHeaders['set-cookie'];
// Extract cookie name=value pairs (strip attributes like Path, Secure, etc.)
const cookies = Array.isArray(setCookieHeader)
? setCookieHeader
: [setCookieHeader];
const cookieValues = cookies
.map((c) => c.split(';')[0].trim())
.filter(Boolean);
if (cookieValues.length > 0) {
this.sessionCookie = cookieValues.join('; ');
this.logger?.debug(`RFC: captured session cookie: ${this.sessionCookie}`);
}
}
// On some systems (e.g. BASIS < 7.50), SADT_REST_RFC_ENDPOINT does not
// populate STATUS_LINE, returning status 0. Detect errors from the
// response body: ADT exception XML indicates a failed request.
if (!statusCode && respBody.includes('<exc:exception')) {
const detected = detectExceptionStatus(respBody);
statusCode = detected.statusCode;
statusText = detected.statusText;
this.logger?.debug(`RFC: STATUS_LINE empty, detected ${statusCode} from exception XML`);
}
// Default to 200 OK only when no error was detected
if (!statusCode) {
statusCode = 200;
statusText = statusText || 'OK';
}
this.logger?.debug(`RFC ← ${statusCode} ${statusText} (${respBody.length} bytes)`);
this.logger?.debug(`RFC ← headers: ${JSON.stringify(respHeaders)}`);
if (respBody)
this.logger?.debug(`RFC ← body: ${respBody}`);
const response = {
data: respBody,
status: statusCode,
statusText,
headers: respHeaders,
};
// Throw for error status codes (matching HTTP/axios behavior)
if (statusCode >= 400) {
const error = new Error(`Request failed with status ${statusCode}: ${method} ${uri}`);
error.response = response;
throw error;
}
return response;
}
catch (e) {
// Re-throw our own errors (status >= 400)
if (e?.response) {
throw e;
}
// RFC-level error
const msg = rfcErrorMessage(e);
this.logger?.error(`RFC call failed: ${msg}`);
throw new Error(`RFC call to SADT_REST_RFC_ENDPOINT failed: ${msg}`);
}
}
/**
* Reset the connection — for RFC this closes the session.
* Provides interface compatibility with HTTP connections.
*/
reset() {
this.close();
}
/**
* Close the RFC connection and release the ABAP session.
*/
async close() {
if (this.rfcClient) {
try {
await this.rfcClient.close();
this.logger?.debug('RFC connection closed');
}
catch (e) {
this.logger?.debug(`RFC close error: ${rfcErrorMessage(e)}`);
}
this.rfcClient = null;
}
}
static validateConfig(config) {
if (config.connectionType !== 'rfc') {
throw new Error(`RFC connection expects connectionType "rfc", got "${config.connectionType}"`);
}
if (!config.url) {
throw new Error('RFC connection requires url (hostname is parsed from it)');
}
if (!config.username || !config.password) {
throw new Error('RFC connection requires both username and password');
}
if (!config.client) {
throw new Error('RFC connection requires SAP client');
}
}
}
exports.RfcAbapConnection = RfcAbapConnection;