ABAP连接库

为MCP ABAP ADT服务器提供SAP ABAP系统的统一连接层,支持本地(基础认证)和云端(JWT/OAuth2)认证,管理会话、CSRF令牌及HTTP通信。

  • by fr0ster
  • TypeScript
  • 本地部署
  • 2 星标
  • 更新于
在 GitHub 查看 fr0ster/mcp-abap-connection

使用说明

@mcp-abap-adt/connection

ABAP connection layer for MCP ABAP ADT server. Provides a unified interface for connecting to SAP ABAP systems via ADT (ABAP Development Tools) protocol, supporting both on-premise (Basic Auth) and cloud (JWT/OAuth2) authentication methods.

Key Features

  • 🔐 Multiple Authentication Methods:
    • Basic Auth for on-premise SAP systems
    • JWT/OAuth2 for SAP BTP ABAP Environment
    • SAML session cookies for pre-authenticated enterprise flows
  • 🔄 Token Management:
    • Token refresh is handled by @mcp-abap-adt/auth-broker package
    • Connection package focuses on HTTP communication only
  • 💾 Session Management:
    • Session headers management (cookies, CSRF tokens)
    • Session state persistence is handled by @mcp-abap-adt/auth-broker package
  • 🏗️ Clean Architecture:
    • One connector per SYSTEM (AdtOnPremConnector, AdtCloudConnector), handed an auth provider
    • Authentication is a parameter, not a subclass — nothing about the system is inferred from it
    • Proper separation of concerns - no JWT logic in base class
  • 🔌 Realtime Transport Scaffold:
    • Generic GenericWebSocketTransport with pluggable WS factory
    • Reusable for debugger/traces and other event-driven flows
  • 📝 Custom Logging: Pluggable logger interface for integration with any logging system
  • 📦 TypeScript: Full TypeScript support with type definitions included
  • ⏱️ No built-in deadline: a request carries the caller's own timeout and nothing else — inside a critical section, none at all

Architecture

The package uses a clean separation of concerns:

  • AbstractAbapConnection (abstract, internal only):

    • Common HTTP request logic
    • Session lifecycle: connect() / disconnect() / flushGoodbye(), admission, lock windows, teardown draining
    • Capability atoms a consumer narrows to, rather than casting to a connector class: ISessionLifecycleAware, ICriticalSection, IRequestProfiling
    • Session management (cookies, CSRF tokens)
    • CSRF token fetching with retry
    • Auth-agnostic - knows nothing about Basic or JWT
  • AdtOnPremConnector (concrete, exported):

    • An on-prem system: the session arrives with the establishing call, and the platform's ICF logoff is how it is given back
    • Takes an auth provider — basic, SAML, certificate, a bearer token, whatever you hold
  • AdtCloudConnector (concrete, exported):

    • An ABAP Cloud system: a session is a resource, opened at /sap/bc/adt/core/http/sessions and given back through the logoff resource its answer links to, or the platform's ICF logoff when it links none (a DELETE on the session's own address answers 200 and ends nothing)
    • Takes an auth provider, same as above

    Which one you take is how you say where you are dialling. Nothing is probed: the session resource answers on on-prem too, so its answer says nothing about which system this is.

  • Auth providers (an IAuthProvider from @mcp-abap-adt/interfaces-auth; the ready-made ones — BasicAuthProvider, TokenAuthProvider, SamlAuthProvider, CertificateAuthProvider — are in @mcp-abap-adt/auth-providers 6.0.0, not here):

    • What a connection authenticates with, passed in. The connection never asks what kind of provider it was given; it calls the same four methods on all of them: prepare once before the wire opens, establish at each logon, authorize before every attempt, rejected when the system refused
    • A 401 that survives the wire's own CSRF recovery, or a refused logon, is put to the provider through rejected. If it answers Ok (a renewed token, say) the request gets exactly one more attempt; if it says no, or the retry is refused too, you get an AuthRefusedError carrying the provider's refusal — an IAuthProviderError minted by @mcp-abap-adt/auth-errors, with a kind to decide on and the reason / hint words to show
    • Every answer is re-checked: a refusal that is not a minted error (a plain { reason } from a JavaScript provider) becomes "the credential provider failed" (connection / provider-threw), and its text reaches no message
  • Transports (OnPremHttpTransport, CloudHttpTransport, RfcTransport):

    • What a request travels over, and everything that is true of that wire. HttpTransport keeps the cookie jar, the CSRF token and the affinity headers; RfcTransport translates into SADT_REST_RFC_ENDPOINT and keeps a conversation that IS the session
    • On-prem is where this is a real choice; ABAP Cloud has one wire and its connector takes no such parameter
    • rfcConversationFrom(config) builds what RfcTransport needs, deriving ashost and sysnr and loading the SDK only when a conversation opens. It reads no user or password: the provider writes the logon parameters
  • GenericWebSocketTransport (concrete, exported):

    • Transport abstraction for realtime WS message flows
    • Pluggable factory, envelope-based send/receive
    • Intended for higher-level debugger/trace session orchestration

Responsibilities and Design Principles

Core Development Principle

