Stage 0: SAP(ZAA_ICF)에서 패키지 단위로 프로그램 소스 수집 (ingest/from_sap.py)

- sap/: abap-mcp 의 catalog/sap_client/mcp_server 복사. import 와 .env 탐색만 이 저장소에 맞춤
- ingest/from_sap.py: 패키지명 → GET_PROGRAM_LIST → GET_PROGRAM_SOURCE(+Include) → data/raw/*.txt
  (normalize 가 읽는 수집 JSON 형식 그대로). 받은 파일은 건너뛰고 --force 로 재수집
- normalize: 응답의 TCODE_LIST 를 tcodes.jsonl 로 (from_dir 와 같은 모양, 로더가 적재)
- .env.example 에 SAP_URL/SAP_USER/SAP_PASS, tests/test_from_sap.py

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
byeongwook.choi
2026-09-21 14:13:14 +09:00
co-authored by Claude Fable 5.1
parent 11ae3629b2
commit 921b8ce229
13 changed files with 1595 additions and 1 deletions
+7
View File
@@ -23,6 +23,13 @@ EMBED_BASE_URL=
EMBED_API_KEY= EMBED_API_KEY=
EMBED_MODEL= EMBED_MODEL=
# SAP 접속 (Stage 0: python -m ingest.from_sap <패키지>) — sap/README.md
# 고객사 SAP 의 ZAA_ICF 서비스 주소. ZCL_ZAA_AGENT_API 가 설치돼 있어야 한다
SAP_URL=https://<host>:<port>/sap/bc/ZAA_ICF
SAP_USER=
SAP_PASS=
# SAP_VERIFY_SSL=1 # 사내 인증서 검증을 켜려면 (기본 꺼짐)
# 질의 API 서버 # 질의 API 서버
INDEX_HOST=127.0.0.1 INDEX_HOST=127.0.0.1
INDEX_PORT=8100 INDEX_PORT=8100
+1
View File
@@ -18,6 +18,7 @@ ABAP 소스코드 인덱싱 서버. 약 1만 본의 ABAP 프로그램 소스와
## 파이프라인 ## 파이프라인
``` ```
python -m ingest.from_sap ZFI01 # Stage 0: SAP(ZAA_ICF)에서 패키지의 프로그램 소스 → data/raw (sap/ = abap-mcp 복사본)
data/raw/*.txt (수집 JSON) data/raw/*.txt (수집 JSON)
→ python -m ingest.normalize # Stage 1: 줄바꿈 아티팩트 제거, 프로그램/인클루드 파일화 → python -m ingest.normalize # Stage 1: 줄바꿈 아티팩트 제거, 프로그램/인클루드 파일화
→ python -m parser.run # Stage 2: unit·참조·데이터플로우·호출그래프·정의부 (LLM 없이) → python -m parser.run # Stage 2: unit·참조·데이터플로우·호출그래프·정의부 (LLM 없이)
+231
View File
@@ -0,0 +1,231 @@
"""Stage 0 — SAP 에서 패키지 단위로 프로그램 소스를 받아 data/raw/ 에 저장한다.
abap-mcp 의 SAP 클라이언트(sap/sap_client.py, ZAA_ICF = ZCL_ZAA_AGENT_API)를 그대로 쓴다.
저장 형식은 `ingest/normalize.py` 가 읽는 수집 JSON 과 같다 — 그래서 받은 뒤엔 평소대로
`python -m ingest.normalize` 부터 돌리면 된다.
python -m ingest.from_sap ZFI01 # 패키지 하나
python -m ingest.from_sap ZFI01 ZCO01 ZSD01 # 여러 개
python -m ingest.from_sap ZFI01 --limit 3 # 시험: 프로그램 3본만
python -m ingest.from_sap ZFI01 --force # 이미 받은 파일도 다시 받기
python -m ingest.from_sap --pattern "ZFIR1*" # 패키지 대신 이름 패턴
접속 정보는 `.env` 의 SAP_URL / SAP_USER / SAP_PASS (sap/README.md). 고객사 SAP 에
ZAA_ICF 서비스(ZCL_ZAA_AGENT_API)가 설치돼 있어야 한다.
만드는 파일 (기본 data/raw/):
<PACKAGE>.txt 프로그램 목록 [{DEVCLASS, OBJ_NAME, TEXT, CREATED_ON, CHANGED_ON}]
<PROGRAM>.txt 프로그램 소스 {MAIN_PROGRAM, DESCRIPTION, TEXT_SYMBOL: [],
INCLUDE_PROGRAM: [{INCLUDE, SOURCE_CODE}], TCODE_LIST: [...]}
_errors.log 실패한 프로그램 (계속 진행하고 여기 기록)
- SAP 목록 API 는 SUBC 를 생략하면 실행 프로그램(1)과 모듈 풀(M)만 준다. Include 는 각 프로그램의
INCLUDE_LIST 로 따라오므로 따로 받지 않는다.
- 텍스트 심볼(TEXT-nnn)은 이 API 에 없다 → TEXT_SYMBOL 은 빈 배열 (from_dir 와 같은 한계).
- 이미 받은 프로그램(.txt 존재)은 건너뛴다. 다시 받으려면 --force.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
def _log(msg: str) -> None:
print(msg, file=sys.stderr, flush=True)
def _call(method: str, params: dict, retries: int = 3, timeout: int = 180) -> dict:
"""sap_call + 네트워크 오류 재시도. 결과는 sap_client.sap_call 과 같은 dict."""
from sap.sap_client import sap_call
last: dict = {}
for attempt in range(1, retries + 1):
last = sap_call(method, params, timeout=timeout)
# error 는 네트워크/인증/파싱 실패. RETURN.TYPE=E 는 SAP 이 정상 응답한 것이라 재시도 안 함
if not last.get("error"):
return last
_log(f" ! {method} {params} 실패({attempt}/{retries}): {last['error']}")
if attempt < retries:
time.sleep(2 * attempt)
return last
def list_programs(package: str | None = None, pattern: str | None = None,
subc: str | None = None) -> list[dict]:
"""GET_PROGRAM_LIST → [{OBJ_NAME, TEXT, SUBC, UDAT, UNAM, ...}]"""
params = {"IV_PACKAGE": package or "", "IV_PATTERN": pattern or "", "IV_SUBC": subc or ""}
res = _call("GET_PROGRAM_LIST", params)
if res.get("error"):
raise RuntimeError(res["error"])
ret = res["return"]
if ret["type"] == "E":
raise RuntimeError(f"SAP 오류: {ret['message']}")
rows = (res.get("parsed") or {}).get("RESULT") or []
if ret["type"] == "W":
_log(f" ! 목록이 잘렸습니다: {ret['message']}")
return [r for r in rows if isinstance(r, dict) and r.get("OBJ_NAME")]
def fetch_program(name: str, description: str = "") -> dict:
"""GET_PROGRAM_SOURCE(+Include) → 수집 JSON 모양의 payload. 실패하면 RuntimeError."""
res = _call("GET_PROGRAM_SOURCE", {"IV_PROGRAM": name, "IV_WITH_INCLUDE": "X"})
if res.get("error"):
raise RuntimeError(res["error"])
ret = res["return"]
if ret["type"] == "E":
raise RuntimeError(f"SAP 오류: {ret['message']}")
data = res.get("parsed") or {}
main_code = data.get("SOURCE_CODE") or ""
if not main_code.strip():
raise RuntimeError("소스가 비어 있음")
program = (data.get("PROGRAM") or name).strip().upper()
includes = [{"INCLUDE": program, "SOURCE_CODE": main_code}]
seen = {program}
for inc in data.get("INCLUDE_LIST") or []:
if not isinstance(inc, dict):
continue
inc_name = (inc.get("INCL_NAME") or "").strip().upper()
code = inc.get("SOURCE") or ""
# 이름 없음 / 빈 소스(권한 없음·삭제됨) / 중복은 뺀다
if not inc_name or inc_name in seen or not code.strip():
continue
seen.add(inc_name)
includes.append({"INCLUDE": inc_name, "SOURCE_CODE": code})
tcodes = [
{"TCODE": (t.get("TCODE") or "").strip().upper(), "TTEXT": t.get("TTEXT") or ""}
for t in data.get("TCODE_LIST") or [] if isinstance(t, dict) and t.get("TCODE")
]
return {
"MAIN_PROGRAM": program,
"DESCRIPTION": description or "",
"TEXT_SYMBOL": [],
"INCLUDE_PROGRAM": includes,
"TCODE_LIST": tcodes,
}
def package_rows(package: str, rows: list[dict]) -> list[dict]:
"""프로그램 목록 → normalize 가 packages.jsonl 로 만드는 package_list 모양."""
return [
{
"DEVCLASS": package.upper(),
"OBJ_NAME": r["OBJ_NAME"].upper(),
"TEXT": r.get("TEXT") or "",
"CREATED_ON": "",
"CHANGED_ON": r.get("UDAT") or "",
}
for r in rows
]
def _write_json(path: Path, obj) -> None:
# 한 줄 JSON. normalize 는 "줄바꿈+패딩 → 공백" 치환을 먼저 하므로 들여쓰기(indent)를 쓰면 안 된다.
path.write_text(json.dumps(obj, ensure_ascii=False), encoding="utf-8", newline="\n")
def run(packages: list[str], out_dir: Path, pattern: str | None = None, subc: str | None = None,
limit: int | None = None, force: bool = False, dry_run: bool = False) -> dict:
out_dir.mkdir(parents=True, exist_ok=True)
stats = {"packages": 0, "listed": 0, "fetched": 0, "skipped": 0, "errors": 0, "includes": 0}
errors: list[str] = []
targets: list[tuple[str, str]] = [] # (program, description)
if packages:
for pkg in packages:
pkg = pkg.strip().upper()
if not pkg:
continue
_log(f"[{pkg}] 프로그램 목록 조회")
try:
rows = list_programs(package=pkg, pattern=pattern, subc=subc)
except Exception as e: # noqa: BLE001
stats["errors"] += 1
errors.append(f"{pkg}\tLIST\t{e}")
_log(f" ! 목록 실패: {e}")
continue
stats["packages"] += 1
_log(f" 프로그램 {len(rows)}")
if not dry_run:
_write_json(out_dir / f"{pkg}.txt", package_rows(pkg, rows))
targets += [(r["OBJ_NAME"].upper(), r.get("TEXT") or "") for r in rows]
else:
_log(f"[패턴 {pattern}] 프로그램 목록 조회")
rows = list_programs(pattern=pattern, subc=subc)
_log(f" 프로그램 {len(rows)}")
targets += [(r["OBJ_NAME"].upper(), r.get("TEXT") or "") for r in rows]
# 같은 프로그램이 두 패키지 목록에 있을 일은 없지만 안전하게 중복 제거
uniq: dict[str, str] = {}
for name, desc in targets:
uniq.setdefault(name, desc)
targets = list(uniq.items())
if limit:
targets = targets[:limit]
stats["listed"] = len(targets)
for i, (name, desc) in enumerate(targets, 1):
path = out_dir / f"{name}.txt"
if path.exists() and not force:
stats["skipped"] += 1
continue
if dry_run:
_log(f" ({i}/{len(targets)}) {name} {desc}")
continue
try:
payload = fetch_program(name, desc)
except Exception as e: # noqa: BLE001 — 실패 기록 후 계속
stats["errors"] += 1
errors.append(f"{name}\tSOURCE\t{e}")
_log(f" ({i}/{len(targets)}) {name} 실패: {e}")
continue
_write_json(path, payload)
stats["fetched"] += 1
stats["includes"] += len(payload["INCLUDE_PROGRAM"])
_log(f" ({i}/{len(targets)}) {name} include {len(payload['INCLUDE_PROGRAM'])} {desc}")
if errors and not dry_run:
with (out_dir / "_errors.log").open("a", encoding="utf-8") as f:
f.write("\n".join(errors) + "\n")
return stats
def main() -> None:
ap = argparse.ArgumentParser(
description="Stage 0 — SAP(ZAA_ICF) 에서 패키지의 프로그램 소스를 data/raw 로 받는다",
epilog="받은 뒤: python -m ingest.normalize && python -m parser.run && python -m index.loader",
)
ap.add_argument("packages", nargs="*", help="패키지명 (예: ZFI01). 여러 개 가능")
ap.add_argument("--pattern", default=None, help="프로그램명 패턴 (예: ZFIR1*). 패키지와 같이 쓰면 그 안에서 필터")
ap.add_argument("--subc", default=None, help="프로그램 유형. 생략=1(실행)+M(모듈풀). 1/M/I/F/K")
ap.add_argument("--out", default=None, help="저장 디렉토리 (기본 data/raw)")
ap.add_argument("--limit", type=int, default=None, help="받을 프로그램 수 상한 (시험용)")
ap.add_argument("--force", action="store_true", help="이미 받은 파일도 다시 받는다")
ap.add_argument("--dry-run", action="store_true", help="목록만 보고 저장하지 않는다")
args = ap.parse_args()
if not args.packages and not args.pattern:
ap.error("패키지명 또는 --pattern 중 하나는 있어야 합니다")
from config.settings import settings
from sap.sap_client import CONFIG
if not CONFIG["user"] or not CONFIG["password"]:
print("SAP_USER / SAP_PASS 가 비어 있습니다 — .env 에 채우세요 (sap/README.md)", file=sys.stderr)
sys.exit(1)
_log(f"SAP: {CONFIG['base_url']} 사용자 {CONFIG['user']}")
out_dir = Path(args.out) if args.out else settings.data_raw
stats = run(args.packages, out_dir, pattern=args.pattern, subc=args.subc,
limit=args.limit, force=args.force, dry_run=args.dry_run)
print(json.dumps(stats, ensure_ascii=False))
if stats["errors"]:
print(f"실패 {stats['errors']}건 → {out_dir / '_errors.log'}", file=sys.stderr)
if __name__ == "__main__":
main()
+10
View File
@@ -95,6 +95,7 @@ def run(raw_dir: Path, out_dir: Path, limit: int | None = None, dry_run: bool =
stats = {"files": 0, "programs": 0, "includes": 0, "package_rows": 0, "total_lines": 0, "errors": 0} stats = {"files": 0, "programs": 0, "includes": 0, "package_rows": 0, "total_lines": 0, "errors": 0}
package_rows: list[dict] = [] package_rows: list[dict] = []
tcode_rows: list[dict] = [] # ingest/from_sap.py 가 실어 보내는 TCODE_LIST → tcodes.jsonl
error_lines: list[str] = [] error_lines: list[str] = []
files = sorted(p for p in raw_dir.rglob("*") if p.is_file() and p.suffix.lower() in {".txt", ".json"}) files = sorted(p for p in raw_dir.rglob("*") if p.is_file() and p.suffix.lower() in {".txt", ".json"})
@@ -132,6 +133,11 @@ def run(raw_dir: Path, out_dir: Path, limit: int | None = None, dry_run: bool =
stats["programs"] += 1 stats["programs"] += 1
stats["includes"] += len(meta["includes"]) stats["includes"] += len(meta["includes"])
stats["total_lines"] += meta["total_lines"] stats["total_lines"] += meta["total_lines"]
for t in data.get("TCODE_LIST") or []:
tcode = clean_text_field(t.get("TCODE")).upper()
if tcode:
tcode_rows.append({"tcode": tcode, "program": meta["program"],
"text_ko": clean_text_field(t.get("TTEXT")) or meta["description"]})
else: else:
stats["errors"] += 1 stats["errors"] += 1
error_lines.append(f"{path}\tUNKNOWN_FORMAT") error_lines.append(f"{path}\tUNKNOWN_FORMAT")
@@ -141,6 +147,10 @@ def run(raw_dir: Path, out_dir: Path, limit: int | None = None, dry_run: bool =
with packages_path.open("w", encoding="utf-8") as f: with packages_path.open("w", encoding="utf-8") as f:
for row in package_rows: for row in package_rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n") f.write(json.dumps(row, ensure_ascii=False) + "\n")
if tcode_rows:
# from_dir.py 와 같은 파일·모양. 로더(index/loader.py load_tcodes)가 tcode 테이블에 넣는다
(out_dir / "tcodes.jsonl").write_text(
"".join(json.dumps(r, ensure_ascii=False) + "\n" for r in tcode_rows), encoding="utf-8")
if error_lines: if error_lines:
errors_log.write_text("\n".join(error_lines) + "\n", encoding="utf-8") errors_log.write_text("\n".join(error_lines) + "\n", encoding="utf-8")
+1 -1
View File
@@ -17,7 +17,7 @@ postgres = ["psycopg[binary]>=3.1", "pgvector>=0.2"]
# flat-layout 자동 탐색이 data/ wiki/ 까지 패키지로 오인해 실패한다 — 명시 지정. # flat-layout 자동 탐색이 data/ wiki/ 까지 패키지로 오인해 실패한다 — 명시 지정.
[tool.setuptools] [tool.setuptools]
packages = ["config", "ingest", "parser", "index", "summarize", "query", "wiki_out", "eval"] packages = ["config", "ingest", "parser", "index", "summarize", "query", "wiki_out", "eval", "sap"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
+99
View File
@@ -0,0 +1,99 @@
# ZAA_ICF MCP 서버 (abap-sap)
SAP `ZCL_ZAA_AGENT_API` 의 16개 메서드를 LLM 에 붙이는 MCP 서버. 전부 조회만 한다.
브라우저에서 같은 API 를 호출해 보는 웹 테스터와 Bruno 연동은 형제 프로젝트 **`../abap-api-tester`** 에 있다.
API 정의(`catalog.py`)와 SAP 호출 클라이언트(`sap_client.py`)는 이 폴더가 원본이고, 테스터가 여기서 가져다 쓴다.
## 실행
```
cp .env.example .env # SAP_USER / SAP_PASS 채우기
pip install -r requirements.txt # mcp>=2.1 (Python 3.10+)
python mcp_server.py # stdio (Claude Code / Claude Desktop)
python mcp_server.py --http 8766 # streamable-http http://127.0.0.1:8766/mcp
python tools/test_mcp.py [--shrink] # 툴 목록 + 실제 호출 점검
```
접속 정보는 `.env``SAP_URL` / `SAP_USER` / `SAP_PASS`. `.env` 는 스크립트 위치 기준으로 읽으므로 작업 디렉터리와 무관하다.
로그는 stderr 로만 나간다 (stdout 은 프로토콜 채널이라 print 금지).
## 파일
| 파일 | 역할 |
|---|---|
| `mcp_server.py` | MCP 서버. `catalog.py` 의 16개 메서드를 툴로 노출 (stdio / streamable-http) |
| `catalog.py` | 메서드 설명, 파라미터, 샘플, 필드 사전, 코드값 맵, 셀 링크. **테스터와 공유 (원본)** |
| `sap_client.py` | SAP 호출·응답 정규화(BOM/utf-16 선언/제어문자 제거, asXML→JSON). **테스터와 공유 (원본)** |
| `.mcp.json` | Claude Code 프로젝트 MCP 설정 (이 폴더를 열면 `abap-sap` 서버가 등록됨) |
| `tools/test_mcp.py` | MCP 서버 점검 (툴 목록 + 실제 호출 + 잘라내기) |
카탈로그를 고치면 툴 정의도, 테스터 화면도, Bruno 컬렉션(테스터가 생성)도 같이 바뀐다.
## 연결
- **Claude Code**: 이 폴더에 `.mcp.json` 이 있어 프로젝트를 열면 `abap-sap` 서버가 등록된다 (처음 한 번 승인).
다른 폴더에서도 쓰려면 사용자 범위로 등록한다.
```
claude mcp add --transport stdio --scope user abap-sap -- python C:\Users\<me>\EdgeCenter\samsung\abap-mcp\mcp_server.py
```
- **Claude Desktop**: `%APPDATA%\Claude\claude_desktop_config.json`
```json
{"mcpServers": {"abap-sap": {"type": "stdio", "command": "python",
"args": ["C:\Users\<me>\EdgeCenter\samsung\abap-mcp\mcp_server.py"]}}}
```
`.env` 대신 환경변수로 넘기려면 `"env": {"SAP_USER": "...", "SAP_PASS": "..."}`.
## 툴
툴 정의(이름·설명·파라미터·예시)는 `catalog.py` 에서 **자동 생성**된다.
| 툴 | 비고 |
|---|---|
| `get_package_list` … `get_tcode_info`, `get_object_type` (17개, 메서드명 소문자) | 파라미터는 `IV_` 를 뗀 소문자 (`IV_MAX_ROWS` → `max_rows`). 플래그는 boolean, 날짜는 `YYYY-MM-DD` |
| `explain_fields` | 응답 필드의 한국어 뜻과 코드값 표 (`catalog.py` 의 FIELDS / VALUE_MAPS) |
| `sap_connection_info` | 접속 대상·호출 규약 확인 (비밀번호 제외) |
응답은 `{"return": {"type", "message", "total_rows"}, …결과, "_meta": {sap_method, sap_params, ms, raw_bytes}}`.
`RETURN.TYPE=E` 도 툴 오류가 아니라 정상 응답으로 돌려준다 (LLM 이 메시지를 읽고 판단한다). 네트워크·인증·XML 파싱 실패만 툴 오류.
서버 `instructions` 에 탐색 흐름(패키지 → 오브젝트 → 소스, T코드 → 프로그램, 용어 → 필드)과 응답 읽는 법이 들어 있다.
### LLM 컨텍스트 보호
| 동작 | 기본값 | 환경변수 |
|---|---|---|
| 목록 툴에서 `max_rows` 생략 시 상한. 잘리면 `return.type=W` + `total_rows`. `0` 이면 전건(SAP 안전 상한 50,000) | 200 | `ABAP_MCP_DEFAULT_MAX_ROWS` |
| 응답 JSON 최대 문자 수. 넘치면 가장 큰 소스/목록부터 절반씩 줄이고 `_truncated` 에 무엇을 얼마나 잘랐는지 적는다 | 120,000 | `ABAP_MCP_MAX_OUTPUT_CHARS` |
| SAP 호출 타임아웃(초) | 180 | `ABAP_MCP_TIMEOUT` |
소스를 주는 툴(`get_program_source`, `get_version_source`, `get_function_detail`)에는 `line_from` / `line_to` 가 추가돼 있어
긴 소스를 나눠 읽는다. 응답에 `SOURCE_CODE_LINES`(전체 줄 수)와 `SOURCE_CODE_RANGE` 가 붙는다. Include/화면 안의 소스도 같은 범위로 잘린다.
실측(2026-09-07): `SAPMV45A` + Include(원문 4.2MB, include 1,822개)를 상한 15,000자로 부르면 14,378자 + 안내 3줄로 돌아온다.
## 호출 규약 (실측)
- `POST /sap/bc/ZAA_ICF/{METHOD}`, `Content-Type: application/json`, 바디 `{"INPUT": {"IV_...": ...}}`
- 응답 asXML. XML 선언은 `utf-16` 이지만 실제 UTF-8 → 선언 제거 후 파싱
- 소스코드 안 제어문자(0x0C)가 XML 을 깨뜨림 → 파싱 전 제거
- 날짜는 `YYYY-MM-DD`
- 목록 API 의 `IV_MAX_ROWS` 는 생략하면 전건(안전 상한 50,000). 지정해서 잘리면 `RETURN.TYPE=W`, 메시지에 `(전체 M건)`, `RETURN.TOTAL_ROWS=M` (2026-09-04 A안. 그 전엔 기본 200/최대 2,000)
## 서버측 결함 이력 (2026-09-04 기준 모두 해결)
- ~~`GET_VERSION_SOURCE` 과거 버전 → HTTP 500~~ 2026-09-04 해결. `SVRS_GET_REPS_FROM_OBJECT` 로 교체 (REPS 만 지원)
- ~~`GET_FUNCTION_DETAIL` 의 `FUNC_SOURCE` 항상 빈값~~ 2026-09-04 해결. 함수 Include 를 통째로 읽도록 교체 (출처가 `RETURN.MESSAGE` 에 찍힘)
- ~~`GET_PROGRAM_SOURCE` include 목록에 클래스 include 노이즈 혼입~~ 2026-09-04 해결. `=` 포함 또는 30자 초과 이름 제거
- ~~`GET_OBJECT_LIST_BY_PACKAGE` 상한에서 잘려도 `TYPE=S`~~ 2026-09-04 해결. n+1 건을 읽어 초과 시 W (실측 ZFI01 PROG 1,132건)
- ~~`GET_FIELD_LIST_BY_TEXT` USED_TABLES 20건 상한이 조용히 잘림 / 후보 0건이면 응답 본문 0바이트~~ 2026-09-04 해결. W + 잘린 엘리먼트 이름, 0건은 빈 RESULT XML
- ~~목록 API 기본 상한 200 / 최대 2,000~~ 2026-09-04 A안으로 제거. 생략 시 전건(안전 상한 50,000), `RETURN.TOTAL_ROWS` 추가. 실측: 테이블 `*` 전건 822,899건 → 50,000건 W, 8.7MB, 8초
- ~~`GET_FIELD_LIST_BY_TEXT` USED_TABLES 엘리먼트당 20건 고정 상한~~ 2026-09-04 제거. 후보 500개씩 청크 조회, 사용 테이블 총량 50,000 넘으면 중단 + W. 실측 '회사코드' 전건 499후보/6,949건 0.8MB, '코드' 14,000후보 → 안전 상한 W 10MB/4초
수정 소스는 `~/Downloads/CLASS ZCL_ZAA_AGENT_API DEFINITION.txt` (전체) 와 `METHOD *.txt`. API 명세서(메서드당 1시트)는 `~/Downloads/ABAP_API_명세서_ZAA_ICF_v1.4_2026-09-04.xlsx` (생성기는 `../abap-api-tester/tools/spec/`).
## 응답 인코딩 (2026-09-04 확인)
응답 HTTP 헤더는 `Content-Type: text/html; charset=utf-8` 이고 본문은 UTF-8 BOM 으로 시작한다.
XML 선언만 `encoding="utf-16"` 으로 잘못 찍혀 있다. Bruno 는 헤더 charset 으로 디코딩하므로 Bruno 단계에서는 문제없고,
직접 파싱하는 클라이언트만 BOM 과 XML 선언을 떼고 UTF-8 로 읽으면 된다.
+5
View File
@@ -0,0 +1,5 @@
"""SAP ZAA_ICF 클라이언트 (abap-mcp 에서 옮겨옴). `sap.sap_client.sap_call` 이 진입점.
원본은 ../abap-mcp — catalog.py / sap_client.py / mcp_server.py 를 그대로 복사했고,
sap_client 의 import 와 .env 탐색만 이 저장소 구조에 맞게 손봤다 (아래 두 곳 '# abap-indexing' 주석).
"""
+549
View File
@@ -0,0 +1,549 @@
# -*- coding: utf-8 -*-
"""ZAA_ICF API 카탈로그: 메서드 설명, 파라미터, 입력 샘플, 응답 필드 사전, 코드값 해석.
SAP 을 모르는 개발자가 응답을 읽을 수 있도록 한국어 설명을 붙였다.
값은 2026-09-03 실제 서버 응답으로 검증한 것.
"""
# 응답에서 "빈 값이면 [] 로 취급" 할 테이블 필드
LIST_FIELDS = {
"RESULT", "INCLUDE_LIST", "SCREEN_LIST", "TCODE_LIST",
"IMPORT_PARAMETER", "EXPORT_PARAMETER", "CHANGING_PARAMETER",
"TABLES_PARAMETER", "EXCEPTION_LIST", "FIX_VALUES", "USED_TABLES", "MASTERS",
}
# 소스코드처럼 길게 표시할 필드
CODE_FIELDS = ["SOURCE_CODE", "FUNC_SOURCE", "SOURCE", "FLOW_LOGIC"]
# ---------------------------------------------------------------- 필드 사전 (응답 컬럼)
FIELDS = {
# 공통
"RETURN": ("처리 결과", "TYPE(S/W/E) 와 MESSAGE. W 는 결과가 상한에서 잘렸다는 뜻이 대부분."),
"RESULT": ("결과 목록", "조회 결과 행 목록"),
"TYPE": ("결과 유형", "S=성공, W=경고(결과 잘림 등), E=오류(필수값 누락/미존재)"),
"MESSAGE": ("메시지", "사람이 읽는 결과 설명"),
"TOTAL_ROWS": ("전체 건수", "절삭 전 전체 건수. IV_MAX_ROWS 가 있는 목록 API 만 채움 (그 외 0). 잘리지 않았으면 RESULT 건수와 같음"),
"KIND": ("오브젝트 세분류", "REPORT=실행 프로그램, MODULE_POOL=모듈 풀, INCLUDE=Include, FUGR_MAIN=함수그룹 메인(SAPL*), CLASS_POOL/INTERFACE_POOL/TYPE_POOL=클래스·인터페이스·타입 풀, TABLE/STRUCTURE/APPEND/VIEW/TABLE_TYPE/DATA_ELEMENT/DOMAIN=DDIC, CLASS/INTERFACE/FUNCTION_GROUP/FUNCTION/TCODE"),
"MASTERS": ("메인 프로그램 목록", "Include 를 포함하는 메인 프로그램(D010INC). KIND=INCLUDE 일 때만 채움"),
"SPRAS": ("텍스트 언어", "설명 텍스트가 어떤 언어인지. 3=한국어, E=영어, D=독일어"),
"SPRSL": ("텍스트 언어", "설명 텍스트가 어떤 언어인지. 3=한국어, E=영어, D=독일어"),
"LANG_PRIO": ("언어 우선순위", "1=로그인 언어(한국어), 2=영어, 3=독일어, 9=기타. 여러 언어 중 이 순서로 하나만 남김"),
# 패키지
"DEVCLASS": ("패키지", "개발 오브젝트를 묶는 폴더 개념(개발 클래스). Z/Y 로 시작하면 고객 개발"),
"CTEXT": ("패키지 설명", "패키지 짧은 설명"),
"PARENTCL": ("상위 패키지", "패키지 계층의 부모. 비어 있으면 최상위"),
# 프로그램
"OBJ_NAME": ("오브젝트 이름", "프로그램/클래스/테이블 등의 기술적 이름"),
"OBJECT": ("오브젝트 유형", "PROG=프로그램, FUGR=함수그룹, CLAS=클래스, TABL=테이블/구조, DTEL=데이터엘리먼트, TRAN=트랜잭션 등"),
"AUTHOR": ("담당자", "오브젝트 책임자(생성자) 사용자 ID"),
"TEXT": ("제목", "프로그램 제목 텍스트"),
"SUBC": ("프로그램 유형", "1=실행 프로그램(리포트), M=모듈 풀(화면 프로그램), I=Include, F=함수그룹, K=클래스 풀"),
"UDAT": ("최종 변경일", "마지막으로 소스를 바꾼 날짜"),
"UNAM": ("최종 변경자", "마지막으로 소스를 바꾼 사용자"),
"PROGRAM": ("프로그램", "조회한 프로그램 이름"),
"SOURCE_CODE": ("소스 코드", "ABAP 소스 전문(줄바꿈 포함)"),
"SOURCE_SIZE": ("소스 길이", "SOURCE_CODE 의 문자 수"),
"INCLUDE_LIST": ("Include 목록", "프로그램이 끌어다 쓰는 별도 소스 파일(Include)과 그 내용"),
"INCL_NAME": ("Include 이름", "Include 프로그램 이름. 클래스/Enhancement 시스템 include(31~32자, '=' 포함)는 서버가 걸러낸다"),
"SOURCE": ("소스", "해당 Include 의 ABAP 소스"),
"SCREEN_LIST": ("화면 목록", "프로그램에 붙은 화면(Dynpro)과 화면 흐름 로직"),
"SCREEN_NUMBER": ("화면 번호", "4자리 화면 번호 (예: 0100)"),
"FLOW_LOGIC": ("화면 흐름 로직", "PBO(표시 전)/PAI(입력 후) 에 실행되는 모듈 호출 코드"),
"TCODE_LIST": ("트랜잭션 코드", "이 프로그램을 실행하는 트랜잭션 코드(SAP 메뉴에서 치는 명령어)"),
"TCODE": ("트랜잭션 코드", "SAP 화면에서 입력하는 실행 명령어 (예: SE38, VA01)"),
"TTEXT": ("트랜잭션 설명", "트랜잭션 코드 텍스트"),
"PGMNA": ("프로그램", "트랜잭션이 실행하는 프로그램 이름"),
# 함수
"FUNCNAME": ("함수 모듈", "재사용 가능한 ABAP 함수(Function Module) 이름"),
"STEXT": ("함수 설명", "함수 짧은 설명"),
"FUNC_TEXT": ("함수 설명", "함수 짧은 설명"),
"FUGR_NAME": ("함수 그룹", "함수 모듈이 속한 그룹. 메인 프로그램은 SAPL<그룹>, TOP include 는 L<그룹>TOP"),
"FUNC_SOURCE": ("함수 소스", "함수 본문 소스 전문 (FUNCTION ~ ENDFUNCTION). 함수 Include 를 통째로 읽은 것"),
"IMPORT_PARAMETER": ("입력 파라미터", "호출자가 함수에 넘기는 값"),
"EXPORT_PARAMETER": ("출력 파라미터", "함수가 돌려주는 값"),
"CHANGING_PARAMETER": ("변경 파라미터", "넘겨서 받아오는(입출력) 값"),
"TABLES_PARAMETER": ("테이블 파라미터", "내부 테이블(행 목록)로 주고받는 값"),
"EXCEPTION_LIST": ("예외 목록", "함수가 실패 시 던지는 예외 이름"),
"PARAMETER": ("파라미터 이름", "함수 인터페이스 파라미터 이름"),
"DBFIELD": ("참조 타입", "파라미터 타입으로 참조하는 데이터엘리먼트/구조"),
"TYP": ("타입", "TYPE 절로 지정한 타입"),
"DEFAULT": ("기본값", "생략 시 사용되는 값"),
"OPTIONAL": ("선택 여부", "X=생략 가능"),
"REFERENCE": ("참조 전달", "X=값 복사 없이 참조로 전달"),
"DBSTRUCT": ("행 구조", "테이블 파라미터 한 행의 구조"),
"EXCEPTION": ("예외", "예외 이름"),
# 테이블
"TABNAME": ("테이블", "DB 테이블 또는 구조 이름"),
"DDTEXT": ("설명", "사전(Data Dictionary) 설명 텍스트"),
"TABCLASS": ("테이블 종류", "TRANSP=실제 DB 테이블, INTTAB=구조(데이터 없음), VIEW=뷰, CLUSTER/POOL=특수 테이블"),
"IS_CUSTOM": ("고객 개발", "X=Z/Y 로 시작하는 고객 개발 테이블"),
"FIELDNAME": ("필드", "테이블 컬럼 이름"),
"KEYFLAG": ("키 여부", "X=기본 키(Primary Key) 필드"),
"POSITION": ("순서", "테이블 안에서의 컬럼 순서"),
"ROLLNAME": ("데이터엘리먼트", "필드의 의미(설명·라벨)를 정의하는 재사용 타입. 도메인 위에 얹힘"),
"DOMNAME": ("도메인", "필드의 기술 속성(타입·길이·허용값)을 정의하는 재사용 타입"),
"DATATYPE": ("데이터 타입", "CHAR=문자, NUMC=숫자문자, DATS=날짜, TIMS=시간, DEC=소수, CURR=금액, QUAN=수량, INT4=정수, CLNT=클라이언트"),
"LENG": ("길이", "필드 길이(문자 수 또는 자릿수)"),
"DECIMALS": ("소수 자릿수", "소수점 이하 자릿수"),
"OUTPUTLEN": ("출력 길이", "화면 표시 길이"),
"CHECKTABLE": ("체크 테이블", "외래키로 값 유효성을 검사하는 참조 테이블"),
"SCRTEXT_S": ("짧은 라벨", "화면 표시용 라벨(짧음)"),
"SCRTEXT_M": ("중간 라벨", "화면 표시용 라벨(중간)"),
"SCRTEXT_L": ("긴 라벨", "화면 표시용 라벨(김)"),
"ELEMENT": ("데이터엘리먼트 정보", "입력이 데이터엘리먼트인 경우 채워짐"),
"DOMAIN": ("도메인 정보", "데이터엘리먼트의 도메인 또는 입력이 도메인인 경우"),
"LOWERCASE": ("소문자 허용", "X=대소문자 구분 저장(기본은 대문자 변환)"),
"CONVEXIT": ("변환 루틴", "저장값과 표시값을 바꾸는 루틴 (예: ALPHA=앞자리 0 채움)"),
"VALEXI": ("고정값 존재", "X=도메인에 허용값 목록(FIX_VALUES)이 있음"),
"ENTITYTAB": ("값 테이블", "허용값을 관리하는 테이블 (예: MTART → T134)"),
"FIX_VALUES": ("고정값 목록", "도메인에 정의된 허용 값과 설명"),
"DOMVALUE_L": ("", "허용 값"),
"USED_TABLES": ("사용 테이블", "이 데이터엘리먼트를 컬럼 타입으로 쓰는 테이블/구조 전부 (총량 안전 상한 50,000)"),
# CTS
"TRKORR": ("전송 요청 번호", "변경 사항 묶음(Transport Request) 번호. 개발→운영 이관 단위"),
"AS4TEXT": ("요청 설명", "전송 요청 제목"),
"AS4USER": ("소유자", "전송 요청을 만든 사용자"),
"TRFUNCTION": ("요청 유형", "K=워크벤치(프로그램 등 개발물), W=커스터마이징(설정값), T=전송 대상 없음"),
"AS4DATE": ("변경일", "요청 마지막 변경(릴리즈) 날짜"),
"AS4TIME": ("변경 시각", "요청 마지막 변경(릴리즈) 시각"),
"PGMID": ("프로그램 ID", "R3TR=오브젝트 전체, LIMU=오브젝트 일부(메서드/소스 등), CORR=전송 관리 항목"),
# 버전
"OBJNAME": ("오브젝트 이름", "버전 관리 대상 이름"),
"OBJTYPE": ("버전 오브젝트 유형", "REPS=프로그램/Include 소스, CLSD=클래스 정의, METH=메서드, FUNC=함수"),
"VERSNO": ("버전 번호", "00000=활성(현재) 버전, 그 외는 과거 버전. 클수록 최신"),
"KORRNUM": ("전송 요청", "이 버전을 만든 전송 요청 번호"),
"DATUM": ("버전 생성일", "버전이 저장된 날짜"),
"ZEIT": ("버전 생성 시각", "버전이 저장된 시각"),
# 사용처
"MASTER": ("사용/메인 프로그램", "where-used: 해당 오브젝트를 사용하는 프로그램. GET_OBJECT_TYPE: Include 를 포함하는 컴파일 단위(함수그룹이면 SAPL<그룹>, 클래스면 클래스 풀)"),
}
# ---------------------------------------------------------------- 코드값 해석
VALUE_MAPS = {
"TYPE": {"S": "성공", "W": "경고", "E": "오류", "X": "호출 실패"},
"SPRAS": {"3": "한국어", "E": "영어", "D": "독일어", "J": "일본어", "1": "중국어(간체)", "": "(텍스트 없음)"},
"SPRSL": {"3": "한국어", "E": "영어", "D": "독일어", "J": "일본어", "1": "중국어(간체)", "": "(텍스트 없음)"},
"LANG_PRIO": {"1": "로그인 언어", "2": "영어", "3": "독일어", "9": "기타"},
"SUBC": {"1": "실행 프로그램(리포트)", "M": "모듈 풀(화면 프로그램)", "I": "Include", "F": "함수 그룹",
"K": "클래스 풀", "J": "인터페이스 풀", "S": "서브루틴 풀", "T": "타입 풀", "X": "XSLT"},
"OBJECT": {
"PROG": "프로그램", "FUGR": "함수 그룹", "CLAS": "클래스", "INTF": "인터페이스",
"TABL": "테이블/구조", "DTEL": "데이터엘리먼트", "DOMA": "도메인", "TTYP": "테이블 타입",
"VIEW": "", "DDLS": "CDS 뷰", "SHLP": "검색 도움말", "ENQU": "잠금 오브젝트",
"TRAN": "트랜잭션 코드", "MSAG": "메시지 클래스", "DEVC": "패키지", "XSLT": "XSLT 변환",
"SXCI": "BAdI 구현", "ENHO": "인핸스먼트 구현", "ENHS": "인핸스먼트 스팟", "WDYN": "Web Dynpro",
"SFPF": "Adobe 폼", "SFPI": "Adobe 인터페이스", "SSFO": "Smart Form", "FORM": "SAPscript 폼",
"SMIM": "MIME 오브젝트", "NROB": "번호 범위", "TOBJ": "유지보수 뷰", "PARA": "SET/GET 파라미터",
"IWSV": "OData 서비스", "IWPR": "OData 프로젝트", "SRVD": "서비스 정의", "SRVB": "서비스 바인딩",
"BDEF": "비헤이비어 정의", "DCLS": "접근 제어(DCL)", "RELE": "릴리즈 마커", "TABU": "테이블 데이터",
"VDAT": "뷰 데이터", "CDAT": "커스터마이징 데이터", "TDAT": "테이블 데이터(그룹)",
"REPS": "프로그램 소스", "METH": "메서드", "FUNC": "함수 모듈", "DYNP": "화면", "CUAD": "GUI 상태",
"CINC": "클래스 로컬 include", "CPUB": "클래스 public 섹션", "CPRI": "클래스 private 섹션",
"CPRO": "클래스 protected 섹션", "CLSD": "클래스 정의", "REPT": "프로그램 텍스트",
"TABD": "테이블 정의", "DTED": "데이터엘리먼트 정의", "DOMD": "도메인 정의", "MESS": "메시지",
},
"PGMID": {"R3TR": "오브젝트 전체", "LIMU": "오브젝트 일부", "CORR": "전송 관리 항목"},
"TRFUNCTION": {"K": "워크벤치 요청", "W": "커스터마이징 요청", "T": "대상 없는 전송", "C": "재배치(패키지 변경 없음)",
"O": "재배치(패키지 변경)", "E": "재배치(패키지)", "S": "개발/수정 태스크", "R": "리페어",
"X": "미분류 태스크", "Q": "커스터마이징 태스크", "L": "삭제 전송", "D": "패치", "P": "사전 준비"},
"TABCLASS": {"TRANSP": "투명 테이블(실제 DB)", "INTTAB": "구조(데이터 없음)", "VIEW": "", "CLUSTER": "클러스터 테이블",
"POOL": "풀 테이블", "APPEND": "어펜드 구조"},
"DATATYPE": {"CHAR": "문자", "NUMC": "숫자 문자열", "DATS": "날짜(YYYYMMDD)", "TIMS": "시간(HHMMSS)", "DEC": "소수(packed)",
"CURR": "통화 금액", "CUKY": "통화 키", "QUAN": "수량", "UNIT": "단위", "INT1": "1바이트 정수", "INT2": "2바이트 정수",
"INT4": "4바이트 정수", "INT8": "8바이트 정수", "FLTP": "부동소수", "RAW": "바이너리", "LRAW": "긴 바이너리",
"STRG": "문자열", "SSTR": "짧은 문자열", "RSTR": "바이너리 문자열", "CLNT": "클라이언트", "LANG": "언어 키",
"LCHR": "긴 문자", "ACCP": "회계 기간(YYYYMM)", "PREC": "정밀도", "DF16_DEC": "10진 부동소수(16)", "DF34_DEC": "10진 부동소수(34)",
"D16D": "10진 부동소수(16)", "D34D": "10진 부동소수(34)", "D16R": "10진 부동소수(16,raw)", "D34R": "10진 부동소수(34,raw)",
"DATN": "날짜(네이티브)", "TIMN": "시간(네이티브)", "UTCL": "UTC 타임스탬프", "GEOM_EWKB": "지오메트리"},
"KEYFLAG": {"X": "", "": ""},
"IS_CUSTOM": {"X": "고객 개발", "": "SAP 표준"},
"OPTIONAL": {"X": "선택", "": "필수"},
"REFERENCE": {"X": "참조", "": ""},
"LOWERCASE": {"X": "허용", "": "대문자 변환"},
"VALEXI": {"X": "있음", "": "없음"},
"OBJTYPE": {"REPS": "프로그램/Include 소스", "CLSD": "클래스 정의", "METH": "메서드", "FUNC": "함수 모듈", "DYNP": "화면",
"TABD": "테이블 정의", "DTED": "데이터엘리먼트", "DOMD": "도메인", "CINC": "클래스 로컬 include", "CUAD": "GUI 상태",
"REPT": "프로그램 텍스트", "CPUB": "public 섹션", "CPRI": "private 섹션", "CPRO": "protected 섹션"},
"CONVEXIT": {"ALPHA": "앞자리 0 채움/제거", "": "없음"},
}
# 결과 셀 클릭 -> 다른 API 로 이어가기 (필드명 -> [메서드, 파라미터])
LINKS = {
"DEVCLASS": ["GET_OBJECT_LIST_BY_PACKAGE", "IV_PACKAGE"],
"PARENTCL": ["GET_OBJECT_LIST_BY_PACKAGE", "IV_PACKAGE"],
"FUNCNAME": ["GET_FUNCTION_DETAIL", "IV_FUNCTION"],
"TABNAME": ["GET_TABLE_FIELDS", "IV_TABLE"],
"CHECKTABLE": ["GET_TABLE_FIELDS", "IV_TABLE"],
"ENTITYTAB": ["GET_TABLE_FIELDS", "IV_TABLE"],
"ROLLNAME": ["GET_TYPE_INFO", "IV_TYPE_NAME"],
"DOMNAME": ["GET_TYPE_INFO", "IV_TYPE_NAME"],
"DBFIELD": ["GET_TYPE_INFO", "IV_TYPE_NAME"],
"TRKORR": ["GET_CTS_OBJECT_LIST", "IV_TRKORR"],
"KORRNUM": ["GET_CTS_OBJECT_LIST", "IV_TRKORR"],
"TCODE": ["GET_TCODE_INFO", "IV_TCODE"],
"PGMNA": ["GET_PROGRAM_SOURCE", "IV_PROGRAM"],
"MASTER": ["GET_PROGRAM_SOURCE", "IV_PROGRAM"],
"INCL_NAME": ["GET_PROGRAM_SOURCE", "IV_PROGRAM"],
"PROGRAM": ["GET_VERSION_LIST", "IV_OBJNAME"],
# OBJ_NAME 은 OBJECT 유형에 따라 다름 -> 프론트에서 OBJECT 값 보고 분기 (LINKS_BY_OBJECT)
}
LINKS_BY_OBJECT = {
"PROG": ["GET_PROGRAM_SOURCE", "IV_PROGRAM"],
"REPS": ["GET_PROGRAM_SOURCE", "IV_PROGRAM"],
"FUGR": ["GET_FUNCTION_LIST", "IV_FUNC_GROUP"],
"TABL": ["GET_TABLE_FIELDS", "IV_TABLE"],
"DTEL": ["GET_TYPE_INFO", "IV_TYPE_NAME"],
"DOMA": ["GET_TYPE_INFO", "IV_TYPE_NAME"],
"TRAN": ["GET_TCODE_INFO", "IV_TCODE"],
"CLAS": ["GET_VERSION_LIST", "IV_OBJNAME"],
}
MAX_ROWS = {"name": "IV_MAX_ROWS", "label": "최대 건수", "type": "number",
"desc": "생략/0 이면 전건 (안전 상한 50,000). 지정하면 그 건수까지만. 잘리면 TYPE=W + '(전체 M건)' + RETURN.TOTAL_ROWS=M (2026-09-04 상한 제거)"}
# ---------------------------------------------------------------- 메서드
METHODS = [
# ------------------------------------------------ 1
{
"name": "GET_PACKAGE_LIST", "title": "패키지 목록", "group": "1. 탐색 (어디에 뭐가 있나)",
"summary": "고객 개발(Z/Y) 패키지 목록을 가져온다. 패키지는 개발물을 담는 폴더 같은 것이라, 시스템 탐색의 시작점이다.",
"when": "처음 시스템을 볼 때, 또는 '영업/재무 관련 개발물이 어디 있지?' 를 설명 텍스트로 찾을 때.",
"params": [
{"name": "IV_PATTERN", "label": "패키지명 패턴", "type": "string", "placeholder": "ZFI*",
"desc": "* 와일드카드 사용. 생략하면 Z* 와 Y* 전부"},
{"name": "IV_SEARCH_TEXT", "label": "설명 검색어", "type": "string", "placeholder": "API",
"desc": "패키지 설명(CTEXT)에 이 문자열이 포함된 것만. 대소문자 구분"},
MAX_ROWS,
],
"samples": [
{"label": "전체 CBO 패키지 (기본, 전건)", "params": {}, "note": "이 시스템은 237개. 상한 없이 전건"},
{"label": "이름 패턴 ZFI*", "params": {"IV_PATTERN": "ZFI*"}},
{"label": "설명에 'API' 포함", "params": {"IV_SEARCH_TEXT": "API"}},
{"label": "상한 5건 → W 응답", "params": {"IV_MAX_ROWS": 5}, "note": "RETURN.TYPE=W, 메시지에 전체 건수, TOTAL_ROWS=237"},
],
},
# ------------------------------------------------ 3
{
"name": "GET_OBJECT_LIST_BY_PACKAGE", "title": "패키지 안의 오브젝트", "group": "1. 탐색 (어디에 뭐가 있나)",
"summary": "한 패키지에 들어 있는 모든 개발 오브젝트(프로그램, 클래스, 테이블, 함수그룹, 트랜잭션…)를 유형별로 나열한다.",
"when": "패키지를 골랐고 그 안에 뭐가 있는지 볼 때. OBJECT 컬럼 값으로 다음에 부를 API 가 정해진다.",
"params": [
{"name": "IV_PACKAGE", "label": "패키지", "type": "string", "required": True, "placeholder": "ZFI01",
"desc": "GET_PACKAGE_LIST 의 DEVCLASS 값"},
{"name": "IV_OBJ_TYPE", "label": "오브젝트 유형", "type": "string", "placeholder": "PROG",
"desc": "PROG/FUGR/CLAS/TABL/DTEL/DOMA/TRAN/VIEW … 생략 시 전체"},
MAX_ROWS,
],
"samples": [
{"label": "ZFI01 전체 (2,874건)", "params": {"IV_PACKAGE": "ZFI01", "IV_MAX_ROWS": 5000},
"note": "PROG 1,132 / DTEL 832 / TABL 428 / TRAN 201 / DOMA 128 ... 기본 상한(2000)이면 W"},
{"label": "ZFI01 테이블만 (428건)", "params": {"IV_PACKAGE": "ZFI01", "IV_OBJ_TYPE": "TABL"}},
{"label": "ZFI01 함수 그룹만 (14건)", "params": {"IV_PACKAGE": "ZFI01", "IV_OBJ_TYPE": "FUGR"}},
{"label": "ZFI01 프로그램 전건 (1,132건)", "params": {"IV_PACKAGE": "ZFI01", "IV_OBJ_TYPE": "PROG"},
"note": "Include(SUBC=I) 946건이 포함돼 GET_PROGRAM_LIST(177건)보다 많다"},
{"label": "ZFI01 프로그램 상한 500 → W", "params": {"IV_PACKAGE": "ZFI01", "IV_OBJ_TYPE": "PROG", "IV_MAX_ROWS": 500},
"note": "TYPE=W, 메시지 '(전체 1132건)', TOTAL_ROWS=1132"},
{"label": "필수값 누락 → E", "params": {}, "note": "IV_PACKAGE 없이 호출하면 오류 응답"},
],
"notes": ["2026-09-04 수정: 상한에서 잘려도 TYPE=S 였던 결함 → W + '(전체 M건)' + TOTAL_ROWS. 이어서 기본 상한 자체를 없앴다 (생략 시 전건).",
"정렬이 OBJECT 알파벳순이라 IV_MAX_ROWS 로 잘리면 뒤쪽 유형(TABL, TRAN …)이 통째로 빠진다. 전체가 필요하면 IV_MAX_ROWS 를 생략.",
"PROG 에는 Include 프로그램과 TADIR 항목만 남은 삭제 프로그램도 포함된다 (GET_PROGRAM_LIST 는 기본 1+M 만)."],
},
# ------------------------------------------------ 2
{
"name": "GET_PROGRAM_LIST", "title": "프로그램 목록", "group": "2. 프로그램",
"summary": "패키지 또는 이름 패턴으로 ABAP 프로그램을 찾는다. 제목, 유형, 최종 변경자/일자를 함께 준다.",
"when": "특정 패키지의 프로그램을 훑거나, 'ZSD*' 처럼 이름 규칙으로 찾을 때. 기본은 실행 프로그램(1)과 화면 프로그램(M)만.",
"params": [
{"name": "IV_PACKAGE", "label": "패키지", "type": "string", "placeholder": "ZFI01", "desc": "패키지로 필터"},
{"name": "IV_PATTERN", "label": "프로그램명 패턴", "type": "string", "placeholder": "ZFI1*", "desc": "* 와일드카드. 패키지와 패턴 둘 다 없으면 빈 결과"},
{"name": "IV_SUBC", "label": "프로그램 유형", "type": "string", "placeholder": "1",
"desc": "1=실행 프로그램, M=모듈 풀, I=Include, F=함수그룹, K=클래스 풀. 생략 시 1+M"},
MAX_ROWS,
],
"samples": [
{"label": "패키지 ZFI01 (177건)", "params": {"IV_PACKAGE": "ZFI01"}},
{"label": "이름 패턴 Z* (10건)", "params": {"IV_PATTERN": "Z*", "IV_MAX_ROWS": 10}},
{"label": "ZFI01 의 Include 만 (946건)", "params": {"IV_PACKAGE": "ZFI01", "IV_SUBC": "I"}},
{"label": "필터 없음 → 안내 메시지", "params": {}, "note": "전건 조회 방지: S + 메시지, 결과 0건"},
],
},
# ------------------------------------------------ 4
{
"name": "GET_PROGRAM_SOURCE", "title": "프로그램 소스", "group": "2. 프로그램",
"summary": "프로그램의 ABAP 소스 전문을 가져온다. 옵션으로 Include 소스, 화면 흐름 로직, 연결된 트랜잭션 코드까지.",
"when": "코드를 읽거나 분석할 때. 모듈 풀(SAPM*) 은 실제 로직이 Include 에 있으므로 IV_WITH_INCLUDE=X 가 사실상 필수.",
"params": [
{"name": "IV_PROGRAM", "label": "프로그램", "type": "string", "required": True, "placeholder": "SAPMZSFT0"},
{"name": "IV_WITH_INCLUDE", "label": "Include 포함", "type": "flag", "desc": "X 면 INCLUDE_LIST 에 각 Include 소스 포함"},
{"name": "IV_WITH_SCREEN", "label": "화면 포함", "type": "flag", "desc": "X 면 SCREEN_LIST 에 화면 흐름 로직 포함"},
],
"samples": [
{"label": "모듈 풀 + Include + 화면 (SAPMZSFT0)", "params": {"IV_PROGRAM": "SAPMZSFT0", "IV_WITH_INCLUDE": "X", "IV_WITH_SCREEN": "X"},
"note": "근무 달력 유지보수. Include 1개, 화면 2개, 트랜잭션 ZSCAL"},
{"label": "리포트 소스만 (ZFI1000 계정별잔액조회)", "params": {"IV_PROGRAM": "ZFI1000"}},
{"label": "SAP 표준 (SAPMV45A, 화면 318개)", "params": {"IV_PROGRAM": "SAPMV45A", "IV_WITH_SCREEN": "X"}, "note": "응답 큼"},
{"label": "없는 프로그램 → E", "params": {"IV_PROGRAM": "ZZZ_NOT_EXIST"}},
],
"notes": ["Include 목록은 D010INC 기준이라 전이적으로 포함된 include 까지 전부 온다 (표준 SAPMV45A 는 1800개 이상). 클래스/Enhancement 시스템 include 는 서버가 걸러낸다 (2026-09-04 수정. 그 전엔 'CL_...==CU' 류가 섞였음).",
"소스에 제어문자(0x0C 등)가 있으면 원문 XML 이 규격 위반 → 이 서버가 파싱 전 제거함."],
},
# ------------------------------------------------ 5
{
"name": "GET_FUNCTION_LIST", "title": "함수 모듈 목록", "group": "3. 함수 모듈",
"summary": "함수 그룹 또는 이름 패턴으로 함수 모듈(재사용 API 단위)을 찾는다.",
"when": "패키지 오브젝트 목록에서 FUGR 을 봤을 때 그 안의 함수를 볼 때, 또는 'BAPI_USER*' 처럼 이름으로 찾을 때.",
"params": [
{"name": "IV_FUNC_GROUP", "label": "함수 그룹", "type": "string", "placeholder": "ZFG_FI02", "desc": "GET_OBJECT_LIST_BY_PACKAGE 의 FUGR 이름"},
{"name": "IV_PATTERN", "label": "함수명 패턴", "type": "string", "placeholder": "BAPI_USER_GET*", "desc": "* 와일드카드. 그룹과 패턴 둘 다 없으면 빈 결과"},
MAX_ROWS,
],
"samples": [
{"label": "함수 그룹 ZFG_FI02 (14건, 전표/BP 처리)", "params": {"IV_FUNC_GROUP": "ZFG_FI02"}},
{"label": "함수 그룹 ZLEASE01", "params": {"IV_FUNC_GROUP": "ZLEASE01"}},
{"label": "이름 패턴 ZFIBP_* (고객 함수)", "params": {"IV_PATTERN": "ZFIBP_*"}},
{"label": "이름 패턴 BAPI_USER_GET*", "params": {"IV_PATTERN": "BAPI_USER_GET*"}},
{"label": "필터 없음 → 안내 메시지", "params": {}},
],
},
# ------------------------------------------------ 6
{
"name": "GET_FUNCTION_DETAIL", "title": "함수 모듈 상세", "group": "3. 함수 모듈",
"summary": "함수 하나의 본문 소스(FUNC_SOURCE), 인터페이스(입력/출력/변경/테이블 파라미터, 예외), 함수 그룹의 TOP/FORM include 소스를 준다.",
"when": "함수를 호출하거나 그 동작을 이해해야 할 때. 파라미터 표만 봐도 시그니처를 알 수 있다.",
"params": [
{"name": "IV_FUNCTION", "label": "함수 모듈", "type": "string", "required": True, "placeholder": "BAPI_USER_GET_DETAIL"},
],
"samples": [
{"label": "SAP 표준 BAPI_USER_GET_DETAIL", "params": {"IV_FUNCTION": "BAPI_USER_GET_DETAIL"}},
{"label": "고객 함수 ZFI_CHECK_STCD2 (사업자번호 체크)", "params": {"IV_FUNCTION": "ZFI_CHECK_STCD2"}, "note": "소스 출처 INCLUDE LZFG_FI02U01"},
{"label": "없는 함수 → E", "params": {"IV_FUNCTION": "Z_NOT_EXIST_FM"}},
],
"notes": ["FUNC_SOURCE 는 함수 Include(L<그룹>U##)를 통째로 읽은 것이라 FUNCTION ~ ENDFUNCTION 과 긴 줄(72자 초과)이 그대로 온다. RETURN.MESSAGE 에 출처가 찍힌다.",
"2026-09-04 수정 전에는 항상 빈값이었다 (RPY_FUNCTIONMODULE_READ 의 SOURCE 테이블은 이 시스템에서 채워지지 않고 NEW_SOURCE 파라미터도 없음).",
"파라미터 목록의 행 태그는 <item> 이 아니라 <RSIMP>/<RSEXP>/<RSCHA>/<RSTBL>/<RSEXC> 다. 이 서버의 파서는 이를 목록으로 처리한다 (직접 파싱할 때 주의)."],
},
# ------------------------------------------------ 7
{
"name": "GET_TABLE_LIST", "title": "테이블 목록", "group": "4. 테이블 / 타입",
"summary": "이름 패턴 또는 설명 검색어로 DB 테이블/구조를 찾는다.",
"when": "'고객' 관련 테이블이 뭐가 있는지 설명으로 찾거나, Z 테이블 전체를 훑을 때.",
"params": [
{"name": "IV_PATTERN", "label": "테이블명 패턴", "type": "string", "placeholder": "ZFIT*"},
{"name": "IV_SEARCH_TEXT", "label": "설명 검색어", "type": "string", "placeholder": "고객", "desc": "DDTEXT 부분 일치. 로그인 언어(한국어)/영어/독일어 텍스트 대상"},
{"name": "IV_CUSTOM_ONLY", "label": "고객 개발만", "type": "flag", "desc": "X 면 Z*/Y* 테이블만"},
MAX_ROWS,
],
"samples": [
{"label": "Z 테이블 50건", "params": {"IV_PATTERN": "Z*", "IV_CUSTOM_ONLY": "X", "IV_MAX_ROWS": 50}},
{"label": "설명에 '고객'", "params": {"IV_SEARCH_TEXT": "고객", "IV_MAX_ROWS": 20}},
{"label": "이름 ZFIT* (737건)", "params": {"IV_PATTERN": "ZFIT*"}},
{"label": "필터 없음 → 안내 메시지", "params": {}},
],
},
# ------------------------------------------------ 8
{
"name": "GET_TABLE_FIELDS", "title": "테이블 필드(컬럼)", "group": "4. 테이블 / 타입",
"summary": "테이블의 컬럼 목록: 키 여부, 타입/길이, 데이터엘리먼트, 도메인, 체크 테이블, 한국어 설명·라벨.",
"when": "테이블 구조를 알아야 할 때(스키마 문서 대용). ROLLNAME 을 클릭하면 타입 상세로 이어진다.",
"params": [
{"name": "IV_TABLE", "label": "테이블", "type": "string", "required": True, "placeholder": "ZFIT0000"},
],
"samples": [
{"label": "고객 테이블 ZFIT0000 (권한별 유저ID 관리)", "params": {"IV_TABLE": "ZFIT0000"}},
{"label": "SAP 표준 MARA (자재 마스터, 304컬럼)", "params": {"IV_TABLE": "MARA"}},
{"label": "T000 (클라이언트)", "params": {"IV_TABLE": "T000"}},
{"label": "없는 테이블 → E", "params": {"IV_TABLE": "ZZZ_NOPE"}},
],
},
# ------------------------------------------------ 9
{
"name": "GET_TYPE_INFO", "title": "타입(데이터엘리먼트/도메인) 정보", "group": "4. 테이블 / 타입",
"summary": "데이터엘리먼트 또는 도메인 이름을 주면 타입·길이·라벨·변환 루틴·허용값(고정값) 목록을 준다.",
"when": "필드 값이 무슨 뜻인지, 어떤 값이 들어올 수 있는지 알고 싶을 때. 예: MTART 의 허용값은 T134 테이블, XFELD 는 X/공백.",
"params": [
{"name": "IV_TYPE_NAME", "label": "데이터엘리먼트 또는 도메인", "type": "string", "required": True, "placeholder": "BUKRS",
"desc": "먼저 데이터엘리먼트로 찾고, 없으면 도메인으로 간주"},
],
"samples": [
{"label": "BUKRS (회사 코드)", "params": {"IV_TYPE_NAME": "BUKRS"}},
{"label": "XFELD (체크박스, 고정값 2개)", "params": {"IV_TYPE_NAME": "XFELD"}},
{"label": "MTART (자재 유형, 값 테이블 T134)", "params": {"IV_TYPE_NAME": "MTART"}},
{"label": "고객 타입 ZE_AUTHCODE (권한관리코드, 고정값 7개)", "params": {"IV_TYPE_NAME": "ZE_AUTHCODE"}},
{"label": "없는 타입 → E", "params": {"IV_TYPE_NAME": "ZZZ_NOPE"}},
],
},
# ------------------------------------------------ 10
{
"name": "GET_FIELD_LIST_BY_TEXT", "title": "업무 용어로 필드 역검색", "group": "4. 테이블 / 타입",
"summary": "'회사코드' 같은 업무 용어로 데이터엘리먼트를 찾고, 그 엘리먼트를 쓰는 테이블·필드까지 같이 준다.",
"when": "테이블/필드 이름을 전혀 모를 때. 자연어 → 기술 이름 매핑의 출발점.",
"params": [
{"name": "IV_SEARCH_TEXT", "label": "검색어", "type": "string", "required": True, "placeholder": "회사코드"},
{"name": "IV_CUSTOM_ONLY", "label": "고객 개발만", "type": "flag", "desc": "X 면 Z/Y 엘리먼트와 Z/Y 테이블만"},
MAX_ROWS,
],
"samples": [
{"label": "'회사코드' 고객 개발만", "params": {"IV_SEARCH_TEXT": "회사코드", "IV_CUSTOM_ONLY": "X", "IV_MAX_ROWS": 10}},
{"label": "'레코드생성일' 전체", "params": {"IV_SEARCH_TEXT": "레코드생성일", "IV_MAX_ROWS": 10}},
{"label": "'자재' 전체 20건", "params": {"IV_SEARCH_TEXT": "자재", "IV_MAX_ROWS": 20}},
{"label": "'회사코드' 전체 10건 (BUKRS 계열은 사용 테이블 수천 건)", "params": {"IV_SEARCH_TEXT": "회사코드", "IV_MAX_ROWS": 10},
"note": "사용 테이블은 제한 없이 전부 온다 (2026-09-04 20건 상한 제거)"},
{"label": "일치 없음 → S, 0건", "params": {"IV_SEARCH_TEXT": "zzqqxx없는용어"}, "note": "2026-09-04 수정 전에는 응답 본문이 비어 있었음"},
{"label": "필수값 누락 → E", "params": {}},
],
"notes": ["USED_TABLES 는 엘리먼트당 제한 없이 전부 온다 (2026-09-04 20건 고정 상한 제거). 사용 테이블 총량이 안전 상한 50,000 을 넘으면 조회를 멈추고 TYPE=W + 안내 메시지 (그 뒤 후보의 USED_TABLES 는 빈값). 검색어를 좁히거나 IV_CUSTOM_ONLY=X, IV_MAX_ROWS 로 줄일 것.",
"2026-09-04 수정: 후보 0건이면 CHECK 로 빠져 응답 본문이 0바이트였던 결함. 지금은 빈 RESULT 를 정상 XML 로 돌려준다."],
},
# ------------------------------------------------ 11
{
"name": "GET_CTS_LIST", "title": "전송 요청(CTS) 목록", "group": "5. 변경 이력 (CTS / 버전)",
"summary": "기간 내 릴리즈된 전송 요청(Transport Request) 목록. 전송 요청은 '변경 사항 묶음' 으로, 개발→운영 이관 단위다.",
"when": "최근 누가 무엇을 바꿨는지 볼 때. 결과의 TRKORR 로 GET_CTS_OBJECT_LIST 를 부르면 바뀐 오브젝트가 나온다.",
"params": [
{"name": "IV_DATE_FROM", "label": "시작일", "type": "date", "required": True, "placeholder": "2025-01-01", "desc": "반드시 YYYY-MM-DD (YYYYMMDD 는 인식 못함)"},
{"name": "IV_DATE_TO", "label": "종료일", "type": "date", "required": True, "placeholder": "2026-12-31"},
{"name": "IV_USER", "label": "소유자", "type": "string", "placeholder": "PWC322063", "desc": "요청 소유자 사용자 ID"},
{"name": "IV_FUNCTION", "label": "요청 유형", "type": "string", "placeholder": "K", "desc": "K=워크벤치, W=커스터마이징. 생략 시 K+W"},
MAX_ROWS,
],
"samples": [
{"label": "최근 1년, 20건", "params": {"IV_DATE_FROM": "2025-09-03", "IV_DATE_TO": "2026-09-03", "IV_MAX_ROWS": 20}},
{"label": "2020~ 워크벤치만 5건", "params": {"IV_DATE_FROM": "2020-01-01", "IV_DATE_TO": "2026-12-31", "IV_FUNCTION": "K", "IV_MAX_ROWS": 5}},
{"label": "잘못된 날짜 형식 → E", "params": {"IV_DATE_FROM": "20200101", "IV_DATE_TO": "20261231"}, "note": "YYYYMMDD 는 빈 값으로 취급됨"},
],
"notes": ["릴리즈 완료(TRSTATUS=R) 된 상위 요청만. 하위 태스크는 제외."],
},
# ------------------------------------------------ 12
{
"name": "GET_CTS_OBJECT_LIST", "title": "전송 요청의 변경 오브젝트", "group": "5. 변경 이력 (CTS / 버전)",
"summary": "전송 요청(과 그 하위 태스크)에 담긴 오브젝트 목록.",
"when": "특정 배포에 무엇이 포함됐는지 확인할 때.",
"params": [
{"name": "IV_TRKORR", "label": "전송 요청 번호", "type": "string", "required": True, "placeholder": "EEDK9A1KSP"},
],
"samples": [
{"label": "작은 요청 EEDK9A1KSP (3건)", "params": {"IV_TRKORR": "EEDK9A1KSP"}},
{"label": "큰 요청 S4HK961147 (1000건+)", "params": {"IV_TRKORR": "S4HK961147"}},
{"label": "필수값 누락 → E", "params": {}},
],
},
# ------------------------------------------------ 13
{
"name": "GET_VERSION_LIST", "title": "오브젝트 버전 이력", "group": "5. 변경 이력 (CTS / 버전)",
"summary": "오브젝트의 저장된 버전 목록(버전 번호, 전송 요청, 작성자, 일시). 전송 시점마다 버전이 남는다.",
"when": "언제 누가 바꿨는지 추적할 때. 버전이 0건이면 한 번도 전송된 적 없는 오브젝트.",
"params": [
{"name": "IV_OBJNAME", "label": "오브젝트 이름", "type": "string", "required": True, "placeholder": "ZCL_FI_LS_ALV_GRID"},
{"name": "IV_OBJTYPE", "label": "버전 오브젝트 유형", "type": "string", "required": True, "placeholder": "CLSD",
"desc": "REPS=프로그램/Include, CLSD=클래스 정의, METH=메서드, FUNC=함수, TABD=테이블 정의. 함수그룹 TOP 은 L<그룹>TOP + REPS"},
],
"samples": [
{"label": "클래스 ZCL_FI_LS_ALV_GRID (6버전)", "params": {"IV_OBJNAME": "ZCL_FI_LS_ALV_GRID", "IV_OBJTYPE": "CLSD"}},
{"label": "함수그룹 TOP LZLEASE01TOP (6버전)", "params": {"IV_OBJNAME": "LZLEASE01TOP", "IV_OBJTYPE": "REPS"}},
{"label": "테이블 정의 ZRPFXT0010 (TABD, 8버전)", "params": {"IV_OBJNAME": "ZRPFXT0010", "IV_OBJTYPE": "TABD"},
"note": "테이블도 버전 이력은 나온다. 단 GET_VERSION_SOURCE 는 TABD 를 지원하지 않아 정의 diff 는 불가"},
{"label": "버전 이력 없는 프로그램 ZFI1000 (0건)", "params": {"IV_OBJNAME": "ZFI1000", "IV_OBJTYPE": "REPS"}, "note": "ZFI01 은 이관 패키지라 버전 이력이 없다"},
{"label": "필수값 누락 → E", "params": {}},
],
},
# ------------------------------------------------ 14
{
"name": "GET_VERSION_SOURCE", "title": "특정 버전의 소스", "group": "5. 변경 이력 (CTS / 버전)",
"summary": "버전 번호를 지정해 그 시점의 소스를 가져온다. 두 버전을 받아 diff 하면 변경 내용을 알 수 있다.",
"when": "변경 전/후 비교. GET_VERSION_LIST 로 버전 번호를 고른 뒤 두 버전을 받아 diff 한다.",
"params": [
{"name": "IV_OBJNAME", "label": "오브젝트 이름", "type": "string", "required": True, "placeholder": "LZLEASE01TOP"},
{"name": "IV_OBJTYPE", "label": "버전 오브젝트 유형", "type": "string", "required": True, "placeholder": "REPS"},
{"name": "IV_VERSNO", "label": "버전 번호", "type": "string", "placeholder": "00000", "desc": "생략/00000 = 활성(현재) 소스. 그 외 = 과거 버전"},
],
"samples": [
{"label": "활성 버전 LZLEASE01TOP", "params": {"IV_OBJNAME": "LZLEASE01TOP", "IV_OBJTYPE": "REPS"}},
{"label": "활성 버전 ZFI1000", "params": {"IV_OBJNAME": "ZFI1000", "IV_OBJTYPE": "REPS", "IV_VERSNO": "00000"}},
{"label": "과거 버전 LZLEASE01TOP 00001", "params": {"IV_OBJNAME": "LZLEASE01TOP", "IV_OBJTYPE": "REPS", "IV_VERSNO": "00001"}},
{"label": "없는 버전 → E", "params": {"IV_OBJNAME": "LZLEASE01TOP", "IV_OBJTYPE": "REPS", "IV_VERSNO": "99999"}},
{"label": "TABD 과거 버전 → E (테이블 정의는 미지원)", "params": {"IV_OBJNAME": "ZRPFXT0010", "IV_OBJTYPE": "TABD", "IV_VERSNO": "00001"},
"note": "GET_VERSION_LIST 에는 8버전이 있지만 소스 조회는 REPS 만 지원. 활성(00000)도 '소스를 읽을 수 없습니다' E"},
{"label": "CLSD 과거 버전 → E (REPS 만 지원)", "params": {"IV_OBJNAME": "ZCL_ZAA_AGENT_API", "IV_OBJTYPE": "CLSD", "IV_VERSNO": "00001"}},
],
"notes": ["과거 버전은 표준 함수 SVRS_GET_REPS_FROM_OBJECT 로 버전 DB 에서 읽는다 (2026-09-04 수정. 그 전엔 없는 함수 SVRS2_GET_VERSION_REPOS_46 을 불러 HTTP 500).",
"과거 버전은 IV_OBJTYPE=REPS(프로그램/Include) 만 지원. 클래스는 메서드 Include 이름(ZCL_X=====CM001 형태)으로 조회.",
"활성 버전은 READ REPORT 로 읽으므로 CLSD(클래스) 타입은 '소스를 읽을 수 없습니다' 가 정상.",
"함수 그룹 TOP include 는 내용이 안 바뀌어도 전송 때마다 버전이 생겨 여러 버전이 같은 소스일 수 있다."],
},
# ------------------------------------------------ 15
{
"name": "GET_WHERE_USED_LIST", "title": "사용처 역추적 (Where-Used)", "group": "6. 관계 추적",
"summary": "테이블/데이터엘리먼트/함수/프로그램을 어떤 프로그램들이 사용하는지 찾는다.",
"when": "영향도 분석: '이 테이블 바꾸면 어디가 깨지나'. 표준 오브젝트는 결과가 많으니 IV_MAX_ROWS 를 작게.",
"params": [
{"name": "IV_OBJ_TYPE", "label": "오브젝트 유형", "type": "string", "required": True, "placeholder": "TABL",
"desc": "검증됨: TABL(테이블/구조), DE(데이터엘리먼트), FUNC(함수), PROG(프로그램). 별칭 DS/STRU, DTEL/DD 도 동작. TB/TA 는 오류(rc=7)"},
{"name": "IV_OBJ_NAME", "label": "오브젝트 이름", "type": "string", "required": True, "placeholder": "ZFIT0000"},
MAX_ROWS,
],
"samples": [
{"label": "고객 테이블 ZFIT0000 사용처 (7건)", "params": {"IV_OBJ_TYPE": "TABL", "IV_OBJ_NAME": "ZFIT0000"}},
{"label": "테이블 T000 사용처 (5건)", "params": {"IV_OBJ_TYPE": "TABL", "IV_OBJ_NAME": "T000", "IV_MAX_ROWS": 5}},
{"label": "데이터엘리먼트 BUKRS (5건)", "params": {"IV_OBJ_TYPE": "DE", "IV_OBJ_NAME": "BUKRS", "IV_MAX_ROWS": 5}},
{"label": "함수 ZFI_CHECK_STCD2 (1건)", "params": {"IV_OBJ_TYPE": "FUNC", "IV_OBJ_NAME": "ZFI_CHECK_STCD2"}},
{"label": "잘못된 유형 TB → E (rc=7)", "params": {"IV_OBJ_TYPE": "TB", "IV_OBJ_NAME": "T000"}},
],
"notes": ["표준 오브젝트(MARA, BUKRS 등)는 사용처가 수천 건이라 느릴 수 있음. IV_MAX_ROWS 는 잘라주기만 하고 조회 자체를 줄이진 않음."],
},
# ------------------------------------------------ 16
{
"name": "GET_TCODE_INFO", "title": "트랜잭션 코드 ↔ 프로그램", "group": "6. 관계 추적",
"summary": "트랜잭션 코드(사용자가 SAP 에서 치는 명령어)로 실행 프로그램을 찾거나, 프로그램으로 트랜잭션 코드를 찾는다.",
"when": "사용자가 'VA01 화면' 이라고 말할 때 그 뒤의 프로그램을 찾거나, 프로그램이 어떤 메뉴로 실행되는지 볼 때.",
"params": [
{"name": "IV_TCODE", "label": "트랜잭션 코드", "type": "string", "placeholder": "ZFI1000", "desc": "둘 중 하나는 필수"},
{"name": "IV_PROGRAM", "label": "프로그램", "type": "string", "placeholder": "SAPMV45A"},
],
"samples": [
{"label": "T코드 ZFI1000 → 프로그램", "params": {"IV_TCODE": "ZFI1000"}},
{"label": "T코드 ZSCAL → 모듈 풀 SAPMZSFT0", "params": {"IV_TCODE": "ZSCAL"}},
{"label": "프로그램 SAPMV45A → T코드 25개", "params": {"IV_PROGRAM": "SAPMV45A"}},
{"label": "없는 T코드 → E", "params": {"IV_TCODE": "ZZZ_NOPE"}},
],
},
# ------------------------------------------------ 17
{
"name": "GET_OBJECT_TYPE", "title": "오브젝트 유형 판별", "group": "6. 관계 추적",
"summary": "이름 하나를 주면 그것이 실행 프로그램인지 Include 인지 테이블·구조·뷰·타입·클래스·함수·T코드인지 판별한다. Include 면 그것을 포함하는 메인 프로그램 목록(D010INC)까지 준다.",
"when": "사용처(where-used)나 CTS 오브젝트 목록에 섞여 나온 이름을 실행 단위(루트 프로그램)로 정리할 때. 이름 규칙에 의존하지 않고 Include → 메인 프로그램을 찾는 유일한 방법.",
"params": [
{"name": "IV_OBJ_NAME", "label": "오브젝트 이름", "type": "string", "required": True, "placeholder": "ZFIMLS0010F01",
"desc": "프로그램/Include/테이블/구조/뷰/테이블타입/데이터엘리먼트/도메인/클래스/인터페이스/함수그룹/함수/T코드 이름"},
],
"samples": [
{"label": "Include → 메인 프로그램 (ZFIMLS0010F01)", "params": {"IV_OBJ_NAME": "ZFIMLS0010F01"}, "note": "KIND=INCLUDE, MASTERS 에 ZFIMLS0010"},
{"label": "함수그룹 Include (LZLEASE01TOP)", "params": {"IV_OBJ_NAME": "LZLEASE01TOP"}, "note": "MASTERS 에 SAPLZLEASE01"},
{"label": "실행 프로그램 + T코드 동명 (ZFI1000)", "params": {"IV_OBJ_NAME": "ZFI1000"}, "note": "RESULT 2행: PROG/REPORT 와 TRAN/TCODE"},
{"label": "테이블 (ZFIT0000)", "params": {"IV_OBJ_NAME": "ZFIT0000"}},
{"label": "클래스 풀 이름 (ZCL_FI_LS_ALV_GRID=...CP 형태는 TRDIR 로 CLASS_POOL)", "params": {"IV_OBJ_NAME": "ZCL_FI_LS_ALV_GRID"}, "note": "클래스 이름은 CLASS"},
{"label": "없는 이름 → E", "params": {"IV_OBJ_NAME": "ZZZ_NOPE"}},
],
"notes": ["2026-09-07 추가 (cts-diff-tester 의 루트 프로그램 정규화용). 같은 이름이 여러 유형에 있으면 RESULT 에 전부 나오고 KIND 는 첫 행(프로그램 우선).",
"MASTERS 는 D010INC 기준이라 활성화된 적 없는 Include 는 비어 있을 수 있다 (그때 RETURN.TYPE=W)."],
},
]
CATALOG = {
"methods": METHODS,
"fields": {k: {"label": v[0], "desc": v[1]} for k, v in FIELDS.items()},
"value_maps": VALUE_MAPS,
"links": LINKS,
"links_by_object": LINKS_BY_OBJECT,
"code_fields": CODE_FIELDS,
"list_fields": sorted(LIST_FIELDS),
"conventions": [
"요청: POST /sap/bc/ZAA_ICF/{METHOD}, Content-Type: application/json, 바디 {\"INPUT\": {\"IV_...\": ...}}",
"응답: asXML. <DATA> 아래 <RETURN>(TYPE/MESSAGE) + 결과. 반복 행은 <item>.",
"응답 XML 선언이 encoding=\"utf-16\" 이지만 실제는 UTF-8 → 이 서버가 선언을 떼고 파싱.",
"날짜(DATS) 파라미터는 YYYY-MM-DD.",
"플래그(CHAR1) 파라미터는 'X' 가 참.",
"목록 API 의 IV_MAX_ROWS 는 생략하면 전건(안전 상한 50,000). 지정해서 잘리면 RETURN.TYPE=W, MESSAGE 에 전체 건수, RETURN.TOTAL_ROWS.",
"이름류 파라미터는 서버가 대문자로 바꾸지만, 검색어(IV_SEARCH_TEXT)는 원문 그대로 비교.",
],
}
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""ZAA_ICF (ZCL_ZAA_AGENT_API) MCP 서버.
catalog.py 의 16개 메서드를 MCP 툴로 노출한다 (툴 이름 = 메서드명 소문자, 파라미터 = IV_ 접두사 뗀 소문자).
SAP 호출은 sap_client.sap_call (../abap-api-tester 의 웹 테스터와 같은 코드).
실행: python mcp_server.py (stdio — Claude Code / Claude Desktop 용)
python mcp_server.py --http 8766 (streamable-http, http://127.0.0.1:8766/mcp)
접속정보: .env 또는 환경변수 SAP_URL / SAP_USER / SAP_PASS (sap_client 가 읽는다)
환경변수 (선택):
ABAP_MCP_DEFAULT_MAX_ROWS 목록 툴에서 max_rows 생략 시 상한 (기본 200). LLM 컨텍스트 보호용
ABAP_MCP_MAX_OUTPUT_CHARS 툴 응답 JSON 최대 문자 수 (기본 120000). 넘치면 소스 → 행 순으로 잘라내고 알린다
ABAP_MCP_TIMEOUT SAP 호출 타임아웃 초 (기본 180)
"""
import inspect
import json
import os
import sys
from typing import Annotated, Any
import anyio
from pydantic import Field
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from catalog import CATALOG, CODE_FIELDS, FIELDS, LIST_FIELDS, METHODS, VALUE_MAPS
import sap_client
from sap_client import CONFIG, sap_call
DEFAULT_MAX_ROWS = int(os.environ.get("ABAP_MCP_DEFAULT_MAX_ROWS", "200"))
MAX_OUTPUT_CHARS = int(os.environ.get("ABAP_MCP_MAX_OUTPUT_CHARS", "120000"))
TIMEOUT = int(os.environ.get("ABAP_MCP_TIMEOUT", "180"))
# 소스를 돌려주는 메서드에는 줄 범위 파라미터를 붙여 LLM 이 페이지 단위로 읽게 한다
SOURCE_METHODS = {"GET_PROGRAM_SOURCE", "GET_VERSION_SOURCE", "GET_FUNCTION_DETAIL"}
INSTRUCTIONS = """SAP ABAP 시스템(ZCL_ZAA_AGENT_API, 읽기 전용)을 탐색하는 툴 모음. 모든 툴은 조회만 하며 아무것도 바꾸지 않는다.
탐색 흐름
- 어디에 뭐가 있나: get_package_list → get_object_list_by_package(OBJECT 값이 다음 툴을 정한다: PROG→get_program_source, FUGR→get_function_list, TABL→get_table_fields, DTEL/DOMA→get_type_info, TRAN→get_tcode_info)
- 사용자가 'VA01 화면' 처럼 T코드로 말하면 get_tcode_info 로 프로그램을 찾은 뒤 get_program_source(with_include=true)
- 업무 용어('회사코드')만 알 때: get_field_list_by_text → 데이터엘리먼트/테이블 → get_table_fields / get_type_info
- 영향도 분석: get_where_used_list. 변경 이력: get_cts_list → get_cts_object_list, get_version_list → get_version_source 두 버전 diff
응답 읽는 법
- 모든 응답은 {"return": {"type","message","total_rows"}, ...결과}. type S=성공, W=경고(대개 결과가 상한에서 잘림), E=입력 오류/미존재, X=통신·파싱 오류.
- 목록 툴은 max_rows 를 생략하면 %d 건까지만 가져온다. 잘리면 type=W 와 total_rows 로 전체 건수를 알려주니, 더 필요하면 max_rows 를 올리거나 0(전건, 안전 상한 50,000)으로 부른다. 페이지(offset) 개념은 없다. 정렬이 유형/이름순이라 잘리면 뒤쪽 유형이 통째로 빠질 수 있다.
- 응답이 너무 크면 이 서버가 소스 → 목록 행 순으로 잘라내고 "_truncated" 에 알린다. 소스는 line_from/line_to 로 나눠 읽는다.
- 이름류 입력은 서버가 대문자로 바꾸지만 검색어(search_text)는 원문 그대로 부분 일치·대소문자 구분. 날짜는 YYYY-MM-DD.
- 코드값 뜻이 궁금하면 explain_fields 로 필드 사전을 본다 (예: SUBC, OBJECT, TRFUNCTION).
""" % DEFAULT_MAX_ROWS
mcp = MCPServer(
"abap-sap",
title="SAP ABAP 탐색 (ZAA_ICF)",
instructions=INSTRUCTIONS,
version="1.0.0",
)
# ------------------------------------------------------------------ 카탈로그 → 툴 시그니처
def _arg_name(p: dict) -> str:
n = p["name"]
return (n[3:] if n.startswith("IV_") else n).lower()
def _py_type(p: dict):
return {"number": int, "flag": bool}.get(p["type"], str)
def _param_desc(p: dict) -> str:
d = p.get("label", "")
if p.get("desc"):
d += ". " + p["desc"]
if p.get("placeholder") and p["type"] != "flag":
d += f" (예: {p['placeholder']})"
if p["type"] == "date":
d += ". 형식 YYYY-MM-DD"
if p["name"] == "IV_MAX_ROWS":
d = f"최대 건수. 생략 시 {DEFAULT_MAX_ROWS}, 0 이면 전건(안전 상한 50,000). 잘리면 return.type=W + total_rows"
return d
def _tool_description(m: dict) -> str:
lines = [f"[{m['title']}] {m['summary']}", f"언제: {m['when']}"]
for n in m.get("notes", []):
lines.append(f"주의: {n}")
ex = [s for s in m.get("samples", []) if s["params"] and "→ E" not in s["label"]][:3]
if ex:
lines.append("예: " + " / ".join(
f"{s['label']}: {json.dumps({_arg_name({'name': k}): v for k, v in s['params'].items()}, ensure_ascii=False)}"
for s in ex))
return "\n".join(lines)
def _build_signature(m: dict) -> inspect.Signature:
params = []
for p in m["params"]:
name, typ = _arg_name(p), _py_type(p)
if p.get("required"):
ann, default = Annotated[typ, Field(description=_param_desc(p))], inspect.Parameter.empty
elif typ is bool:
ann, default = Annotated[bool, Field(description=_param_desc(p))], False
else:
ann, default = Annotated[typ | None, Field(description=_param_desc(p))], None
params.append(inspect.Parameter(name, inspect.Parameter.KEYWORD_ONLY, default=default, annotation=ann))
if m["name"] in SOURCE_METHODS:
params.append(inspect.Parameter("line_from", inspect.Parameter.KEYWORD_ONLY, default=None,
annotation=Annotated[int | None, Field(description="소스 시작 줄(1부터). 긴 소스를 나눠 읽을 때. 생략 시 처음부터")]))
params.append(inspect.Parameter("line_to", inspect.Parameter.KEYWORD_ONLY, default=None,
annotation=Annotated[int | None, Field(description="소스 끝 줄(포함). 생략 시 끝까지")]))
# 필수 파라미터가 앞에 오도록 (KEYWORD_ONLY 라 순서 제약은 없지만 스키마 가독성)
params.sort(key=lambda x: x.default is not inspect.Parameter.empty)
return inspect.Signature(params, return_annotation=dict[str, Any])
# ------------------------------------------------------------------ 호출 + 응답 다듬기
def _to_sap_params(m: dict, kwargs: dict) -> dict:
out = {}
for p in m["params"]:
v = kwargs.get(_arg_name(p))
if p["type"] == "flag":
if v:
out[p["name"]] = "X"
elif p["name"] == "IV_MAX_ROWS":
if v is None:
out[p["name"]] = DEFAULT_MAX_ROWS
elif v > 0:
out[p["name"]] = v
# 0 → 전건 (파라미터 생략)
elif v is not None and str(v) != "":
out[p["name"]] = v
return out
def _slice_sources(obj, line_from, line_to):
"""CODE_FIELDS 를 줄 범위로 자르고 *_LINES(전체 줄 수) 를 붙인다. include/screen 목록 안의 소스도 같이."""
if isinstance(obj, dict):
for k in list(obj.keys()):
v = obj[k]
if k in CODE_FIELDS and isinstance(v, str):
lines = v.split("\n")
obj[k + "_LINES"] = len(lines)
lo = max(1, line_from or 1)
hi = min(len(lines), line_to or len(lines))
if lo > 1 or hi < len(lines):
obj[k] = "\n".join(lines[lo - 1:hi])
obj[k + "_RANGE"] = f"{lo}-{hi}"
else:
_slice_sources(v, line_from, line_to)
elif isinstance(obj, list):
for x in obj:
_slice_sources(x, line_from, line_to)
def _size(obj) -> int:
return len(json.dumps(obj, ensure_ascii=False))
def _shrink(data: dict) -> list[str]:
"""응답이 MAX_OUTPUT_CHARS 를 넘으면 그때그때 가장 큰 항목(소스 필드 또는 목록)을 절반으로 줄인다.
잘린 자리에는 *_CUT / *_TOTAL 표식을 남기고, 마지막에 표식을 모아 설명 문장 목록으로 돌려준다.
소스는 1,000자 아래로, 목록은 1행 아래로는 줄이지 않는다.
"""
if _size(data) <= MAX_OUTPUT_CHARS:
return []
def candidates(obj, path=""):
if isinstance(obj, dict):
for k, v in obj.items():
if k in CODE_FIELDS and isinstance(v, str) and len(v) >= 2000:
yield len(v), obj, k, path + "/" + k
elif k in LIST_FIELDS and isinstance(v, list) and len(v) > 1:
yield _size(v), obj, k, path + "/" + k
yield from candidates(v, path + "/" + k)
else:
yield from candidates(v, path + "/" + k)
elif isinstance(obj, list):
for i, x in enumerate(obj):
yield from candidates(x, f"{path}[{i}]")
for _ in range(400):
if _size(data) <= MAX_OUTPUT_CHARS:
break
cands = sorted(candidates(data), key=lambda t: -t[0])
if not cands:
break
_, holder, key, _ = cands[0]
v = holder[key]
if isinstance(v, str):
keep = v[: max(1000, len(v) // 2)]
keep = keep[: keep.rfind("\n") + 1] or keep
holder[key + "_CUT"] = holder.get(key + "_CUT", 0) + (v.count("\n") - keep.count("\n"))
holder[key] = keep
else:
holder.setdefault(key + "_TOTAL", len(v))
holder[key] = v[: max(1, len(v) // 2)]
# 표식을 모아 설명으로
notes, cut_sources = [], 0
def collect(obj, path=""):
nonlocal cut_sources
if isinstance(obj, dict):
for k, v in obj.items():
if k.endswith("_TOTAL") and k[:-6] in LIST_FIELDS:
notes.append(f"{(path + '/' + k[:-6]).lstrip('/')}: {v}행 중 앞 {len(obj[k[:-6]])}행만 남김 (max_rows 를 줄이거나 조건을 좁힐 것)")
elif k.endswith("_CUT") and k[:-4] in CODE_FIELDS:
if path.count("[") == 0:
notes.append(f"{(path + '/' + k[:-4]).lstrip('/')}: 뒤쪽 {v}줄 생략 (line_from/line_to 로 이어서 읽을 것)")
else:
cut_sources += 1
else:
collect(v, path + "/" + k)
elif isinstance(obj, list):
for i, x in enumerate(obj):
collect(x, f"{path}[{i}]")
collect(data)
if cut_sources:
notes.append(f"목록 안 소스 {cut_sources}개의 뒷부분 생략 (해당 Include/화면을 개별 툴로 다시 조회하거나 line_from/line_to 사용)")
notes.insert(0, f"응답이 {MAX_OUTPUT_CHARS:,}자를 넘어 이 서버가 잘라냈습니다 (ABAP_MCP_MAX_OUTPUT_CHARS)")
return notes
def _call(m: dict, kwargs: dict) -> dict:
if not CONFIG["user"] or not CONFIG["password"]:
raise ToolError("SAP 접속 정보가 없습니다. .env 또는 환경변수 SAP_USER / SAP_PASS 를 설정하세요.")
params = _to_sap_params(m, kwargs)
res = sap_call(m["name"], params, timeout=TIMEOUT)
if res["error"] and res["parsed"] is None: # 네트워크/인증/파싱 — 결과 자체가 없음
hint = ""
if res.get("http_status") == 401:
hint = " (인증 실패: SAP_USER / SAP_PASS 확인)"
raise ToolError(f"{m['name']} 호출 실패{hint}: {res['error']} [HTTP {res.get('http_status')}]")
parsed = res["parsed"] if isinstance(res["parsed"], dict) else {"RESULT": res["parsed"]}
ret_raw = parsed.pop("RETURN", {}) if isinstance(parsed.get("RETURN"), dict) else {}
out: dict[str, Any] = {
"return": {
"type": res["return"]["type"],
"message": res["return"]["message"],
},
}
try:
out["return"]["total_rows"] = int(ret_raw.get("TOTAL_ROWS") or 0)
except (TypeError, ValueError):
pass
if res["error"]: # ICF 에러 봉투 (<error>) 등: 본문은 있으나 오류
out["return"]["error"] = res["error"]
out["return"]["http_status"] = res.get("http_status")
out.update(parsed)
if m["name"] in SOURCE_METHODS:
_slice_sources(out, kwargs.get("line_from"), kwargs.get("line_to"))
notes = _shrink(out)
if notes:
out["_truncated"] = notes
out["_meta"] = {"sap_method": m["name"], "sap_params": params, "ms": res["ms"], "raw_bytes": res.get("raw_size")}
return out
def _make_tool(m: dict):
async def tool(**kwargs):
return await anyio.to_thread.run_sync(_call, m, kwargs)
tool.__name__ = m["name"].lower()
tool.__doc__ = _tool_description(m)
tool.__signature__ = _build_signature(m) # inspect.signature 가 이것을 읽는다
tool.__annotations__ = {p.name: p.annotation for p in tool.__signature__.parameters.values()}
tool.__annotations__["return"] = dict[str, Any]
return tool
for _m in METHODS:
mcp.add_tool(_make_tool(_m), name=_m["name"].lower(), title=_m["title"])
# ------------------------------------------------------------------ 보조 툴
@mcp.tool(title="필드 사전 / 코드값 해석")
def explain_fields(
fields: Annotated[list[str] | None, Field(description="응답 필드명 목록 (예: [\"SUBC\", \"OBJECT\", \"TRFUNCTION\"]). 생략하면 전체 사전")] = None,
) -> dict[str, Any]:
"""응답 컬럼의 한국어 뜻과 코드값 해석표를 돌려준다 (SAP 을 모르는 사용자에게 결과를 설명할 때).
예: SUBC=1 은 실행 프로그램, OBJECT=FUGR 은 함수그룹, TRFUNCTION=K 는 워크벤치 요청.
"""
names = [f.upper() for f in fields] if fields else sorted(FIELDS)
out = {}
for n in names:
entry: dict[str, Any] = {}
if n in FIELDS:
entry["label"], entry["desc"] = FIELDS[n]
if n in VALUE_MAPS:
entry["values"] = VALUE_MAPS[n]
out[n] = entry or "사전에 없음"
return out
@mcp.tool(title="접속 상태 / 호출 규약")
def sap_connection_info() -> dict[str, Any]:
"""현재 SAP 접속 대상과 호출 규약을 보여준다 (비밀번호는 제외). 툴이 X 오류를 낼 때 먼저 확인."""
return {
"base_url": CONFIG["base_url"],
"user": CONFIG["user"] or "(미설정)",
"password_set": bool(CONFIG["password"]),
"verify_ssl": CONFIG["verify_ssl"],
"default_max_rows": DEFAULT_MAX_ROWS,
"max_output_chars": MAX_OUTPUT_CHARS,
"conventions": CATALOG["conventions"],
"tools": [m["name"].lower() for m in METHODS],
}
# ------------------------------------------------------------------ 실행
def main():
args = sys.argv[1:]
if args and args[0] == "--http":
port = int(args[1]) if len(args) > 1 else 8766
print(f"abap-sap MCP (streamable-http) -> http://127.0.0.1:{port}/mcp", file=sys.stderr)
mcp.run(transport="streamable-http", host="127.0.0.1", port=port)
else:
# stdio: stdout 은 프로토콜 채널이므로 로그는 stderr 로만
print(f"abap-sap MCP (stdio) SAP={CONFIG['base_url']} user={CONFIG['user'] or '(미설정)'}", file=sys.stderr)
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
+3
View File
@@ -0,0 +1,3 @@
# MCP 서버(mcp_server.py) 실행에 필요하다. pip install -r requirements.txt
# (웹 테스터는 ../abap-api-tester 로 분리됐고 표준 라이브러리만 쓴다)
mcp>=2.1,<3
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""ZAA_ICF (ZCL_ZAA_AGENT_API) 호출 클라이언트. MCP 서버(mcp_server.py)와 웹 테스터(../abap-api-tester/server.py)가 공유한다.
- 표준 라이브러리만 사용 (Python 3.10+)
- 접속정보: .env 파일 또는 환경변수 SAP_URL / SAP_USER / SAP_PASS / SAP_VERIFY_SSL
호출 규약 (실측, README 참고):
POST {SAP_URL}/{METHOD}, Content-Type: application/json, 바디 {"INPUT": {"IV_...": ...}}
응답 asXML. XML 선언은 utf-16 이지만 실제 UTF-8 → BOM/선언 제거 후 파싱. 소스 안 제어문자(0x0C) 제거.
"""
import base64
import json
import os
import re
import ssl
import time
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
try: # abap-indexing: 패키지(sap.sap_client)로도, 스크립트(mcp_server.py 옆)로도 import 되게
from .catalog import LIST_FIELDS
except ImportError:
from catalog import LIST_FIELDS
ROOT = Path(__file__).resolve().parent
DEFAULT_URL = "https://pwcs4h.pwcc.co.kr:44310/sap/bc/ZAA_ICF"
def load_env():
# abap-indexing: sap/.env 다음에 저장소 루트 .env 도 본다 (LLM 키와 같은 파일에 SAP_* 를 둔다)
for p in (ROOT / ".env", ROOT.parent / ".env"):
if not p.exists():
continue
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
load_env()
CONFIG = {
"base_url": os.environ.get("SAP_URL", DEFAULT_URL).rstrip("/"),
"user": os.environ.get("SAP_USER", ""),
"password": os.environ.get("SAP_PASS", ""),
"verify_ssl": os.environ.get("SAP_VERIFY_SSL", "0") == "1",
}
def ssl_context():
ctx = ssl.create_default_context()
if not CONFIG["verify_ssl"]:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
# ------------------------------------------------------------------ XML -> JSON
def xml_to_data(el):
"""asXML 요소를 dict/list/str 로 변환.
행 반복은 list 로: 보통 <item> 이지만, 행 타입이 DDIC 구조인 테이블(RPY 의 파라미터 목록 등)은
<RSIMP>, <RSEXP>, <RSTBL> 처럼 구조 이름이 행 태그가 된다. 같은 태그가 반복되거나
부모가 알려진 테이블 필드(LIST_FIELDS)면 list 로 본다. (2026-09-04: 마지막 행만 남던 버그 수정)
"""
children = list(el)
if not children:
return el.text or ""
tags = {c.tag for c in children}
if len(tags) == 1 and (children[0].tag == "item" or len(children) > 1 or el.tag in LIST_FIELDS):
return [xml_to_data(c) for c in children]
out = {}
for c in children:
out[c.tag] = xml_to_data(c)
return out
def coerce_lists(obj):
"""빈 테이블 <RESULT/> 은 "" 로 오므로 알려진 테이블 필드는 [] 로 통일."""
if isinstance(obj, dict):
for k, v in list(obj.items()):
if k in LIST_FIELDS and v == "":
obj[k] = []
else:
obj[k] = coerce_lists(v)
elif isinstance(obj, list):
obj = [coerce_lists(x) for x in obj]
return obj
def normalize_xml(raw: bytes) -> str:
"""서버 응답 정규화: BOM 제거, 잘못된 utf-16 선언 제거, XML 1.0 비허용 제어문자 제거."""
text = raw.decode("utf-8", errors="replace").lstrip("")
return normalize_xml_text(text)
def normalize_xml_text(text: str) -> str:
"""이미 문자열로 디코딩된 응답에 같은 정규화를 적용 (Bruno 가 보내온 본문용)."""
text = text.lstrip("")
text = re.sub(r"^<\?xml[^>]*\?>", "", text)
return "".join(ch for ch in text if ord(ch) >= 0x20 or ch in "\t\n\r")
def parse_asxml(text: str) -> dict:
"""정규화된 asXML 텍스트 -> {parsed, return, error}. sap_call 과 /api/render 가 공유."""
out = {"parsed": None, "return": {"type": "?", "message": ""}, "error": None}
try:
root = ET.fromstring(text)
except ET.ParseError as e:
out["error"] = f"XML 파싱 실패: {e}"
out["return"] = {"type": "X", "message": out["error"]}
return out
if root.tag == "error": # ICF 핸들러 에러 봉투 (Method 없음 / ABAP 덤프)
err = xml_to_data(root)
out["parsed"] = {"error": err}
out["error"] = f"{err.get('code')}: {err.get('message')}"
out["return"] = {"type": "X", "message": out["error"]}
return out
data_el = root.find(".//DATA")
data = coerce_lists(xml_to_data(data_el)) if data_el is not None else {}
out["parsed"] = data
ret = data.get("RETURN") if isinstance(data, dict) else None
if isinstance(ret, dict):
out["return"] = {"type": ret.get("TYPE") or "-", "message": ret.get("MESSAGE") or ""}
return out
# ------------------------------------------------------------------ SAP 호출
def sap_call(method: str, params: dict, timeout: int = 180) -> dict:
url = f"{CONFIG['base_url']}/{method}"
body_obj = {"INPUT": {k: v for k, v in params.items() if v not in ("", None)}}
body = json.dumps(body_obj, ensure_ascii=False)
auth = "Basic " + base64.b64encode(f"{CONFIG['user']}:{CONFIG['password']}".encode()).decode()
req = urllib.request.Request(
url, data=body.encode("utf-8"), method="POST",
headers={"Content-Type": "application/json", "Authorization": auth},
)
curl = (f"curl -sk -u '{CONFIG['user']}:****' -H 'Content-Type: application/json' "
f"--data '{body}' '{url}'")
result = {
"method": method,
"request": {"url": url, "body": body_obj, "curl": curl},
"http_status": None, "ms": None, "raw": "", "parsed": None,
"return": {"type": "?", "message": ""}, "error": None,
}
t0 = time.time()
try:
with urllib.request.urlopen(req, context=ssl_context(), timeout=timeout) as r:
raw, status = r.read(), r.status
except urllib.error.HTTPError as e:
raw, status = e.read(), e.code
except Exception as e: # 네트워크/인증 등
result["ms"] = int((time.time() - t0) * 1000)
result["error"] = f"{type(e).__name__}: {e}"
result["return"] = {"type": "X", "message": result["error"]}
return result
result["ms"] = int((time.time() - t0) * 1000)
result["http_status"] = status
raw_text = raw.decode("utf-8", errors="replace")
result["raw"] = raw_text
result["raw_size"] = len(raw)
result.update(parse_asxml(normalize_xml(raw)))
return result
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""MCP 서버 연쇄 확인: stdio 로 mcp_server.py 를 띄워 툴 목록과 몇 가지 실제 호출을 점검한다.
python tools/test_mcp.py # 기본 케이스
python tools/test_mcp.py --shrink # 응답 상한을 15,000자로 낮춰 잘라내기 동작까지
접속 정보는 .env (SAP_USER / SAP_PASS). mcp 패키지 필요 (pip install -r requirements.txt).
"""
import asyncio
import json
import os
import sys
from pathlib import Path
from mcp.client import Client
from mcp.client.stdio import StdioServerParameters
ROOT = Path(__file__).resolve().parents[1]
CASES = [
("sap_connection_info", {}),
("explain_fields", {"fields": ["SUBC", "TRFUNCTION"]}),
("get_package_list", {"max_rows": 3}), # W + total_rows
("get_tcode_info", {"tcode": "ZSCAL"}),
("get_program_source", {"program": "ZFI1000", "line_from": 1, "line_to": 5}),
("get_program_source", {"program": "SAPMZSFT0", "with_include": True, "with_screen": True, "line_to": 4}),
("get_function_detail", {"function": "ZFI_CHECK_STCD2", "line_to": 3}),
("get_version_source", {"objname": "LZLEASE01TOP", "objtype": "REPS", "versno": "00001", "line_to": 2}),
("get_where_used_list", {"obj_type": "TABL", "obj_name": "ZFIT0000"}),
("get_table_fields", {"table": "ZZZ_NOPE"}), # E 응답 (툴 오류 아님)
("get_cts_list", {"date_from": "2026-01-01"}), # 필수 누락 → 툴 오류
]
SHRINK_CASES = [
("get_program_source", {"program": "SAPMV45A", "with_include": True}), # 원문 4MB
("get_object_list_by_package", {"package": "ZFI01", "obj_type": "PROG", "max_rows": 0}),
]
def brief(d):
if isinstance(d, dict):
return {k: (f"<str {len(v)}>" if isinstance(v, str) and len(v) > 60 else brief(v)) for k, v in d.items()}
if isinstance(d, list):
return f"<list {len(d)}>" + (" first=" + json.dumps(brief(d[0]), ensure_ascii=False)[:160] if d else "")
return d
async def main(shrink: bool):
env = dict(os.environ)
if shrink:
env["ABAP_MCP_MAX_OUTPUT_CHARS"] = "15000"
params = StdioServerParameters(command=sys.executable, args=[str(ROOT / "mcp_server.py")], cwd=str(ROOT), env=env)
fails = 0
async with Client(params) as c:
tools = (await c.list_tools()).tools
print(f"tools: {len(tools)} {[t.name for t in tools]}")
for name, args in (SHRINK_CASES if shrink else CASES):
r = await c.call_tool(name, args)
txt = r.content[0].text if r.content else ""
print(f"\n=== {name} {json.dumps(args, ensure_ascii=False)} error={r.is_error} chars={len(txt):,}")
if r.is_error:
print(" ", txt.splitlines()[0][:200])
continue
d = json.loads(txt)
print(" ", json.dumps(brief(d), ensure_ascii=False)[:700])
if d.get("_truncated"):
for n in d["_truncated"]:
print(" -", n)
if shrink and len(txt) > 15000:
fails += 1
print(" !! 상한 초과")
print("\nOK" if not fails else f"\nFAIL {fails}")
return fails
if __name__ == "__main__":
sys.exit(asyncio.run(main("--shrink" in sys.argv)))
+113
View File
@@ -0,0 +1,113 @@
"""Stage 0 (ingest/from_sap.py) — SAP 응답을 가짜로 넣어 저장 형식과 normalize 연결을 확인한다.
실제 SAP 부르지 않는다. 응답 모양은 ../abap-api-tester/tools/spec/samples 실측 JSON 같다.
"""
from __future__ import annotations
import json
import pytest
from ingest import from_sap
from ingest.normalize import classify, parse_collected_file, run as normalize_run
PROGRAM_LIST = {
"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": "2"},
"RESULT": [
{"OBJ_NAME": "ZFIR0010", "TEXT": "거래처 I/F 이력", "SUBC": "1", "UDAT": "2024-01-02", "UNAM": "U1"},
{"OBJ_NAME": "SAPMZSFT0", "TEXT": "달력 유지보수", "SUBC": "M", "UDAT": "2020-10-16", "UNAM": "U2"},
],
}
SOURCES = {
"ZFIR0010": {
"RETURN": {"TYPE": "S", "MESSAGE": "", "TOTAL_ROWS": "0"},
"PROGRAM": "ZFIR0010",
"SOURCE_CODE": "REPORT zfir0010.\nINCLUDE zfir0010_top.\nSTART-OF-SELECTION.\n PERFORM main.\n",
"INCLUDE_LIST": [
{"INCL_NAME": "ZFIR0010_TOP", "SOURCE": "DATA gv_bukrs TYPE bukrs.\n"},
{"INCL_NAME": "DB__SSEL", "SOURCE": ""}, # 빈 소스는 버린다
{"INCL_NAME": "ZFIR0010_TOP", "SOURCE": "DATA dup.\n"}, # 중복은 버린다
],
"SCREEN_LIST": "",
"TCODE_LIST": [{"TCODE": "ZFIR0010", "TTEXT": "거래처 I/F 이력조회", "PGMNA": "ZFIR0010"}],
},
"SAPMZSFT0": {
"RETURN": {"TYPE": "E", "MESSAGE": "프로그램 소스를 읽을 수 없습니다", "TOTAL_ROWS": "0"},
"PROGRAM": "SAPMZSFT0", "SOURCE_CODE": "", "INCLUDE_LIST": "", "SCREEN_LIST": "", "TCODE_LIST": "",
},
}
def fake_sap_call(method: str, params: dict, timeout: int = 180) -> dict:
if method == "GET_PROGRAM_LIST":
assert params["IV_PACKAGE"] == "ZFI01"
data = PROGRAM_LIST
elif method == "GET_PROGRAM_SOURCE":
assert params["IV_WITH_INCLUDE"] == "X"
data = SOURCES[params["IV_PROGRAM"]]
else:
raise AssertionError(method)
ret = data["RETURN"]
return {"method": method, "parsed": data, "error": None,
"return": {"type": ret["TYPE"], "message": ret["MESSAGE"]}}
@pytest.fixture
def sap(monkeypatch):
import sap.sap_client as client
monkeypatch.setattr(client, "sap_call", fake_sap_call)
monkeypatch.setattr(from_sap.time, "sleep", lambda s: None)
def test_run_writes_raw_files_and_records_errors(tmp_path, sap):
stats = from_sap.run(["zfi01"], tmp_path)
assert stats == {"packages": 1, "listed": 2, "fetched": 1, "skipped": 0, "errors": 1, "includes": 2}
# 패키지 목록: normalize 가 package_list 로 분류하는 모양
pkg = parse_collected_file((tmp_path / "ZFI01.txt").read_text(encoding="utf-8"))
assert classify(pkg) == "package_list"
assert pkg[0] == {"DEVCLASS": "ZFI01", "OBJ_NAME": "ZFIR0010", "TEXT": "거래처 I/F 이력",
"CREATED_ON": "", "CHANGED_ON": "2024-01-02"}
# 프로그램 소스: 메인 + 인클루드(빈 것·중복 제외), 줄바꿈은 JSON 이스케이프로 보존
prog = parse_collected_file((tmp_path / "ZFIR0010.txt").read_text(encoding="utf-8"))
assert classify(prog) == "program_source"
assert prog["DESCRIPTION"] == "거래처 I/F 이력"
assert [i["INCLUDE"] for i in prog["INCLUDE_PROGRAM"]] == ["ZFIR0010", "ZFIR0010_TOP"]
assert prog["INCLUDE_PROGRAM"][0]["SOURCE_CODE"].splitlines()[0] == "REPORT zfir0010."
assert prog["TEXT_SYMBOL"] == []
assert prog["TCODE_LIST"][0]["TCODE"] == "ZFIR0010"
# 실패한 프로그램은 파일 없이 _errors.log 에
assert not (tmp_path / "SAPMZSFT0.txt").exists()
assert "SAPMZSFT0\tSOURCE\tSAP 오류" in (tmp_path / "_errors.log").read_text(encoding="utf-8")
def test_rerun_skips_existing_unless_force(tmp_path, sap):
from_sap.run(["ZFI01"], tmp_path)
again = from_sap.run(["ZFI01"], tmp_path)
assert again["skipped"] == 1 and again["fetched"] == 0
forced = from_sap.run(["ZFI01"], tmp_path, force=True)
assert forced["fetched"] == 1
def test_normalize_consumes_output_and_builds_tcodes(tmp_path, sap):
raw, out = tmp_path / "raw", tmp_path / "normalized"
from_sap.run(["ZFI01"], raw)
stats = normalize_run(raw, out)
assert stats["programs"] == 1 and stats["package_rows"] == 2 and stats["errors"] == 0
assert (out / "ZFIR0010" / "ZFIR0010.abap").read_text(encoding="utf-8").startswith("REPORT zfir0010.\n")
assert (out / "ZFIR0010" / "ZFIR0010_TOP.abap").exists()
meta = json.loads((out / "ZFIR0010" / "ZFIR0010.meta.json").read_text(encoding="utf-8"))
assert meta["description"] == "거래처 I/F 이력" and len(meta["includes"]) == 2
tcodes = [json.loads(l) for l in (out / "tcodes.jsonl").read_text(encoding="utf-8").splitlines()]
assert tcodes == [{"tcode": "ZFIR0010", "program": "ZFIR0010", "text_ko": "거래처 I/F 이력조회"}]
def test_dry_run_writes_nothing(tmp_path, sap):
stats = from_sap.run(["ZFI01"], tmp_path, dry_run=True)
assert stats["listed"] == 2 and stats["fetched"] == 0
assert list(tmp_path.iterdir()) == []