Skip to main content
Glama
README.md
# SAP-MCP

MCP server cho **SAP on-premise** qua ADT: một tiến trình chạy cả MCP endpoint
lẫn web admin, nối được nhiều hệ SAP cùng lúc, có debugger và công cụ chẩn đoán
runtime. Tên tool theo quy ước PascalCase của vibing-steampunk, cộng thêm mô
hình đa hệ thống và trang quản trị.

Không cần cài object ABAP nào lên SAP để dùng — ngoại lệ duy nhất là
`RunReport`, và server tự cài giúp (xem mục Nhóm D).

## Cài đặt

**Windows** — nhấp đúp `install.bat`, hoặc chạy trong terminal:

```bat
install.bat
```

Nó kiểm tra Python 3.10+, tạo `.venv`, cài phụ thuộc, và tạo `systems.json` từ
file mẫu. Sau đó mở `systems.json` điền URL / user / password hệ SAP thật rồi
chạy `run.bat`.

**Nền tảng khác:**

```bash
python -m venv .venv && . .venv/bin/activate
pip install -e .
cp systems.example.json systems.json   # sửa URL, user, password
python -m sap_mcp
```

`systems.json` chứa mật khẩu và đã nằm trong `.gitignore` — đừng commit nó.

## Chạy server

**Windows** — nhấp đúp `run.bat`, hoặc:

```bat
run.bat              :: cổng 8765, chế độ focused (50 tool)
run.bat 8766         :: đổi cổng
run.bat 8766 expert  :: đổi cổng + bật đủ 66 tool
```

`run.bat` tự đặt console về UTF-8 (log có tiếng Việt, console cp1252 sẽ làm
Python chết), tự tạo `systems.json` từ file mẫu nếu chưa có, và báo rõ PID
nào đang chiếm cổng thay vì để uvicorn ném lỗi socket thô.

Mở http://127.0.0.1:8765 để thêm/sửa/test hệ thống. MCP endpoint ở `/mcp`.

## Nối vào MCP client

Chép `mcp.example.json` thành `.mcp.json` ở thư mục dự án, hoặc gộp phần
`mcpServers` vào file cấu hình sẵn có của client:

```json
{
  "mcpServers": {
    "sap-mcp": {
      "type": "http",
      "url": "http://127.0.0.1:8765/mcp"
    }
  }
}
```

Sửa cổng cho khớp nếu bạn chạy `run.bat` với cổng khác. Server phải đang chạy
trước khi client kết nối — đây là transport `streamable-http`, client không
tự khởi động tiến trình như kiểu `stdio`.

## Cấu hình hệ thống (`systems.json`)

| Trường | Mặc định | Ý nghĩa |
|---|---|---|
| `url` | — | `https://host:port` của hệ SAP |
| `client` | `100` | `sap-client` |
| `language` | `EN` | `sap-language` |
| `username` / `password` | — | Basic auth |
| `verify_ssl` | `true` | Đặt `false` cho cert tự ký |
| `ca_bundle` | — | Đường dẫn CA riêng (thay cho `verify_ssl`) |
| `timeout` | `30` | Trần HTTP thường, giây |
| `allow_write` | `false` | Bật mới ghi được |
| `write_packages` | `["Z*","Y*","$TMP"]` | Package được phép ghi |
| `write_objects` | — | Giới hạn thêm theo tên object |
| `require_transport` | `true` | Package transportable bắt buộc có TR |
| `allow_debug` | `false` | Bật mới dùng được nhóm D (debugger + chạy code) |
| `debug_timeout` | `1800` | Được dừng ở breakpoint bao lâu, giây |
| `debug_listen_seconds` | `300` | `DebuggerListen` chờ mặc định, giây |

Object trong namespace SAP chuẩn **luôn** bị từ chối, không cấu hình tắt được.

**`debug_timeout` không phải chỉ là con số cho dễ chịu.** Code dừng ở breakpoint
giữ luôn request HTTP đã chạy nó, nên `timeout` thường (30s) cắt ngang giữa lúc
bạn đang xem biến: luồng chạy nền chết và kết quả report mất trắng, `DebuggerDetach`
trả về `The read operation timed out` thay vì dữ liệu. Trần này chỉ được nới **khi
đang debug** (có listener, hoặc đang dừng ở debuggee) — nới cho mọi lần chạy thì
một report treo sẽ ôm một work process của SAP nửa tiếng mà không ai nhìn.

## Biến môi trường

