ADT MCP
by NhatPD-VNEXT
README.md
# ADT MCP (Python)
Multi-system MCP server for reading and writing ABAP source via SAP ADT,
with a local web admin to configure systems. One process serves both the
MCP endpoint (`/mcp`) and the web admin (`/`).
## Install (Windows)
```bat
git clone https://github.com/NhatPD-VNEXT/ADT-MCP adt-mcp
cd adt-mcp
install.bat REM creates .venv, installs everything, verifies the import
run.bat REM starts the server and opens the web admin
```
Only prerequisite: **Python 3.10+** on the machine. `install.bat` finds it via
`py -3` or `python` and skips the Microsoft Store stub; it creates a project
`.venv` so nothing global is touched. Flags:
| Flag | Effect |
| --- | --- |
| `--no-venv` | Install into the Python already on PATH instead of `.venv` |
| `--no-browser` | Skip Playwright (only fine if every system uses basic auth) |
Playwright is installed by default because cookie systems log in through a
browser. It drives the machine's own Chrome/Edge, so there is no browser
download; if neither is installed, run
`.venv\Scripts\python -m playwright install chromium`.
### Configure the new machine
`systems.json` and `cookies/` are gitignored — they hold credentials and are
**never** in the repo. After `install.bat`, pick one:
- **Add the system in the web admin** (`run.bat` → http://127.0.0.1:8765) and
log in once with the browser flow. Nothing secret has to be copied.
- **Copy `systems.json` + `cookies\` by hand** from a working machine (USB,
password manager, internal share — not email/chat). Fix the absolute
`cookie_file` paths afterwards, or re-login from the admin.
Until that is done the server runs and the admin opens, but `list_systems`
reports no systems.
## Install (manual / non-Windows)
```bash
cd adt-mcp
python -m pip install -e ".[refresh]" # -e matters: config, web/ and cookies/
# are read from the checkout.
# [refresh] = Playwright cookie login
```
## Run
```bash
python -m adt_mcp # or: adt-mcp
# → http://127.0.0.1:8765 (MCP at /mcp, admin at /)
```
Environment variables (all optional):
| Variable | Default | Purpose |
| --- | --- | --- |
| `ADT_MCP_PORT` | `8765` | Port for MCP + web admin (`run.bat` follows it) |
| `ADT_MCP_HOME` | the checkout | Folder holding `systems.json`, `web/`, `cookies/` |
| `ADT_MCP_SYSTEMS` | `<home>/systems.json` | Explicit path to the systems config |
| `ADT_MCP_TOOLS` | `full` | `core` exposes only the essential tools |
| `ADT_MCP_BROWSER` | `chrome` | Browser channel for cookie login (`msedge`/`chromium`) |
| `ADT_MCP_CDP` | `http://127.0.0.1:9222` | Chrome DevTools endpoint for `mode: "cdp"` |
Set `ADT_MCP_HOME` when the package is installed non-editable or run as a
service from another working directory.
Open http://127.0.0.1:8765 to add SAP systems (URL, client, language, auth).
Config is stored in `systems.json` (gitignored). See `systems.example.json`.
Cookie systems can be (re)authenticated from the web admin via a browser login.
## Connect Claude Code
Add to `.mcp.json`:
```json
{
"mcpServers": {
"sap-adt": { "type": "http", "url": "http://127.0.0.1:8765/mcp" }
}
}
```
## Tools
Read / navigate:
- `list_systems`, `list_package`, `search_objects`, `get_object_info`
(package, active state, last change; no source)
- `get_source`, `get_source_by_uri`, `get_class_method_source`,
`get_class_include`, `get_object_structure`, `get_package_source`
(`get_source` with `object_type=MSAG` returns message class texts)
- `grep_package` (also scans class local includes), `find_references`
(where-used), `cds_dependencies`
- `get_context` (object + compressed dependencies: CDS/BDEF/CLAS)
- `get_revisions`, `get_revision_source`, `compare_source`
- `syntax_check`, `run_unit_tests` (ABAP Unit; filter by test class/method,
own timeout), `data_preview` (CDS/SQL data), `data_preview_batch` (many
previews in one call, results written as evidence files)
- `trace_start`, `trace_list`, `trace_analyze` (ABAP profiler: CPU hotspots + DB accesses)
- `list_dumps`, `get_dump` (ST22 runtime dumps: liệt kê + đọc chi tiết để phân tích lỗi)
Write (gated by safety, see below):
- `update_source`, `update_class_include`, `activate`
- `create_object` (CLAS / INTF / DDLS / DDLX / BDEF / SRVD / SRVB / TABL)
- `clone_package` (clone toàn bộ object của một package sang package đích, thêm suffix `_VN` + sửa tham chiếu chéo trong source; dry-run mặc định)
- `push_local` (đẩy một thư mục source local lên SAP theo phase: sync / check / activate / status, xem mục dưới)
Debug (gated by `allow_debug`, see below):
- `debug_set_breakpoint`, `debug_delete_breakpoint`, `debug_clear_breakpoints`
- `debug_listen`, `debug_poll`, `debug_stop_listener`
- `debug_attach`, `debug_detach`
- `debug_stack`, `debug_variables`, `debug_step`
- `run_class` (IF_OO_ADT_CLASSRUN — Eclipse's F9)
Cookie maintenance: `refresh_cookies_for`.
## Reading large classes
Do not read a large class in full. Get the outline first, then fetch only
the part you need:
```
get_source(system, object_type, name, to_file="C:\case\abap\ZCL_X.clas.abap")
→ writes the source to the file and returns only file names + outline.
A CLAS also writes its non-empty includes next to it, named like
abapGit: ZCL_X.locals_def / .locals_imp / .macros / .testclasses.abap.
Then Grep / Read only the part you need from the file.
A sidecar ZCL_X.clas.abap.json records system, object, time and per
file the SAP hash and change time. On the next download, a file named
in the old sidecar that is no longer produced (an include that became
empty) is deleted; a file not in it is never deleted (WARNING instead).
The path must not go through a symlink or junction.
get_source(system, "CLAS" | "INTF", name, section="public")
→ only the public declarations (class header + PUBLIC SECTION, or the
whole interface), comments removed. For method signatures of a
standard class/interface.
get_object_structure(system, class_name)
→ per part (main / local include): CLASS blocks and methods with line ranges
implementations (421 lines)
lhc_zi_mi902_01 DEFINITION 1-70
lhc_zi_mi902_01 IMPLEMENTATION 72-421
process 77-190
get_data 282-342
get_class_method_source(system, class_name, method, local_class?)
→ one METHOD…ENDMETHOD block, also from local classes (RAP lhc_*/lsc_*)
* lhc_zi_mi902_01=>get_data implementations:282-342
get_class_method_source(system, class_name, methods=["get_data", "send"])
→ several blocks from one fetch, separated by a blank line
get_class_include(system, class_name, include, start_line?, end_line?)
get_source(system, object_type, name, ..., start_line?, end_line?)
→ only that line range (1-based, inclusive), e.g. a DEFINITION block
```
- The outline skips includes that only hold the SAP template comment.
- A method implemented by several local classes (e.g. `validate` in
`lhc_a` and `lhc_b`) returns the candidates; call again with
`local_class`.
- Interface methods are named as in the source: `if_rap_query_provider~select`.
- Line numbers are per part, so they match `get_class_include` and the
header of `get_class_method_source`.
Measured on HAX: `ZBP_I_MI902_01` is 16,728 characters in full (main + 4
includes); its outline is 351 characters and one method (`get_data`) is
1,604. Measured on VNEXT with `section="public"`: `IF_BALI_LOG` 8,112 →
2,933 characters, `CL_BOTD_MOCKEMLAPI_BO_TEST_ENV` 10,295 → 495.
## Object info and unit test filters
```
get_object_info(system, object_type, name)
CLAS ZCL_MCP_DEBUG_DEMO ZCL_MCP_DEBUG_DEMO
package: ZVNEXT_003
status: active (or: inactive (a version is waiting for activation))
changed: 2026-10-02 10:24:37 +07:00 by CB9980000020
created: 2026-08-13 by CB9980000020
includes: (CLAS only) change time and user per include, INACTIVE flag
get_object_info(..., hashes=True)
→ also reads the source and adds hash=<source_hash> per include (or a
hash: line), the same hash as the to_file sidecar and push_local.
run_unit_tests(system, "CLAS", name, test_class="ltc_job_logic", method="t26_*", timeout=180)
```
- `test_class` / `method` (CLAS only) take names, `*` `?` patterns or comma
lists. Exact names go straight to SAP; patterns, or a method without a
test class, are resolved from the `testclasses` include first. `setup`,
`teardown`, `class_setup`, `class_teardown` never match a pattern.
- A filter that matches nothing lists the test methods that exist.
- `timeout` is in seconds (default 300, 10–3600). A run that times out on
our side may still finish on SAP.
## Data preview in batches
`data_preview_batch` runs up to 200 `data_preview` queries in one call
(4 in parallel; one by one with `stop_on_error=true`). It only reads SAP;
the only files it writes are inside `out_dir`.
```
data_preview_batch(system, out_dir?, queries=[...] | file="C:\\...\\q.json",
output="table" | "json" | "both", overwrite=False,
stop_on_error=False)
queries: [{"id": "find-open", "sql": "SELECT ... FROM I_SalesDocumentItem WHERE ...",
"max_rows": 5, "return_rows": 5}, # rows shown in the reply
{"id": "1-1/01", "sql": "SELECT ... FROM zv907t_vn WHERE ...",
"out": "1-1/01.txt"}] # written under out_dir
```
- `sql` is a SELECT or an entity name, with the same limits as
`data_preview` (one table/CDS, no JOIN). `max_rows` defaults to 5.
- `file` holds `{"queries": [...]}`. Passing `queries` inline saves the
agent one tool call.
- Input is checked before any SAP call: absolute `file`/`out_dir`, unique
`id`, `out` inside `out_dir` (no `..`, no absolute path, no two queries
on one file, case-insensitive), `return_rows <= max_rows`, at most 200.
- `overwrite=False`: a query whose `out` file exists is not run and its
file is kept.
- All queries run before any file is written. An expired SAP session writes
nothing; the server logs in again and reruns the whole batch.
Evidence file (UTF-8, LF; the format of the sap-evidence skill):
```
[data_preview] system VNEXT | 2026-10-02 12:02
SELECT SalesDocument FROM I_SalesDocumentItem
SALESDOCUMENT
0000000004
0000000005
(2 of 209 rows)
```
- Column names are as SAP returns them: upper case.
- Values are SAP's internal format: leading zeros kept, dates `20250422`.
Tabs and line breaks in a value become spaces, trailing blanks are
dropped, values over 200 characters are cut with `…`.
- Last line: `(1 row)`, `(N rows)`, or `(N of M rows)` when `max_rows` cut
the result. A `COUNT(*)` (aggregates without GROUP BY) is always `(1 row)`.
- A failing query with `out` still gets its file, with
`ERROR: <SAP message>` as line 3.
## Pushing a local case folder
`push_local` writes a folder of source files to SAP, one phase per call.
The folder holds `manifest.json` and the files it names:
```json
{"case": "VI907_VN", "system": "VNEXT", "package": "ZVNEXT_003",
"objects": [
{"type": "DDLS", "name": "ZI_VI907_01_VN", "action": "create", "phase": 1,
"description": "...", "files": {"main": "DDLS/ZI_VI907_01_VN.ddls"}},
{"type": "CLAS", "name": "ZBP_I_VI907_01_VN", "action": "create", "phase": 3,
"files": {"main": "CLAS/ZBP_I_VI907_01_VN.main.abap",
"implementations": "CLAS/ZBP_I_VI907_01_VN.implementations.abap"}},
{"type": "SRVB", "name": "ZSB_U4_VI907_01_VN", "action": "create", "phase": 5,
"service_definition": "ZSD_VI907_01_VN", "binding_version": "V4", "files": {}}
]}
```
- Types: TABL, DDLS, DDLX, DCLS, BDEF, SRVD, CLAS, INTF, PROG (source) and
SRVB (created only, no source). CLAS roles: main, definitions,
implementations, macros, testclasses.
- `action: create` creates the object when it is missing; `action: edit`
never creates. An existing object must be in the manifest's package.
- `purpose: "ut"` (optional) marks a unit test manifest; see `activate`.
- Objects run in manifest order. Pass exactly one of `phase` or
`objects: ["TYPE:NAME", ...]` (`status` also accepts neither = all objects;
`accept_sap` needs `objects`).
Modes:
- `sync` — create missing objects, write each file that differs from SAP as
**inactive**, then check the object with the local sources. Never activates.
Per object it refuses, writing nothing:
- an existing object with `action: create` that this case did not create
(no `created_by_this_manifest` mark for this system, package and case in
the state): change the manifest to `edit`;
- a part whose SAP source changed since its baseline (`sap_changed`), e.g.
an edit in Eclipse: merge it into the local file, or, after the user
agrees, run `accept_sap` for that object;
- a part of an existing object with no baseline yet: run `status` first.
Every part is checked before any is written, so a class is never half
written. The state is saved right after each part is written and read
back. `RESULT_JSON` lists `written_roles` per object.
- `check` — check local files of objects already on SAP; writes nothing.
SAP does not check an object that does not exist.
- `activate` — activate the scope in one call. Refused, with nothing sent to
SAP, unless the last sync checked clean and neither the files nor the SAP
source changed since (e.g. an edit in Eclipse). Activating a class also
activates its parts the manifest did not write, so it is also refused when
a class part not in the manifest has an inactive version that differs from
the active one (comment-only differences such as SAP's template do not
count). With `purpose: "ut"`, a listed part other than `testclasses` may
only differ in friend declarations (`CLASS ltc DEFINITION DEFERRED [FOR
TESTING]`, `... LOCAL FRIENDS ltc`, a `FRIENDS ltc` clause); other changes
are listed in the refusal.
- `status` — compare each local file with SAP's current source by hash;
writes nothing, prints no source. Per file: `same`, `local_changed`,
`sap_changed`, `both_changed` (from `.push-state.json`), `differs` (never
synced, so unknown which side), `missing`, and `extra_on_sap` for a class
include that has code on SAP but is not in the manifest (an include with
only SAP's template comment does not count). Per object: active or not,
package, last change time and user. If the object is inactive, it also
says whether the active version still differs. An object is `ok` only when
every entry is `same`; then the local files hold all of its code and can
be read instead of reading SAP again. `status` writes nothing to SAP, but
records SAP's current hash as the **baseline** of each file that has none
yet. It never moves an existing baseline, so an edit made on SAP after it
stays visible as `sap_changed`. Run it right after reading the reference
sources, before generating code.
- `accept_sap` — take SAP's current source as the new baseline of the named
objects, so the next `sync` writes the local files over it. Writes nothing
to SAP. Use it only after the user agreed to overwrite.
`output` (all modes): `both` (default) prints the table, `SUMMARY` and
`RESULT_JSON`; `table` leaves out `RESULT_JSON`; `json` prints only the
`RESULT_JSON` line.
Typical flow, per phase: `sync` → fix files until clean → `activate` → next
phase. A phase that uses objects of an earlier phase must wait until those
are active: SAP checks against active versions ("does not exist or is not
active").
Output is one line per object plus `SUMMARY` and a one-line `RESULT_JSON`;
errors point at the local file, e.g.
`DDLS/ZI_ZZPL_T01.ddls:7:6 E: The column NoSuchFieldZz is unknown`.
State is kept in `<dir>/.push-state.json`; a rerun after a failure repeats
nothing that already landed.
## Debugging
The tenant must expose the ADT debugger; check with
```
GET /sap/bc/adt/compatibility/graph → COM.SAP.ADT.DEBUGGER :: userRequestDebugging
```
Enable it per system in `systems.json` — it is **off by default**, separately
from `allow_write`, because `run_class` executes arbitrary ABAP:
```json
"allow_debug": true,
"debug_timeout": 600,
"debug_listen_seconds": 120
```
The flow, in order:
```
debug_set_breakpoint → statement="…" lets the server find the executable line
debug_listen → returns at once; the listener runs in the background
run_class → run the code WHILE the listener waits
debug_poll → state: listening → caught
debug_attach
debug_stack / debug_variables / debug_step
debug_detach → releases the debuggee and returns the run's output
```
`debug_variables` needs explicit names — read the source first. There is no
working "list what is in scope" call on this platform: `getChildVariables`
answers with `ME` alone whatever parent it is asked for.
Two things follow from how SAP works, not from choices made here:
- **Run the code while the listener is waiting.** A debuggee is only trapped by
a listener that was already registered. `debug_listen` therefore returns
immediately instead of blocking.
- **A breakpoint freezes the HTTP request that started the code.** `run_class`
runs in the background for that reason, and the console output only arrives
at `debug_detach` (or a later `debug_poll`).
### Debugging a Fiori app (RAP action, determination, validation)
Nothing here runs the code — you do, by clicking in the browser. An external
breakpoint is keyed on the ABAP *user*, so it traps any session of that user,
including the OData request the Fiori UI sends.
```
debug_set_breakpoint VNEXT CLAS ZBP_I_FLIGHT_013 \
include="implementations" statement="READ ENTITIES"
debug_listen VNEXT seconds=600 ← wide enough to switch to the browser
… now trigger the action in the Fiori app …
debug_poll VNEXT → state: caught
debug_attach / debug_stack / debug_variables / debug_step
debug_detach VNEXT → the OData request completes
```
`run_class` plays no part in this flow.
Two things decide whether it works:
- **`include="implementations"`.** RAP handler code (`lhc_…` classes, where
actions, determinations and validations live) is in the class's Local Types
include. A behavior pool's `/source/main` is an empty shell — a breakpoint
there never fires. The returned id shows where it really landed
(`…INCLUDE=…CCIMP.LINE_NR=131`).
- **Same ABAP user.** The Fiori session must run as the user in
`/sap/bc/adt/core/http/systeminformation`. A different login is a different
user and is never trapped.
While you sit at the breakpoint the OData request is held open, so the Fiori UI
spins and can hit its own gateway timeout — detach promptly. Leaving a
breakpoint armed with no listener is not dangerous, though: measured on the
tenant, the code simply runs straight through.
### Cost on cloud
The ABAP session *is* the `SAP_SESSIONID` cookie, so the debugger's isolated
channels each need their own login. The first `debug_listen` and the first
`run_class` on a system therefore take ~20–25s while a headless login runs;
afterwards the sessions are reused. Channel cookies land in
`cookies/<system>.debug.txt` / `.exec.txt` and are gitignored like the rest.
Breakpoints set here are **external** breakpoints keyed on the ABAP user, so
leaving one behind would pop the debugger open on a real session later. The
server deletes everything it set when it shuts down; `debug_clear_breakpoints`
does it on demand. The `ideId` is `adt-mcp`, distinct from Eclipse's, so your
IDE breakpoints are never touched.
## Write safety
Writes are **off by default**. Per system in `systems.json`:
- `allow_write: true` — required to enable any create/update.
- `write_packages: ["Z*", "$TMP"]` — target package must match (default).
Delete is intentionally **not** supported.
## Token economy
Tool schemas are sent to the model on every turn. Set `ADT_MCP_TOOLS=core`
to expose only the essential ~16 tools (smaller schema); default `full`
exposes all 29. Descriptions are kept terse.
```bash
ADT_MCP_TOOLS=core python -m adt_mcp
```
## Security
- `systems.json`, `cookies/`, `*-cookies.txt` hold session secrets and are
gitignored — never commit them.
- The server binds `127.0.0.1` only.
- **Stored passwords are plaintext.** A `username`/`password` is only kept to
enable headless cookie refresh (`refresh_cookies_for`). For real systems
prefer the cookie flows that store **no** password:
- `mode: "browser"` — log in once in a visible browser; only session cookies
are saved (the persistent profile keeps SSO so re-login is rare).
- `mode: "cdp"` — attach to your already-authenticated Chrome.
If you must keep a password, store it in an OS keyring / secrets manager and
inject it into `systems.json` at deploy time rather than committing it.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues