# ADT-over-RFC bridge
**Give any HTTP-only ABAP ADT client full access to a SAP system that is only
reachable over RFC / through a SAProuter. That includes the [`vsp`][vsp] MCP
server that lets Claude develop ABAP in SAP.**
Tools like [`vsp` / vibing-steampunk][vsp] let **Claude** (or another AI/MCP
client) read, search and edit ABAP in your SAP system through the **ADT REST API
over HTTP**. But some SAP systems can only be reached through a SAProuter that
allows the SAP *NI* protocol (DIAG, gateway/RFC) but **not** raw HTTP routing to
the ICM. An ADT client that speaks only HTTP then cannot connect at all, even
though Eclipse ADT can.
This bridge fixes that. It is a tiny local HTTP server that takes each ADT REST
request from your HTTP client and forwards it to SAP over **RFC**, through the
standard function module `SADT_REST_RFC_ENDPOINT`, the very same path Eclipse
ADT uses when it tunnels ADT over RFC. Nothing changes on the SAP side.
```mermaid
flowchart LR
A["ADT client
(vsp, HTTP-only)"] -- "HTTP
ADT REST" --> B["adt_rfc_bridge.py
(localhost)"]
B -- "RFC via PyRFC
(+ saprouter)" --> C(["SAProuter"])
C -- "RFC" --> D["SADT_REST_RFC_ENDPOINT
(SAP gateway)"]
D --> E[("ADT framework")]
```
> 📖 The full story, the problem, the investigation, and why this works, is
> written up in the companion blog post: **[`blog/adt-over-rfc-bridge.md`](blog/adt-over-rfc-bridge.md)**.
---
## When you need this
Use this bridge if **all** of the following are true:
- Your ADT tooling speaks ADT **only over HTTP** (e.g. `vsp`).
- The target SAP system is reachable **only through a SAProuter**, and that
router's `saprouttab` permits NI-native routes (gateway port, e.g. `33`)
but **denies** raw routing to the ICM HTTP(S) port.
- You **cannot change anything on the SAP / customer side** (no new Web
Dispatcher, no `saprouttab` edits).
- **Eclipse ADT already works** against that system (that proves the
ADT-over-RFC path is open and you have the needed authorisations).
You do **not** need this if the system exposes HTTP(S) directly (VPN-reachable
ICM, a Web Dispatcher, or a SAProuter that permits raw routing to the ICM port).
In those cases point your ADT client straight at the HTTP(S) endpoint.
> **Backend-agnostic:** this works on **S/4HANA and classic ECC / R/3 alike**. The
> only requirement is that the system has the ADT backend, i.e. the
> `SADT_REST_RFC_ENDPOINT` function module exists (ABAP Development Tools ship on
> SAP NetWeaver 7.31+ with the relevant support packages). Rule of thumb: if
> Eclipse ADT can open the system, this bridge can drive it.
## How it works (short version)
1. Your HTTP client sends a normal ADT REST request to `http://127.0.0.1:`.
2. The bridge packs the request line, headers and body into the
`SADT_REST_REQUEST` structure and calls `SADT_REST_RFC_ENDPOINT` via
[`pyrfc`][pyrfc]. `pyrfc` understands the `saprouter` connection parameter
natively, so it traverses the router exactly like Eclipse/JCo.
3. SAP returns a `SADT_REST_RESPONSE`; the bridge turns it back into a normal
HTTP response.
Two small adaptations make HTTP clients happy:
- **`HEAD` → `GET`.** The function module rejects HTTP `HEAD` (400); the bridge
issues a `GET` and drops the body so the client still gets a valid `HEAD`.
- **Synthesised `X-CSRF-Token`.** There is no HTTP session over RFC, so SAP
issues no CSRF token. The bridge returns a placeholder token on `fetch`
requests. The function module is already authenticated by the RFC logon and
does not validate CSRF, so this only satisfies the client.
A single, lock-serialised RFC connection is reused and the bridge connects
**lazily**, performing no SAP logon until the first ADT request. One important
limit: `SADT_REST_RFC_ENDPOINT` is **stateless per call** (there is no HTTP
session over RFC, which is exactly why the CSRF token has to be synthesised). So
the bridge is great for **reads** and **single-shot source writes**, but it
**cannot drive a multi-request stateful flow such as object activation**, see
[Writing & activating objects](#writing--activating-objects) below for why and
what to do instead.
---
## Prerequisites
| Requirement | Notes |
|---|---|
| **SAP NW RFC SDK** | The C SDK `pyrfc` is built on. On Windows it usually ships **with SAP GUI** already (look for `sapnwrfc.dll`); otherwise download "SAP NWRFC SDK 7.50" from the SAP Support Portal (needs an S-user). Its `lib` folder must be on the library path (`PATH` on Windows). |
| **Python** matching the SDK architecture | The NW RFC SDK is **x86-64 only**. Use an **x64 Python** even on ARM Windows (an arm64 Python cannot load the x64 SDK). |
| **pyrfc** | `pip install pyrfc`, or grab a prebuilt wheel from the [PyRFC releases][pyrfc] if no wheel matches your Python. Must be the same architecture as the SDK and Python. |
| An ADT user with RFC authorisations | If Eclipse ADT works for your user, you already have them (`S_RFC` for the FM + the ADT resource auths). |
> **Architecture must match end to end:** x64 SDK ↔ x64 Python ↔ x64 `pyrfc`.
> This is the single most common setup mistake.
## Install
```bash
git clone https://github.com/enricoandreoli/adt-rfc-bridge.git
cd adt-rfc-bridge
pip install pyrfc # uses the NW RFC SDK already on your PATH
```
Configure your connection:
```bash
cp .env.example .env # Windows: copy .env.example .env
# edit .env with your RFC host, sysnr, client, user, password, saprouter, port
```
`.env` is git-ignored on purpose, it holds your credentials. Never commit it.
## Verify it works
Load your `.env` into the environment, then run the built-in self-test. It makes
a single ADT *core discovery* call all the way to SAP and prints the result:
```bash
python adt_rfc_bridge.py selftest
```
Expected output (abridged):
```
SELFTEST status: 200 OK
content-type: application/atomsvc+xml
body bytes: 4187
```
A `200` with an `atomsvc+xml` body means the whole chain, bridge → pyrfc →
SAProuter → `SADT_REST_RFC_ENDPOINT` → SAP, is working.
> ⚠️ **Account-lockout safety:** every ADT call is one RFC logon attempt. If a
> call fails with an **authentication** error, stop and fix the credentials,
> do **not** loop/retry, or SAP will lock the user. The bridge itself never
> auto-retries a failed logon.
## Run it
Start the bridge (it stays in the foreground, listening on `BRIDGE_PORT`):
```bash
python adt_rfc_bridge.py
# ADT-RFC bridge listening on http://127.0.0.1:8410 -> ... via /H/.../S/3299
```
Then point your HTTP-only ADT client at `http://127.0.0.1:`. For
`vsp` in CLI mode, set `SAP_URL`, `SAP_USER`, `SAP_PASSWORD`, `SAP_CLIENT` and
run your usual commands; e.g. a system-info / object-search call should now
return data from the system behind the router.
## Writing & activating objects
### Saving source over the bridge (and the `MODIFICATION_SUPPORT` self-block)
On newer NetWeaver releases (observed on **752**) an ADT object **lock** succeeds,
it returns a valid `LOCK_HANDLE` and the transport, but the lock result also
carries `MODIFICATION_SUPPORT=NoModification`. Some clients, including `vsp`, treat
that as "not modifiable" and **abort before writing**, so edits fail with a
*NoModification* error even though Eclipse edits the same object fine and the write
would actually go through. (That flag is a real NW 75x backend property, not an
RFC-channel artifact — it shows up over plain HTTPS too — but it does **not**
actually prevent the `PUT`.)
The included **`adt_write.py`** helper performs the raw `LOCK -> PUT .../source/main
-> UNLOCK` against a running bridge, bypassing the client's self-block:
```bash
# the bridge must be running: python adt_rfc_bridge.py
python adt_write.py CLAS ZCL_FOO ./zcl_foo.abap T74K900123 # transportable
python adt_write.py PROG ZFOO ./zfoo.abap # local / $TMP
```
Two headers on the `PUT` are mandatory and are the usual cause of failures when
missing: `Accept` (without it SAP returns **400** *"Accept header missing"*) and,
for a transportable object, `corrNr=` (without it **500** *"already
locked in request"*). It reads the bridge URL from `$BRIDGE_URL` or
`http://127.0.0.1:$BRIDGE_PORT`.
### Activation does NOT work over the RFC bridge — use the ICM HTTP(S) endpoint
The `PUT` above saves your source as the object's **inactive** version, but you
**cannot activate it through the bridge**. Because `SADT_REST_RFC_ENDPOINT` is
stateless per call, each bridge request is a *different* ADT session: the `LOCK`
creates a real enqueue, but the activation call doesn't own it (→ HTTP **403**
*"User … is currently editing"*), and if you unlock first the session-bound
inactive worklist is empty so activation is a silent **200 no-op**. The `PUT` works
only because it self-authenticates with `lockHandle`+`corrNr`; activation has no
such token. (Driving the three calls on one dedicated PyRFC connection doesn't help
— function-group globals don't carry the ADT session — and the direct-RFC
`RS_WORKING_OBJECTS_ACTIVATE` isn't RFC-callable.)
The fix: if the target's **ICM HTTP(S) port is reachable** (directly, or via a
SAProuter host that forwards it — many routers that allow OData/HTTPS also expose
`/sap/bc/adt`), drive ADT over **real HTTP** instead. There the ADT session is
stateful via the `sap-contextid` + `SAP_SESSIONID` cookies, so `LOCK -> PUT ->
ACTIVATE` share one session and activation works — exactly like Eclipse. The
included **`adt_https_write.py`** does the whole edit-and-activate end to end:
```bash
set SAP_URL=https://host:44300 & set SAP_USER=... & set SAP_PASSWORD=...
set SAP_CLIENT=100 & set SAP_CORRNR=T74K900123
python adt_https_write.py --uri /sap/bc/adt/oo/classes/zcl_foo --name ZCL_FOO \
--source ./zcl_foo.abap --check
# or just activate an object that already has an inactive version:
python adt_https_write.py --uri /sap/bc/adt/oo/classes/zcl_foo --name ZCL_FOO --activate-only
```
Two non-obvious details it handles, both of which silently cost hours otherwise:
- **`GET .../source/main` returns the _inactive_ version when one exists.** Always
verify the real active code with `?version=active`, or you'll think you activated
when you didn't.
- **Activation is two-phase.** `POST /sap/bc/adt/activation?...&preauditRequested=true`
returns the full set to activate, which includes the class **and the changed
method** (`adtcore:type="CLAS/OM/public"`) and the transport. You must then post
**all** of those object references with `preauditRequested=false`. Referencing only
the class is a silent no-op.
> The RFC bridge stays the right tool for **reads/search/analyze** and for saving
> source on RFC-only systems; pair it with `adt_https_write.py` for activation
> whenever the ICM HTTP(S) port happens to be reachable.
### Reports (PROG) are the exception: no activation needed at all
`RPY_PROGRAM_INSERT` **is** remote-enabled, and with `SAVE_INACTIVE = ' '` it writes
the program straight into its **active** version. So on an RFC-only system you can
create and update an executable report end to end, with no Eclipse and no ICM port.
**`rpy_put_report.py`** does that, plus the text pool:
```bash
set RFC_ASHOST=10.0.0.1 & set RFC_SYSNR=00 & set RFC_CLIENT=100
set RFC_USER=DEVELOPER & set RFC_PASSWD=...
python rpy_put_report.py --prog ZFOO --title "My report" --source ./zfoo.abap --replace --seltexts ./selection-texts.txt --symbols ./text-symbols.txt
```
Four things it had to work around, each of which costs an afternoon:
- **`RPY_PROGRAM_UPDATE` is not remote-enabled** (`CALL_FUNCTION_NOT_REMOTE`) and
`RPY_PROGRAM_DELETE` is not an RFC module at all. Replacing an existing report
therefore means: delete it through the bridge (ADT `LOCK` + `DELETE`), then insert
it again over RFC.
- **The ADT `DELETE` leaves the enqueue behind** — same stateless-session story as
above — and the next insert dies with **EU 510** *"already being edited"*. You must
release it yourself with `?_action=UNLOCK&lockHandle=...`. `ENQUEUE_READ` is
remote-enabled and will show you the stray `TRDIR` lock; `ENQUE_DELETE` is not, and
`DEQUEUE_E_TRDIR` from another session won't touch it. The original lock handle is
the only thing that works.
- **No text pool function module exists over RFC** (`RPY_TEXTPOOL_*` isn't there at
all), but ADT exposes
`/sap/bc/adt/textelements/programs//source/{selections,symbols,headings}`.
A plain-text `PUT` there saves the **active** version, no activation involved.
Format: `NAME =Text` (name padded to 8) for selection texts, `@MaxLength:`
followed by `KEY=Text` for text symbols.
- **Selection texts are capped at 30 characters.** One text over the limit and the
backend rejects the *whole* PUT with a bare **406** naming no culprit — it looks
like a header problem and isn't (a wrong content type returns 415). The script
checks the lengths before sending.
A syntax check can be requested from the backend through the bridge, which is worth
doing since the insert happily stores code that doesn't compile:
```bash
curl -X POST "http://127.0.0.1:8410/sap/bc/adt/checkruns?reporters=abapCheckRun" -H "Content-Type: application/vnd.sap.adt.checkobjects+xml" -H "X-CSRF-Token: ADT-RFC-BRIDGE" --data ''
```
An empty `checkReport` means no errors.
## Use it as an MCP server (Claude Desktop, etc.)
`vsp_launch.py` lets an MCP host (such as Claude Desktop) start everything with
one command: it auto-starts the bridge if its port is not already listening,
then hands over to the ADT client (e.g. `vsp`) inheriting the MCP stdio pipes.
Add one server entry per SAP client. Example (paths and values are illustrative):
```jsonc
{
"mcpServers": {
"my-rfc-client": {
"command": "C:\\path\\to\\x64\\python.exe",
"args": ["C:\\path\\to\\adt-rfc-bridge\\vsp_launch.py"],
"env": {
"BRIDGE_PORT": "8410",
"RFC_ASHOST": "10.0.0.1",
"RFC_SYSNR": "00",
"RFC_CLIENT": "100",
"RFC_USER": "YOUR_USER",
"RFC_PASSWD": "your-password",
"RFC_SAPROUTER": "/H/router.example.com/S/3299",
"ADT_CLIENT": "C:\\path\\to\\vsp.exe",
"SAP_URL": "http://127.0.0.1:8410",
"SAP_USER": "YOUR_USER",
"SAP_PASSWORD": "your-password",
"SAP_CLIENT": "100"
}
}
}
}
```
Because the bridge connects lazily, idle clients never touch SAP. To add another
RFC-only client, copy the entry, pick a **different** `BRIDGE_PORT` and matching
`SAP_URL`, and fill in that client's RFC settings.
## Configuration reference
| Variable | Used by | Meaning |
|---|---|---|
| `RFC_ASHOST` | bridge | Application server host as SAP sees it |
| `RFC_SYSNR` | bridge | System/instance number (default `00`) |
| `RFC_CLIENT` | bridge | Client |
| `RFC_USER` / `RFC_PASSWD` | bridge | RFC logon |
| `RFC_SAPROUTER` | bridge | Route string `/H/host/S/3299`; omit for direct access |
| `BRIDGE_PORT` | both | Local port the bridge listens on (default `8410`) |
| `SAP_URL` etc. | ADT client | Point the client at `http://127.0.0.1:` |
| `BRIDGE_PYTHON` | launcher | Python that can load the x64 SDK (default: current) |
| `BRIDGE_SCRIPT` | launcher | Path to `adt_rfc_bridge.py` |
| `ADT_CLIENT` | launcher | ADT client executable to run (default `vsp`) |
## Limitations
- Requires the NW RFC SDK and an RFC user with the ADT authorisations, i.e. a
setup where Eclipse ADT already works.
- Reuses one serialised RFC connection: simple and lock-safe, but not tuned for
many parallel ADT clients hammering one bridge.
- It bridges the ADT REST surface exposed by `SADT_REST_RFC_ENDPOINT`. That is
what Eclipse uses, so day-to-day ADT development works; very exotic ICM-only
endpoints are out of scope.
## Security notes
- `.env` and `adt_bridge.log` are git-ignored. The log can contain request URIs
and header values, keep it local and delete it when done debugging.
- The bridge listens on `127.0.0.1` only.
- Treat your RFC credentials like any SAP password.
## Credits
- **`vsp` / vibing-steampunk**, the HTTP-only ABAP ADT MCP client this bridge
was built to serve: [oisee/vibing-steampunk][vsp].
- **PyRFC**, the Python ↔ NW RFC SDK binding that traverses the SAProuter:
[SAP-archive/PyRFC][pyrfc].
- **`SADT_REST_RFC_ENDPOINT`**, the standard SAP function module that dispatches
ADT REST requests over RFC (the same one Eclipse ADT uses).
## License
[MIT](LICENSE).
---
## More tech reads
Like this kind of practical tech content? I also share technology news,
explainers and tools at **[The Clipboard][clipboard]** (theclipboard.it).
Worth a look if you enjoyed this write-up.
[vsp]: https://github.com/oisee/vibing-steampunk
[pyrfc]: https://github.com/SAP-archive/PyRFC
[clipboard]: https://theclipboard.it