| Biến | Mặc định | Ý nghĩa |
|---|---|---|
| `SAP_MCP_SYSTEMS` | `./systems.json` | Đường dẫn file cấu hình |
| `SAP_MCP_PORT` | `8765` | Cổng HTTP |
| `SAP_MCP_MODE` | `focused` | `focused` (50 tool) hoặc `expert` (66 tool) |
| `SAP_MCP_DISABLED_GROUPS` | — | Tắt vùng tính năng, ví dụ `C` hoặc `C,D` |

Mã nhóm: `C` transport request, `D` debugger, `P` chẩn đoán runtime (dump,
trace) — xem mục Tool bên dưới. Tool lõi không thuộc nhóm nào và luôn được bật.
Tắt cả hai (`SAP_MCP_DISABLED_GROUPS=D,P`) đưa focused/expert về lại 30/45 tool.

## Tool

**Quản trị** `ListSystems` `GetConnectionInfo` `GetSystemInfo`

**Đọc** `GetSource` `GetObjectStructure` `GetClassInfo` `GetPackage`
`GetFunctionGroup` `SyntaxCheck`
· *expert:* `GetProgram` `GetClass` `GetInterface` `GetInclude` `GetFunction`
`GetClassInclude`

`GetSource` đọc được **một đoạn** thay vì cả object: `around="SELECT"` lấy cửa
sổ quanh lần xuất hiện đầu tiên ngoài chú thích, hoặc `from_line`/`to_line` lấy
đúng khoảng dòng. Đoạn nào cũng mở đầu bằng một dòng chú thích ghi rõ nó là
đoạn — chỉ bản đầy đủ mới đem đi `UpdateSource` được, ghi đè bằng một cửa sổ là
xoá phần còn lại. `GetPackage` có trần `max_objects` và nói rõ khi đã cắt.

**Tìm** `SearchObject` `GrepObjects` `GrepPackages`
· *expert:* `GrepObject` `GrepPackage`

**Dữ liệu** `RunQuery` `GetTableContents`

`RunQuery` chạy Open SQL **SELECT** và trả bảng kết quả; `GetTableContents` dựng
câu SELECT giúp bạn. Không cần `allow_write` vì **chính SAP** từ chối lệnh ghi ở
endpoint này:

```
DELETE FROM t001 …  → 400 Invalid query string. Only SELECT statement is allowed.
```

Hàng rào cho việc ĐỌC là quyền của user SAP trong `systems.json` — mọi bảng user
đó đọc được thì agent cũng đọc được, kể cả bảng nhân sự. Đừng cấu hình một user
vạn năng.

**Điều hướng mã nguồn** `FindDefinition` `FindReferences`

`FindDefinition(system, 'CLAS', 'ZCL_X', symbol='cl_salv_bs_runtime_info')` —
server tự tìm ký hiệu trong source (bỏ qua chú thích) rồi giải tại đó, trả về
loại, tên và danh sách thành phần. Không dùng `navigation/target` dù tên nghe
hợp lý hơn: nó trả lại **chính uri đầu vào** khi không giải được, tức thành
công giả. Đường dùng được là `abapsource/codecompletion/elementinfo`, và nó đòi
toàn bộ source trong body.

`FindReferences` trả where-used. SAP trả về một cây trộn ba loại nút; chỉ mục
có `gradeDirect` mới là chỗ dùng thật. `gradeComponent` là thành phần của
chính object đang tra — đếm vào thì một class không ai gọi bỗng có 6 chỗ dùng.

**Ghi** `WriteSource` `EditSource` `Activate` `ActivatePackage` `CreatePackage`
`GetInactiveObjects` `LockObject` `UnlockObject`
· *expert:* `CreateObject` `UpdateSource` `DeleteObject`

**Tổng hợp** `CompareSource` `CloneObject` `PrettyPrint` `ImportFromFile`
`ExportToFile`

**Nhóm C** `ListTransports` · *expert:* `GetTransport` `CreateTransport`
`ReleaseTransport` `DeleteTransport`

**Nhóm D — debugger** `SetBreakpoint` `DeleteBreakpoint` `DebuggerListen`
`DebuggerPoll` `DebuggerStopListener` `DebuggerAttach` `DebuggerDetach`
`DebuggerGetStack` `DebuggerGetVariables` `DebuggerStep` `RunClass`
`RunReport` `RunUnitTests`

Cần `allow_debug: true`. Trình tự dùng:

