使用说明
@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-brokerpackage - Connection package focuses on HTTP communication only
- Token refresh is handled by
- 💾 Session Management:
- Session headers management (cookies, CSRF tokens)
- Session state persistence is handled by
@mcp-abap-adt/auth-brokerpackage
- 🏗️ 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
- One connector per SYSTEM (
- 🔌 Realtime Transport Scaffold:
- Generic
GenericWebSocketTransportwith pluggable WS factory - Reusable for debugger/traces and other event-driven flows
- Generic
- 📝 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
timeoutand 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/sessionsand given back through the logoff resource its answer links to, or the platform's ICF logoff when it links none (aDELETEon the session's own address answers200and 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.
- An ABAP Cloud system: a session is a resource, opened at
-
Auth providers (an
IAuthProviderfrom@mcp-abap-adt/interfaces-auth; the ready-made ones —BasicAuthProvider,TokenAuthProvider,SamlAuthProvider,CertificateAuthProvider— are in@mcp-abap-adt/auth-providers6.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:
prepareonce before the wire opens,establishat each logon,authorizebefore every attempt,rejectedwhen the system refused - A
401that survives the wire's own CSRF recovery, or a refused logon, is put to the provider throughrejected. 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 anAuthRefusedErrorcarrying the provider's refusal — anIAuthProviderErrorminted by@mcp-abap-adt/auth-errors, with akindto decide on and thereason/hintwords 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
- 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:
-
Transports (
OnPremHttpTransport,CloudHttpTransport,RfcTransport):- What a request travels over, and everything that is true of that wire.
HttpTransportkeeps the cookie jar, the CSRF token and the affinity headers;RfcTransporttranslates intoSADT_REST_RFC_ENDPOINTand 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 whatRfcTransportneeds, derivingashostandsysnrand loading the SDK only when a conversation opens. It reads no user or password: the provider writes the logon parameters
- What a request travels over, and everything that is true of that wire.
-
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:
- HTTP communication with SAP systems: Makes HTTP requests to SAP ABAP systems via ADT protocol
- Authentication handling: Supports Basic Auth and JWT/OAuth2 authentication methods
- Session management: Manages cookies, CSRF tokens, and session state
- Error handling: Handles HTTP errors and connection issues
What This Package Does
- Provides connection abstraction:
AbapConnectioninterface 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-providersand@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
ILoggerinterface 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 -
rfcParamsFromreturnsRfcAddress(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,getTimeoutConfigandSAP_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) andADT_REQUEST_ABORTEDare 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-connection2:makeAdtRequest's answer isunknownunless its type is named; nothing changes at run time - 🚚 Migration to 14.0.0 - the
sap-abap-authCLI is removed, with itsopenandcommanderdependencies; 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.refusalis anIAuthProviderError— read itskind; a customILogonTargetmints 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 speaksIAuthProvider3.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
timeoutand 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, ascreateConnection({ …, session })takes them —nullbefore 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):
ashostfrom the url andsysnrfrom the HTTP port by the SAP convention that80XXis the ICM port for systemXX, whichSAP_SYSNRoverrides for a port that follows no convention. - A message server and a logon group, when the config states
mshost: the config'smshost,msservorsysid(one of the two is required) andgroup(defaultPUBLIC) 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
- mcp-abap-adt - Main MCP server for ABAP ADT