Interface-Only Communication: This package follows a fundamental development principle: all interactions with external dependencies happen ONLY through interfaces. The code knows NOTHING beyond what is defined in the interfaces.

This means:

  • Does not know about concrete implementation classes from other packages
  • Does not know about internal data structures or methods not defined in interfaces
  • Does not make assumptions about implementation behavior beyond interface contracts
  • Does not access properties or methods not explicitly defined in interfaces

This principle ensures:

  • Loose coupling: Connection classes are decoupled from concrete implementations in other packages
  • Flexibility: New implementations can be added without modifying connection classes
  • Testability: Easy to mock dependencies for testing
  • Maintainability: Changes to implementations don't affect connection classes

Package Responsibilities

This package is responsible for:

  1. HTTP communication with SAP systems: Makes HTTP requests to SAP ABAP systems via ADT protocol
  2. Authentication handling: Supports Basic Auth and JWT/OAuth2 authentication methods
  3. Session management: Manages cookies, CSRF tokens, and session state
  4. Error handling: Handles HTTP errors and connection issues

What This Package Does

  • Provides connection abstraction: AbapConnection interface for interacting with SAP systems
  • Handles HTTP requests: Makes requests to SAP ADT endpoints with proper headers and authentication
  • Manages sessions: Handles cookies, CSRF tokens, and session state persistence

What This Package Does NOT Do

  • Does NOT obtain tokens: Token acquisition is handled by @mcp-abap-adt/auth-providers and @mcp-abap-adt/auth-broker
  • Does NOT store tokens: Token storage is handled by @mcp-abap-adt/auth-stores
  • Does NOT refresh tokens: Token refresh is handled by @mcp-abap-adt/auth-broker
  • Does NOT orchestrate authentication: Token lifecycle management is handled by @mcp-abap-adt/auth-broker
  • Does NOT know about destinations: Destination-based authentication is handled by consumers
  • Does NOT handle OAuth2 flows: OAuth2 flows are handled by token providers

External Dependencies

This package interacts with external packages ONLY through interfaces:

  • Logger interface: Uses ILogger interface for logging - does not know about concrete logger implementation
  • No direct dependencies on auth packages: All token-related operations are handled through configuration (SapConfig) passed by consumers

The contracts themselves come from five packages, and with @mcp-abap-adt/auth-errors — the runtime that mints and classifies the auth errors — are the only runtime dependencies of this one besides axios:

Package What this package takes from it
@mcp-abap-adt/interfaces-adt-connection IAbapConnection, IAbapRequestOptions, IAdtWireResponse, ITimeoutConfig, the capability atoms, ADT_SESSION_ERROR
@mcp-abap-adt/interfaces-auth (7.x) IAuthProvider, AuthOutcome, IAuthRefusal (= IAuthProviderError), IAuthRejection, ILogonTarget, IRequestTarget, ICertificateMaterial, ITokenRefresher, ITokenRefreshResult
@mcp-abap-adt/interfaces-auth-sap ISapConfig, SapAuthType, SapConnectionType, ICertificateMaterialLoader
@mcp-abap-adt/interfaces-network NETWORK_ERROR_CODES, the WebSocket contracts
@mcp-abap-adt/interfaces-utils ILogger
@mcp-abap-adt/auth-errors authError (the builders: every refusal is minted here), classifyOutcome

Not @mcp-abap-adt/interfaces. That package is deleted as of its 52.0.0, which was never published: npm still serves 51.0.0 — every symbol re-exported and deprecated — to whoever is pinned to it, and nothing further ships there. This package stopped depending on it in 9.0.0; there is nothing left to move to but the packages above. See Migration to 9.0.0.