1. `SetBreakpoint` — dòng phải là câu lệnh **thực thi**, không phải khai báo.
   Không cần đếm dòng: `statement="SELECT"` để server tự tìm (nó bỏ qua chú
   thích nên không rơi vào dòng không thực thi được) và báo lại số dòng.
2. `DebuggerListen` — trả về ngay, listener chạy nền
3. `RunClass` / `RunReport` / `RunUnitTests` — chạy code
4. Nếu breakpoint nổ, bước 3 trả về ngay `Đã dừng ở breakpoint …` (không phải
   dữ liệu). `DebuggerPoll` cho biết trạng thái bất cứ lúc nào.
5. `DebuggerAttach` → `DebuggerGetStack` / `DebuggerGetVariables` / `DebuggerStep`
6. `DebuggerDetach` — thả debuggee; code chạy nốt và **kết quả của bước 3 trả
   về ở đây** (hoặc ở `DebuggerPoll` nếu nó chạy lâu)

Không có breakpoint nào nổ thì bước 3 trả thẳng kết quả như một tool thường.

**Vì sao ba tool chạy code lại chạy ở nền.** Khi code dừng ở breakpoint, SAP
giữ luôn request HTTP đang chạy nó — lời gọi chỉ trả về sau khi debuggee được
thả. Gọi đồng bộ thì chính tool đó treo và agent không bao giờ gọi được
`DebuggerAttach` để thả nó ra: tự khoá chính mình. Ba tool này vì thế chạy trên
một session riêng ở luồng nền và trả lời ngay khi listener bắt được debuggee.

Mỗi hệ dùng ba session HTTP tách biệt khi debug: một cho listener + phiên debug
(stateful, bị giữ hàng chục giây), một để chạy code (có thể bị chặn tới lúc thả
debuggee), một để đặt/xoá breakpoint. Không tách thì chúng chặn lẫn nhau: chạy
code trên session của listener chỉ chen được vào khe giữa hai vòng long-poll —
đúng lúc SAP không có listener nào đăng ký, nên breakpoint không bao giờ nổ.

**Debug report có selection-screen.** External breakpoint **không bắt được
phiên dialog** — bấm F8 trong SE38 thì debugger không thấy gì (đã đo trên hệ
thật). Dùng `RunReport` thay cho `RunClass` ở bước 3: nó chạy report trong
một phiên external, nên breakpoint mới nổ.

`RunReport` chặn ALV hiển thị mà vẫn lấy được dữ liệu
(`cl_salv_bs_runtime_info`), nên report kết thúc bằng ALV không dump giữa
chừng. Nhận cả `PARAMETERS` lẫn `SELECT-OPTIONS` (tên tham số bắt đầu bằng
`S_`) và variant.

`RunReport` **ghi vào SAP**, nên nó cần cả `allow_write` lẫn `allow_debug`,
không chỉ `allow_debug` như các tool debugger còn lại. Server tự cài hai object
vào `$TMP`, bạn không phải làm gì:

- `ZCL_MCP_RUNNER` — class trung gian, **tổng quát và không bao giờ bị sửa**.
  Nó `SUBMIT (mv_report) WITH SELECTION-TABLE mt_sel`, tức tên report và toàn
  bộ selection-screen đều là dữ liệu lúc chạy.
- `ZMCP_RUNNER_ARGS` — chương trình chỉ gồm dòng chú thích, bị ghi lại trước
  mỗi lần chạy. Class đọc nó lúc chạy bằng `READ REPORT`.

```abap
*@MCP TOKEN 24b8bff8dfb8477b
*@MCP REPORT ZPG_DEMO
*@MCP MAX 100
*@MCP SEL S_BUKRS S I BT
*@MCP LOW 1000
*@MCP HIGH 2000
```

Vì sao vẫn phải ghi: `IF_OO_ADT_CLASSRUN~MAIN( out )` không nhận tham số nào —
không query param, không body. Source của một object là kênh truyền tham số
duy nhất ADT REST mở ra.

Hệ quả quan trọng nhất là **an toàn**: không có thứ gì do agent cung cấp trở
thành mã ABAP nữa. Bản trước nhúng giá trị lọc vào literal ABAP, nên một dấu
nháy lọt qua là chèn được lệnh tuỳ ý vào hệ SAP — chỗ đó phải escape mới an
toàn. Giờ giá trị nằm trên dòng chú thích và tới SAP qua bảng `RSPARAMS`, nên
không còn cú pháp nào để phá. Chỉ ký tự xuống dòng bị cấm (nó đẻ ra dòng tham
số giả), và giá trị dài quá 45 ký tự bị từ chối vì `RSPARAMS-LOW` là `CHAR45`
— SAP sẽ cắt cụt trong im lặng, tức lọc sai mà không ai biết.