Documentation

  • 📦 Installation Guide - Setup and installation instructions
  • 📚 Usage Guide - Detailed usage examples and API documentation
  • 🚚 Migration to 19.0.0 - @mcp-abap-adt/sap-rfc-lite ^0.3.2: no compile on install; prebuilt RFC bindings for linux-x64, win32-x64, darwin-arm64; elsewhere build sap-rfc-lite's binding from its repository; the API is unchanged
  • 🚚 Migration to 18.0.0 - rfcParamsFrom returns RfcAddress (application server or message server): narrow before reading .ashost / .sysnr; RFC through a message server and a logon group (mshost, msserv | sysid, group, saprouter)
  • 🚚 Migration to 17.0.0 - no deadline of the package's own: getTimeout, getTimeoutConfig and SAP_TIMEOUT_* are gone, and a request inside a critical section carries none at all; an instance can be built on an existing session (session, sessionCookies())
  • 🚚 Migration to 16.0.0 - the per-request signal (15.1.0) and ADT_REQUEST_ABORTED are removed; disconnect() resolves once the logoff is written, not answered — a process about to exit awaits it; flushGoodbye() when the answer matters
  • 🚚 Migration to 15.0.0 - on @mcp-abap-adt/interfaces-adt-connection 2: makeAdtRequest's answer is unknown unless its type is named; nothing changes at run time
  • 🚚 Migration to 14.0.0 - the sap-abap-auth CLI is removed, with its open and commander dependencies; use @mcp-abap-adt/auth-broker-cli; the library's API is unchanged
  • 🚚 Migration to 13.0.0 - interfaces-auth 7 and auth-errors 2, and the providers from auth-providers 6.0.0; no behaviour change, one interfaces-auth major per process
  • 🚚 Migration to 12.0.0 - refusals follow the auth error contract (interfaces-auth 6, auth-errors): AuthRefusedError.refusal is an IAuthProviderError — read its kind; a custom ILogonTarget mints its refusal; no auth-providers release fits 12.x — take 13.0.0 with auth-providers 6.0.0
  • 🚚 Migration to 11.0.0 - the server certificate is verified by default; trust a self-signed system with agentOptions.ca, or opt out explicitly
  • 🚚 Migration to 10.0.0 - the credential providers moved to @mcp-abap-adt/auth-providers; the connection speaks IAuthProvider 3.0; AuthRefusedError; the RFC factory takes the logon parameters
  • 🚚 Migration to 9.0.0 - the contracts split out of @mcp-abap-adt/interfaces; which package each one moved to
  • 🚚 Migration to 7.0.0 and 8.0.0 - request headers leave the stateful branch, the contracts floor moves, and flushGoodbye()
  • 🚚 Migration to 6.0.0 - the factory and the per-credential classes are removed; RFC is a transport, not a class
  • 🚚 Migration to 4.0.0 - a 401 refreshes the token, a 403 reaches you with the server's message; the synthesised "JWT token has expired" is gone
  • 🚚 Migration: the explicit session lifecycle - connect() is now required; start here if you are coming from 1.x
  • 💡 Examples - Working code examples

Features

  • 🔐 Multiple Authentication Methods: Basic Auth for on-premise systems, JWT/OAuth2 for SAP BTP ABAP Environment
  • 💾 Session Management: Session headers management (cookies, CSRF tokens) for HTTP communication
  • 📝 Custom Logging: Pluggable logger interface for integration with any logging system (optional)
  • 📦 TypeScript: Full TypeScript support with type definitions included
  • ⏱️ No built-in deadline: a request carries the caller's own timeout and nothing else — inside a critical section, none at all
  • 🌐 Network Error Detection: Automatic detection and proper handling of network-level errors (connection refused, timeout, DNS failures)

Installation

npm install @mcp-abap-adt/connection

That is everything you need to use the connectors. If your own code names a contract type — IAuthProvider for a credential you write, ISapConfig for a config you build — install the package it lives in as well, because this one no longer brings them along:

npm install @mcp-abap-adt/interfaces-adt-connection @mcp-abap-adt/interfaces-auth @mcp-abap-adt/interfaces-auth-sap

A provider or a logon target of your own answers refusals minted by @mcp-abap-adt/auth-errors (authError[kind](facts)); install it too. Keep one @mcp-abap-adt/interfaces-auth major in the process (npm ls @mcp-abap-adt/interfaces-auth).

The credential providers (BasicAuthProvider, TokenAuthProvider, SamlAuthProvider, CertificateAuthProvider) are not part of this package since 10.0.0. Install them from where they live:

npm install @mcp-abap-adt/auth-providers@^6

connection 13.x to 15.x work with auth-providers 6.0.0 (^6.0.0), the release built on interfaces-auth 7 and auth-errors 2, and are tested with it: the 5.x providers answer unbranded refusals and do not fit. See Migration to 13.0.0 and Migration to 12.0.0.

For detailed installation instructions, see Installation Guide.

Quick Start

The recommended way: createConnection

import { createConnection } from "@mcp-abap-adt/connection";
import type { SapConfig } from "@mcp-abap-adt/connection";
import { BasicAuthProvider } from "@mcp-abap-adt/auth-providers";

const config: SapConfig = {
  url: "https://your-sap-system.com",
  client: "100",
  authType: "basic",
  username: "your-username",
  password: "your-password",
};

// Which system, and which wire: both stated, never inferred.
//   cloud  + http          → AdtCloudConnector  + CloudHttpTransport
//   onprem + http          → AdtOnPremConnector + OnPremHttpTransport
//   onprem + legacy-http   → AdtOnPremConnector + LegacyOnPremHttpTransport (BASIS 7.40)
//   onprem + rfc           → AdtOnPremConnector + RfcTransport
const connection = createConnection({
  system: "onprem",
  transport: "http",
  config,
  credential: new BasicAuthProvider(config.username!, config.password!),
});

await connection.connect();
try {
  await connection.makeAdtRequest({
    method: "GET",
    url: "/sap/bc/adt/discovery",
    timeout: 0,
  });
} finally {
  await connection.disconnect(); // resolves once the logoff is written
}
// One instance is one SAP session: a new session is a new createConnection().

createConnection returns a new instance on every call. Any other combination of system and wire (cloud over RFC, say) is refused at construction. The transport options are passed through as you give them: agentOptions for the HTTP wires, rfc for the RFC wire, wireLogger for what the wire logs about itself, logger and sessionId for the connection. The constructors below remain available; the factory only names the four combinations that exist.