Mỗi lần chạy mang một token; class trả lại token đó và server đối chiếu. Ghi
tham số hỏng mà vẫn chạy tiếp thì report chạy bằng tham số cũ rồi kết quả được
gắn nhãn của lần hỏi mới — token là thứ chặn kiểu sai lặng lẽ đó.

So với vibing-steampunk (đòi plugin `ZADT_VSP`: 1 interface, 3 class, WebSocket
handler), `RunReport` cần ít hơn và không phải cấu hình SAPC + SICF thủ công:

| | vsp (`ZADT_VSP`) | SAP-MCP (`RunReport`) |
|---|---|---|
| Object ABAP phải cài | 4 | 2 |
| Cấu hình SAPC + SICF | cần basis admin | không |
| Server tự cài được | không | có |
| Class có sửa mỗi lần chạy | không | không |
| SELECT-OPTIONS | không (hardcode `kind='P'`) | có |
| Ghi vào SAP mỗi lần chạy | không | có (một file chú thích) |

Dòng cuối là cái giá của việc không cần admin cài đặt gì: vsp truyền tham số
qua WebSocket nên không đụng vào hệ, `RunReport` truyền qua source vì ADT REST
không mở kênh nào khác. Đổi lại, object bị ghi là một file chỉ gồm chú thích —
nó không có cú pháp để hỏng, và class chứa logic thì đứng yên.

**Nhóm P — chẩn đoán runtime** `ListDumps` `GetDump` `StartTrace` `ListTraces`
`GetTrace` `DeleteTrace` `GetSQLTraceState` · *expert:* `DeleteTraceRequest`

**Short dump (ST22).** `ListDumps` lọc theo `user`/`error`/`program`/`since`,
`GetDump` trả `summary` (chuyện gì xảy ra, phân tích lỗi, chỗ dừng, call
stack), `source` (mã nguồn tại chỗ chết), `full`, hoặc `meta`.

**Đo hiệu năng (SAT/ATRA).** `StartTrace('ZPG_X', 'report')` → chạy code →
`ListTraces` → `GetTrace`. `GetTrace` mặc định trả hồ sơ thời gian theo lời
gọi, sắp giảm dần; `view='db'` trả truy cập CSDL theo bảng — số lần, số lần
lấy từ buffer, thời gian. `RunReport(..., trace=True)` làm gọn cả chuỗi: nó tự
đặt yêu cầu đo giới hạn đúng report đó.

```
   NET µs      %  GROSS µs   LẦN  GỌI TỪ                     VIỆC
     3800   51.2      3800     1  CL_HTTP_SERVER_NET=======C DB: Exec Static
      368    5.0      4185     1  SAPLHTTP_RUNTIME           Call M. …SEND_RESPONSE
```

Ba điều đã **đo trên NW 758**, ngược với những gì vibing-steampunk giả định —
mỗi cái đều làm hỏng tool một cách im lặng nếu làm theo:

| | vsp làm | đo được trên NW 758 |
|---|---|---|
| Accept của feed dump | `application/atom+xml` | **406** — phải là `…;type=feed` |
| Lọc dump | gửi `$filter` FQL | SAP **bỏ qua**, trả nguyên danh sách |
| ST05 `trace/directory` | đọc như feed trace | trả **một URL Fiori**, không có bản ghi |

Nên `ListDumps` lọc ở phía server MCP, và câu SQL lấy từ `dbAccesses` của ABAP
trace chứ không từ ST05. `GetSQLTraceState` vẫn hữu ích để phát hiện một trace
bị bỏ quên ở trạng thái bật — nó làm chậm cả hệ mà nhìn từ ngoài không thấy gì.

**`StartTrace` bắt buộc có tên object.** Một yêu cầu trace không giới hạn sẽ
tóm ngay chính lời gọi HTTP vừa tạo ra nó: bản đo thu được toàn
`ICFSERVICE`/`HTTP_HEADER_REG` — đo bộ máy ADT chứ không đo code của bạn — mà
nhìn vào vẫn ra một bảng số liệu trông rất thật. Có giới hạn thì bản đo rơi
đúng vào lần chạy sau đó (`T001`, `DDFTX`, `VARID`…).