No deadline but yours. A request carries the timeout you pass and nothing else: no wire sets one of its own, because a client deadline closes the socket and SAP then ends the request running on it — a write in it lost. IAbapRequestOptions still requires the field; 0 is none over HTTP. Inside a critical section (beginCriticalSection()) a request carries none at all, not even yours.

Kept-alive connections, and the one resend (19.0.1). The HTTP wires pool their sockets over http: and https: alike (Node.js's global-agent options: keepAlive, lifo, an idle socket closed after 5 s; your agentOptions win over https:, so keepAlive: false there turns the pool off). A server may close an idle kept-alive connection just as a request is written onto it; that request is lost unread. So, as a named exception to "no retries": a request that went out on a socket taken from the pool, and whose socket ended with no byte of an answer read (ECONNRESET — "socket hang up" — or EPIPE), is sent once more, on a new connection, with the same cookies, session headers, CSRF token and body — the ABAP session and its locks are the cookies', not the socket's (measured on-premise, 2026-10-23). Nothing else is resent: not a request on a socket opened for it, not one that read any byte of an answer, not one after a redirect, not a stream body, not the logoff, and never a second time. The resend is visible only as a debug line in fixed words. The risk, stated: a server that read the request and died before writing a byte of its answer would run it twice; for a socket the server closed while it was idle, that does not happen. Without the resend, a cleanup UNLOCK died so and the enqueue lock stayed until SM12 (mcp-abap-adt#314).

An instance on an existing session

An instance may be built on a session that already exists, instead of logging on: session takes the cookies an earlier instance's sessionCookies() returned. connect() then sends no logon. It makes one request in that session without the credential — so a session that is gone cannot quietly become a new one — and SAP's answer says whether the session is alive: if it is, the instance is on it (a stateful request reaches the same ABAP context, and disconnect() logs it off); if not, connect() rejects with SAP's answer as it is (a 401), nothing is logged off, and the instance is spent. HTTP wires only; over RFC session is refused at construction.

import { createConnection } from "@mcp-abap-adt/connection";
import type { SapConfig } from "@mcp-abap-adt/connection";
import { BasicAuthProvider } from "@mcp-abap-adt/auth-providers";

const system: SapConfig = {
  url: "https://your-sap-system.com",
  client: "100",
  authType: "basic",
  username: "your-username",
  password: "your-password",
};
const credential = new BasicAuthProvider(system.username!, system.password!);

const first = createConnection({ system: "onprem", transport: "http", config: system, credential });
await first.connect();
// The session's cookies, for you to keep — they act as the user while the
// session lives, so keep them as a secret. null before a session, and over RFC.
const kept = first.sessionCookies();

// Later — another process, say: the same SAP session, no new logon.
if (kept !== null) {
  const resumed = createConnection({
    system: "onprem",
    transport: "http",
    config: system,
    credential,
    session: kept,
  });
  await resumed.connect(); // rejects with SAP's 401 when the session is gone
  try {
    await resumed.makeAdtRequest({ method: "GET", url: "/sap/bc/adt/discovery", timeout: 0 });
  } finally {
    await resumed.disconnect(); // logs the session off, as for any instance
  }
}

Basic Usage (On-Premise), with the constructors

import {
  AdtOnPremConnector,
  OnPremHttpTransport,
  SapConfig,
} from "@mcp-abap-adt/connection";
import { BasicAuthProvider } from "@mcp-abap-adt/auth-providers";

const config: SapConfig = {
  url: "https://your-sap-system.com",
  client: "100",
  authType: "basic",
  username: "your-username",
  password: "your-password",
};

// Create a simple logger
const logger = {
  info: (msg: string, meta?: any) => console.log(msg, meta),
  error: (msg: string, meta?: any) => console.error(msg, meta),
  warn: (msg: string, meta?: any) => console.warn(msg, meta),
  debug: (msg: string, meta?: any) => console.debug(msg, meta),
};

// Which system you are dialling is the class you take; which credential it
// authenticates with is the object you hand it. Neither is detected.
const connection = new AdtOnPremConnector(
  config,
  new BasicAuthProvider(config.username!, config.password!),
  new OnPremHttpTransport(() => ({}), logger, {
    client: config.client,
    baseUrl: config.url,
  }),
  logger,
);
await connection.connect();   // required before any request

// Make ADT request
const response = await connection.makeAdtRequest({
  method: "GET",
  url: "/sap/bc/adt/programs/programs/your-program",
  timeout: 0,
});

The client is the wire's. The transport's client option is what addresses the SAP client (mandant): an HTTP wire sends it as the sap-client header and the sap-usercontext=sap-client=<n> cookie on every request, the first one — which opens the session and earns the CSRF token — included. config.client alone does not reach the system over HTTP; pass the same value to the transport, as above. X-SAP-Client is not sent: ABAP ignores it. A request cannot name another client: a sap-client header, query parameter, params entry or sap-usercontext cookie (in any case) that differs from the connection's — or names one when the connection was given none — is refused before anything is sent. Another client is another logon, with its own user, password, session and CSRF token, so it is another connection.

Cloud Usage (JWT/OAuth2)

import {
  AdtCloudConnector,
  CloudHttpTransport,
  SapConfig,
} from "@mcp-abap-adt/connection";
import { TokenAuthProvider } from "@mcp-abap-adt/auth-providers";

// JWT configuration
const config: SapConfig = {
  url: "https://your-instance.abap.cloud.sap",
  client: "100", // Optional
  authType: "jwt",
  jwtToken: "your-jwt-token-here", // Obtained via OAuth2 flow
};

const logger = {
  info: (msg: string, meta?: any) => console.log(msg, meta),
  error: (msg: string, meta?: any) => console.error(msg, meta),
  warn: (msg: string, meta?: any) => console.warn(msg, meta),
  debug: (msg: string, meta?: any) => console.debug(msg, meta),
};

// Logger is optional - if not provided, no logging output.
// `.fixed` is a token with nothing behind it. `TokenAuthProvider.from` takes an
// `ITokenRefresher` instead and renews when the system refuses the token, which
// is what you want in anything long-lived.
const connection = new AdtCloudConnector(
  config,
  TokenAuthProvider.fixed(config.jwtToken!),
  new CloudHttpTransport(() => ({}), logger, {
    client: config.client,
    baseUrl: config.url,
  }),
  logger,
);
await connection.connect();

// Note: obtaining and refreshing tokens is @mcp-abap-adt/auth-broker's job
const response = await connection.makeAdtRequest({
  method: "GET",
  url: "/sap/bc/adt/programs/programs/your-program",
  timeout: 0,
});

On-Premise over RFC

The same ADT calls, over SADT_REST_RFC_ENDPOINT — the function module Eclipse ADT itself uses through JCo — instead of over HTTP. Worth taking on a system where stateful HTTP sessions are not usable: an RFC conversation is one ABAP session for its whole lifetime, which is the way past 423 invalid lock handle on BASIS < 7.50.

Since 9.3.2, LOCK and UNLOCK in stateful mode share the persistent RFC conversation; stateless requests use a separate ABAP context. With sap-rfc-lite 0.2.1 or newer (required since 9.4.2), that context is reset between calls on a kept conversation; older clients open one conversation per call. 0.2.0 on npm lacks the reset in its JS client and behaves like an older one. This prevents stale package state from causing PAK/058 on later writes or SADT_RESOURCE/007 on a read after create. Concurrent calls use a separate conversation. See RFC conversation isolation.

Needs npm install @mcp-abap-adt/sap-rfc-lite (0.3.2 or newer: it installs with no install script and carries prebuilt bindings for linux-x64 glibc, win32-x64 and darwin-arm64; elsewhere, build one from its repository) and the SAP NetWeaver RFC library at run time: on Windows, the 64-bit sapnwrfc.dll SAP GUI installs into System32 is enough; otherwise the SAP NW RFC SDK, its lib directory on the library search path (see sap-rfc-lite's README, "Runtime library loading").

import {
  AdtOnPremConnector,
  RfcTransport,
  rfcConversationFrom,
} from "@mcp-abap-adt/connection";
import { BasicAuthProvider } from "@mcp-abap-adt/auth-providers";

const connection = new AdtOnPremConnector(
  config,
  new BasicAuthProvider(config.username!, config.password!),
  new RfcTransport(rfcConversationFrom(config), logger),
  logger,
);

await connection.connect();
// Everything above the wire is the same: makeAdtRequest, setSessionType,
// disconnect. What differs is where the session lives — see below.

Where to look for it. An HTTP session is an ICF session and appears in SM05. An RFC conversation is a gateway client: it appears in SMGW → Logged on Clients as NWRFC, and never in SM05, because there is no ICM in that path. Looking for one in the other monitor and finding nothing is not a fault.

There is no cloud equivalent: ABAP Cloud has one wire, and AdtCloudConnector takes no transport parameter at all.

SSO Usage (SAML Session Cookies)

import {
  AdtOnPremConnector,
  OnPremHttpTransport,
  SapConfig,
} from "@mcp-abap-adt/connection";
import { SamlAuthProvider } from "@mcp-abap-adt/auth-providers";

const config: SapConfig = {
  url: "https://your-sap-system.com",
  authType: "saml",
  sessionCookies: "MYSAPSSO2=...; SAP_SESSIONID=...",
};

// The cookies ARE the credential here — there is no Authorization header at all.
const connection = new AdtOnPremConnector(
  config,
  new SamlAuthProvider(config.sessionCookies!),
  new OnPremHttpTransport(() => ({}), logger, {
    client: config.client,
    baseUrl: config.url,
  }),
  logger,
);
await connection.connect();

const response = await connection.makeAdtRequest({
  method: "GET",
  url: "/sap/bc/adt/programs/programs/your-program",
  timeout: 0,
});

Cloud Usage with Automatic Token Refresh

Build TokenAuthProvider.from over an ITokenRefresher and the provider replaces a token the system refused: the connection puts the 401 to rejected, the provider fetches a new token, and the request is sent once more. A token the source still believes in and the server refuses again is the verdict, and that one surfaces as an AuthRefusedError:

import {
  AdtCloudConnector,
  CloudHttpTransport,
  SapConfig,
} from "@mcp-abap-adt/connection";
import { TokenAuthProvider } from "@mcp-abap-adt/auth-providers";
import type { ITokenRefresher } from "@mcp-abap-adt/interfaces-auth";

// Token refresher provides token acquisition and refresh
// (created by @mcp-abap-adt/auth-broker or custom implementation)
const currentAccessToken = 'the access token you already hold';
const exchangeRefreshToken = async () => 'a freshly exchanged access token';
const tokenRefresher: ITokenRefresher = {
  getToken: async () => currentAccessToken,      // the one you hold
  refreshToken: async () => exchangeRefreshToken(), // a new one, and cache it
};

const config: SapConfig = {
  url: "https://your-instance.abap.cloud.sap",
  authType: "jwt",
};

// The connector says which SYSTEM this is; the provider says how to
// authenticate. Neither decides the other.
const connection = new AdtCloudConnector(
  config,
  TokenAuthProvider.from(tokenRefresher),
  new CloudHttpTransport(() => ({}), logger, {
    client: config.client,
    baseUrl: config.url,
  }),
  logger,
);
await connection.connect();

// On a 401 the connection asks the provider (`rejected`); on Ok it sends the
// request once more with the renewed token, otherwise you get an
// AuthRefusedError. The resend always runs under the same lease and generation
// as the attempt it repeats, so stale-request fencing applies. After a rejection
// at 'request' it goes out on the same session; after one at 'logon' the wire
// logs on again first. No 401 opens a new session: the resend stays in this
// one, or fails with SESSION_REPLACED if SAP replaced it. For a new session,
// create a new connection.
const response = await connection.makeAdtRequest({
  method: "GET",
  url: "/sap/bc/adt/programs/programs/your-program",
  timeout: 0,
});

A 403 is never treated as an expired token. It means the server authenticated the caller and refused the action anyway, so no credential can change the answer. It propagates unchanged — error.response.status and the server's message, which usually names the authorization object — rather than being reported as an expired token.

Earlier versions reported both 401 and 403 as JWT token has expired. Please re-authenticate. and discarded the original error. Code matching on that message must branch on error.response.status instead — which it can now do, since the status is no longer thrown away. See MIGRATION-4.0.md.

When the credential is refused

Every request asks the provider first (authorize). A 401 that survives the wire's own CSRF recovery (in the same session), or a logon the system refused, is put to the provider (rejected); on Ok the request is sent once more, otherwise it fails with an AuthRefusedError carrying the provider's refusal. A 403 is never put to the provider. The refusal is an IAuthProviderError: decide on its kind and facts; reason and hint are the words to show, and the error's message is reason — hint. It is frozen — relay it, never copy it.

import { AuthRefusedError } from "@mcp-abap-adt/connection";

try {
  await connection.connect();
} catch (error) {
  if (error instanceof AuthRefusedError) {
    if (error.refusal.kind === "credential-refused") {
      // the credential itself was refused: renew it, or ask the user
    }
    console.error(error.refusal.reason, error.refusal.hint, error.at);
  }
  throw error;
}

See USAGE.md.

The server certificate

The HTTP wire verifies the server certificate, as Node does. For a system with a self-signed certificate or one from a company CA, trust that CA:

import { readFileSync } from "node:fs";
import { OnPremHttpTransport } from "@mcp-abap-adt/connection";

const ca = readFileSync("/path/to/sap-system-ca.pem", "utf8");
const transport = new OnPremHttpTransport(() => ({ ca }), logger, {
  client: config.client,
  baseUrl: config.url,
});

Verification is turned off only explicitly — agentOptions: { rejectUnauthorized: false }, or (when agentOptions leaves it unset) TLS_REJECT_UNAUTHORIZED=0 or NODE_TLS_REJECT_UNAUTHORIZED=0; any other value verifies. A last resort, never for production. Before 11.0.0 the certificate was not verified by default: see MIGRATION-11.0.md.

Stateful Sessions

Enable stateful mode for requests that need the ABAP context, such as LOCK and UNLOCK. Switch to stateless for GET and PUT between them: a lock handle does not require the write itself to run in the lock’s context. Authentication cookies and the CSRF token remain available in both modes. See the Stateful Session Guide for transport behavior.

import {
  AdtOnPremConnector,
  OnPremHttpTransport,
} from "@mcp-abap-adt/connection";
import { BasicAuthProvider } from "@mcp-abap-adt/auth-providers";

const connection = new AdtOnPremConnector(
  config,
  new BasicAuthProvider(config.username!, config.password!),
  new OnPremHttpTransport(() => ({}), logger, {
    client: config.client,
    baseUrl: config.url,
  }),
  logger,
);
await connection.connect();

// Enable the stateful HTTP context (session header and context cookie)
connection.setSessionType("stateful");

// Send the LOCK request here, then leave its context for GET / PUT.
connection.setSessionType("stateless");

// Send GET / PUT with the lock handle here.
// Return to the same context when sending UNLOCK.
connection.setSessionType("stateful");
// Send UNLOCK here, then restore the default mode.
connection.setSessionType("stateless");

Custom Logger

import { AdtOnPremConnector, ILogger, OnPremHttpTransport } from "@mcp-abap-adt/connection";
import { BasicAuthProvider } from "@mcp-abap-adt/auth-providers";

class MyLogger implements ILogger {
  info(message: string, meta?: any): void {
    // Your logging implementation
  }

  error(message: string, meta?: any): void {
    // Your logging implementation
  }

  warn(message: string, meta?: any): void {
    // Your logging implementation
  }

  debug(message: string, meta?: any): void {
    // Your logging implementation
  }
}

const logger = new MyLogger();
const connection = new AdtOnPremConnector(
  config,
  new BasicAuthProvider(config.username!, config.password!),
  new OnPremHttpTransport(() => ({}), logger, {
    client: config.client,
    baseUrl: config.url,
  }),
  logger,
);

API Reference

Types

SapConfig

Configuration for SAP ABAP connection.

type SapConfig = {
  url: string;
  client?: string;
  authType: "basic" | "jwt" | "saml";
  // For basic auth
  username?: string;
  password?: string;
  // For JWT auth
  jwtToken?: string;
  // For SAML session cookies
  sessionCookies?: string;
};

AbapConnection

Main interface for ABAP connections.

import type { AbapRequestOptions } from '@mcp-abap-adt/connection';
import type { IAdtWireResponse } from '@mcp-abap-adt/interfaces-adt-connection';
// The shared contract (IAbapConnection), what every connection provides:
interface AbapConnection {
  connect(): Promise<void>; // REQUIRED before any request; rejects on failure
  // An answer whose type is not named is `unknown` (interfaces-adt-connection 2).
  makeAdtRequest<T = unknown, D = unknown>(
    options: AbapRequestOptions,
  ): Promise<IAdtWireResponse<T, D>>;
  getBaseUrl(): Promise<string>;
  setSessionType(type: "stateless" | "stateful"): void; // Switch session type
  getSessionId(): string | null; // Client-side conversation id
}

The connectors carry the rest of the session lifecycle. It is on the shared contract as a capability atom in @mcp-abap-adt/interfaces-adt-connection rather than as methods on IAbapConnection, so a consumer that only carries requests is unaffected by its existence. Note that a connection over RFC has the whole of it — what an RFC conversation has none of is a session RESOURCE to open and close by address, which is an empty mechanism, not an absent lifecycle:

// ISessionLifecycleAware
disconnect(): Promise<void>;         // resolves once the logoff is written; call it once (a second call rejects)
isConnected(): boolean;
getSessionIdentity(): string | null; // WHICH SAP session; null is not "disconnected"

Import those names from @mcp-abap-adt/interfaces-adt-connection, not from this package: a contract type re-exported under a second name is a contract type that can drift.

The connection does not track locks. Deciding when to disconnect, and preparing for it, belongs to the caller; pairing every LOCK with its UNLOCK belongs to @mcp-abap-adt/adt-clients, which holds the handles. What this layer owns is not being interrupted by a deadline mid-operation: inside beginCriticalSection() a request carries none, not even the caller's.

See docs/USAGE.md — Session Lifecycle.

Session Management:

  • setSessionType(type): Programmatically switch between stateful and stateless modes (on the contract)
  • getSessionId(): Returns the client-side conversation id, an auto-generated UUID (on the contract)
  • getSessionMode(): Returns current session mode (HTTP classes only)
  • sessionCookies(): The cookies of the instance's SAP session, as createConnection({ …, session }) takes them — null before a session, after it ended, and over RFC. They act as the user while the session lives: keep them as a secret; the package never logs them (this package's connectors)

ILogger

Logger interface for custom logging implementations.

interface ILogger {
  info(message: string, meta?: any): void;
  error(message: string, meta?: any): void;
  warn(message: string, meta?: any): void;
  debug(message: string, meta?: any): void;
}

Functions

createConnection(spec)

Builds a NEW connection from what the caller states: system ('cloud' or 'onprem') and transport ('http', 'legacy-http' or 'rfc'), plus config, credential and, optionally, logger, wireLogger, sessionId, agentOptions and session (HTTP) or rfc (IRfcTransportOptions). Nothing is inferred, not even from config.connectionType. A combination that does not exist is refused with a TypeError, and so is session over RFC. One instance is one SAP session: call it again for a new one — or pass session (the string an instance's sessionCookies() returned) to build it on an existing session, which connect() then takes over instead of logging on.

rfcConversationFrom(config)

What RfcTransport is constructed with. The address is one of two:

  • One application server (default): ashost from the url and sysnr from the HTTP port by the SAP convention that 80XX is the ICM port for system XX, which SAP_SYSNR overrides for a port that follows no convention.
  • A message server and a logon group, when the config states mshost: the config's mshost, msserv or sysid (one of the two is required) and group (default PUBLIC) go to the SDK as they are, and the url gives no host or system number — the message server picks the application server.

saprouter goes with either.

The SAP NW RFC SDK is loaded when a conversation opens, not when this is called, so a machine without it fails at connect() with a message saying what to install rather than at construction.

function rfcConversationFrom(
  config: SapConfig,
): (logon: Readonly<Record<string, string>>) => IRfcConversation;
function rfcParamsFrom(config: SapConfig): RfcAddress; // the address only
// RfcAddress = RfcConnectionParams (ashost, sysnr) | RfcMessageServerParams (mshost, msserv | sysid, group)
import {
  AdtOnPremConnector,
  RfcTransport,
  rfcConversationFrom,
} from "@mcp-abap-adt/connection";
import { BasicAuthProvider } from "@mcp-abap-adt/auth-providers";

const connection = new AdtOnPremConnector(
  config,
  new BasicAuthProvider(config.username!, config.password!),
  new RfcTransport(rfcConversationFrom(config), logger),
  logger,
);

A third argument turns on the wire log, which is off by default:

new RfcTransport(rfcConversationFrom(config), logger, { logWire: true });

It adds three debug lines per request — header fields, request body, response body — which is how you see that a payload was mis-serialised before it reached SADT_REST_RFC_ENDPOINT. Credential header values are replaced with [redacted] and bodies are clipped at maxLoggedBodyChars (2000). The bodies themselves are not redacted, so read a captured log before pasting it into an issue. HttpTransport never logs bodies, so this is the one wire whose debug channel can be asked for the payload.

CSRF_CONFIG and CSRF_ERROR_MESSAGES

New in 0.1.13+: Exported constants for consistent CSRF token handling across different connection implementations.

import { CSRF_CONFIG, CSRF_ERROR_MESSAGES } from '@mcp-abap-adt/connection';

// CSRF_CONFIG contains:
// - RETRY_COUNT: number (default: 3)
// - RETRY_DELAY: number (default: 1000ms)
// - ENDPOINT: string (default: '/sap/bc/adt/core/discovery')
// - REQUIRED_HEADERS: { 'x-csrf-token': 'fetch', 'Accept': 'application/atomsvc+xml' }

// CSRF_ERROR_MESSAGES contains:
// - FETCH_FAILED(attempts: number, cause: string): string
// - NOT_IN_HEADERS: string
// - REQUIRED_FOR_MUTATION: string

Use case: When implementing custom connection classes (e.g., Cloud SDK-based), you can use these constants to ensure consistent CSRF token handling:

import { CSRF_CONFIG, CSRF_ERROR_MESSAGES } from '@mcp-abap-adt/connection';

// Whatever HTTP client your own connection class is built on.
const yourHttpClient = {
  get: async (url: string, config: { headers: Record<string, string> }) =>
    ({ headers: {} as Record<string, string> }),
};

async function fetchCsrfToken(baseUrl: string): Promise<string> {
  const csrfUrl = `${baseUrl}${CSRF_CONFIG.ENDPOINT}`;
  
  for (let attempt = 0; attempt <= CSRF_CONFIG.RETRY_COUNT; attempt++) {
    try {
      const response = await yourHttpClient.get(csrfUrl, {
        headers: CSRF_CONFIG.REQUIRED_HEADERS
      });
      
      const token = response.headers['x-csrf-token'];
      if (!token) {
        if (attempt < CSRF_CONFIG.RETRY_COUNT) {
          await new Promise(resolve => setTimeout(resolve, CSRF_CONFIG.RETRY_DELAY));
          continue;
        }
        throw new Error(CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
      }
      
      return token;
    } catch (error) {
      if (attempt >= CSRF_CONFIG.RETRY_COUNT) {
        throw new Error(
          CSRF_ERROR_MESSAGES.FETCH_FAILED(
            CSRF_CONFIG.RETRY_COUNT + 1,
            error instanceof Error ? error.message : String(error)
          )
        );
      }
      await new Promise(resolve => setTimeout(resolve, CSRF_CONFIG.RETRY_DELAY));
    }
  }

  // Unreachable: the last attempt either returns or throws above. Stated so the
  // function has a return type the compiler can agree with.
  throw new Error(CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
}

Requirements

  • Node.js 22, 24 or 26 (engines: ^22 || ^24 || ^26)
  • Access to SAP ABAP system (on-premise or BTP)

Changelog

See CHANGELOG.md for detailed version history and breaking changes.

Version history: CHANGELOG.md

  • Removed token refresh functionality (handled by @mcp-abap-adt/auth-broker)
  • Removed session storage functionality (handled by @mcp-abap-adt/auth-broker)
  • Logger is now optional
  • See CHANGELOG.md for full details

Documentation

License

GNU Lesser General Public License v3.0 only (LGPL-3.0-only). Earlier published versions were MIT and stay MIT — a licence change is not retroactive.

Copyright © 2025–2026 Oleksii Kyslytsia

This library is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, version 3.

It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.

Both texts ship with the package and both are needed: LICENSE is the LGPL, COPYING is the GPL it is written on top of, since the LGPL is a set of additional permissions over the GPL and cannot be read alone.

What this means if you depend on this package. Linking it into your own program — importing it, as every consumer of an npm package does — does not put your program under the LGPL. What the licence asks is that changes to this library stay free, and that your users can replace it with their own build.

Repository

https://github.com/fr0ster/mcp-abap-adt

Related Projects