`StartTrace`, `DeleteTrace`, `DeleteTraceRequest` cần `allow_debug`: chúng đổi
hành vi của hệ, và một yêu cầu bỏ quên sẽ đo trộm một lần chạy về sau. Ba tool
đọc (`ListDumps`, `GetDump`, `GetSQLTraceState`) thì không cần gì.

## Trạng thái

Nhóm tool lõi, nhóm D (debugger, gồm cả `RunReport`) và nhóm P (dump + trace)
hoàn tất — 50 tool ở chế độ focused / 66 ở expert, đã chạy trên NetWeaver 758.
Phần DDIC/i18n, abapGit và ABAP helper chưa làm.

## Giới hạn đã biết

**1. `ImportFromFile` / `ExportToFile` không giới hạn đường dẫn.** Hai tool
này nhận bất kỳ path nào mà model đưa vào. `import_from_file` chỉ kiểm tra
`os.path.isfile`, `export_to_file` chỉ kiểm tra `os.path.isdir` — không có
allowlist, không giới hạn về một thư mục workspace, không chặn `..` hay
đường dẫn tuyệt đối. Vì vậy một agent — kể cả agent bị ảnh hưởng bởi nội
dung nó đọc được từ SAP — có thể đọc bất kỳ file nào mà tiến trình server
đọc được rồi đưa vào SAP, hoặc ghi source SAP ra bất kỳ path nào ghi được.
Cách giảm rủi ro hiện tại: chỉ chạy server trên máy bạn kiểm soát, dưới
một account không có quyền truy cập file nào ngoài những gì bạn muốn agent
có.

**2. Route REST admin không có xác thực.** `/` và `/api/systems*` chỉ được
bảo vệ bằng cách bind vào `127.0.0.1`. Bất cứ thứ gì tới được loopback ở
cổng đó đều có thể liệt kê, thêm, sửa, xoá cấu hình hệ thống và kích hoạt
test kết nối. Đừng mở cổng này ra ngoài máy cục bộ, và đừng chạy nó trên
một host dùng chung.

**3. Debugger đọc được mọi biến trong bộ nhớ.** `DebuggerGetVariables` trả về
giá trị thật tại điểm dừng, gồm cả dữ liệu nhạy cảm đang nằm trong biến —
mật khẩu, khoá, dữ liệu cá nhân. Đây là bản chất của debug, không phải lỗi.
Kèm theo đó `RunClass` thực thi ABAP tuỳ ý. Vì vậy `allow_debug` mặc định tắt
và nên chỉ bật trên hệ phát triển.

**4. `RunReport` dùng chung một file tham số.** `ZMCP_RUNNER_ARGS` trong `$TMP`
bị ghi lại trước mỗi lần chạy. Trong một server thì các lần chạy đã được xếp
hàng sẵn (một kênh thực thi cho mỗi hệ), nhưng hai server hoặc hai người cùng
dùng một hệ vẫn đè tham số của nhau. Token trong output phát hiện được chuyện
đó và biến nó thành lỗi, chứ không để trả về dữ liệu sai. vibing-steampunk né
hẳn bằng một WebSocket APC riêng cho mỗi phiên, đổi lại là phải cấu hình SAPC
+ SICF bằng tay.

## Kiến trúc

`transport/` (HTTP, auth, CSRF) → `adt/` (object type, trả dữ liệu) →
`tools/` (format + đăng ký MCP). Bảng `adt/uri.py` là nguồn duy nhất dựng URI.
Nhóm D dùng thêm ba `AdtSession` mỗi hệ (`transport/debug_pool.py`, chia theo
kênh) vì listener chạy nền, phiên debug phải giữ trạng thái liên tục qua nhiều
lời gọi, và code đang chạy có thể bị chặn ở breakpoint — không session nào
trong số đó mượn được từ `SessionPool` dùng chung.

**Tool chạy ở worker thread, không trên event loop.** FastMCP gọi thẳng hàm
đồng bộ trên event loop, nên nếu để nguyên thì một lời gọi SAP chặn cả server:
agent không gọi nổi `DebuggerPoll` trong lúc `RunReport` đang chờ, hai hệ khác
nhau chặn lẫn nhau, và web admin đứng hình. `tools/_registry.py` bọc mọi tool
bằng `anyio.to_thread.run_sync` trước khi đăng ký. Việc tuần tự hoá theo từng
hệ vẫn còn nguyên và vẫn là chủ ý — nó nằm ở `SessionPool`, vì lock handle của
SAP chỉ hợp lệ trên một connection cho mỗi hệ.