diff --git a/.env.example b/.env.example index 4ba6270..222dcfb 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,13 @@ EMBED_BASE_URL= EMBED_API_KEY= EMBED_MODEL= +# SAP 접속 (Stage 0: python -m ingest.from_sap <패키지>) — sap/README.md +# 고객사 SAP 의 ZAA_ICF 서비스 주소. ZCL_ZAA_AGENT_API 가 설치돼 있어야 한다 +SAP_URL=https://:/sap/bc/ZAA_ICF +SAP_USER= +SAP_PASS= +# SAP_VERIFY_SSL=1 # 사내 인증서 검증을 켜려면 (기본 꺼짐) + # 질의 API 서버 INDEX_HOST=127.0.0.1 INDEX_PORT=8100 diff --git a/README.md b/README.md index 9016a91..d35b084 100644 --- a/README.md +++ b/README.md @@ -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) → python -m ingest.normalize # Stage 1: 줄바꿈 아티팩트 제거, 프로그램/인클루드 파일화 → python -m parser.run # Stage 2: unit·참조·데이터플로우·호출그래프·정의부 (LLM 없이) diff --git a/ingest/from_sap.py b/ingest/from_sap.py new file mode 100644 index 0000000..2f068fa --- /dev/null +++ b/ingest/from_sap.py @@ -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/): + .txt 프로그램 목록 [{DEVCLASS, OBJ_NAME, TEXT, CREATED_ON, CHANGED_ON}] + .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() diff --git a/ingest/normalize.py b/ingest/normalize.py index 62c80f5..10c5cff 100644 --- a/ingest/normalize.py +++ b/ingest/normalize.py @@ -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} package_rows: list[dict] = [] + tcode_rows: list[dict] = [] # ingest/from_sap.py 가 실어 보내는 TCODE_LIST → tcodes.jsonl error_lines: list[str] = [] 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["includes"] += len(meta["includes"]) 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: stats["errors"] += 1 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: for row in package_rows: 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: errors_log.write_text("\n".join(error_lines) + "\n", encoding="utf-8") diff --git a/pyproject.toml b/pyproject.toml index 163ba55..48cb089 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ postgres = ["psycopg[binary]>=3.1", "pgvector>=0.2"] # flat-layout 자동 탐색이 data/ wiki/ 까지 패키지로 오인해 실패한다 — 명시 지정. [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] testpaths = ["tests"] diff --git a/sap/README.md b/sap/README.md new file mode 100644 index 0000000..e79d58e --- /dev/null +++ b/sap/README.md @@ -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\\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\\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 로 읽으면 된다. diff --git a/sap/__init__.py b/sap/__init__.py new file mode 100644 index 0000000..b648428 --- /dev/null +++ b/sap/__init__.py @@ -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' 주석). +""" diff --git a/sap/catalog.py b/sap/catalog.py new file mode 100644 index 0000000..d496ec1 --- /dev/null +++ b/sap/catalog.py @@ -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 파라미터도 없음).", + "파라미터 목록의 행 태그는 이 아니라 //// 다. 이 서버의 파서는 이를 목록으로 처리한다 (직접 파싱할 때 주의)."], + }, + # ------------------------------------------------ 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. 아래 (TYPE/MESSAGE) + 결과. 반복 행은 .", + "응답 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)는 원문 그대로 비교.", + ], +} diff --git a/sap/mcp_server.py b/sap/mcp_server.py new file mode 100644 index 0000000..861de67 --- /dev/null +++ b/sap/mcp_server.py @@ -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 에러 봉투 () 등: 본문은 있으나 오류 + 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() diff --git a/sap/requirements.txt b/sap/requirements.txt new file mode 100644 index 0000000..16f3eb1 --- /dev/null +++ b/sap/requirements.txt @@ -0,0 +1,3 @@ +# MCP 서버(mcp_server.py) 실행에 필요하다. pip install -r requirements.txt +# (웹 테스터는 ../abap-api-tester 로 분리됐고 표준 라이브러리만 쓴다) +mcp>=2.1,<3 diff --git a/sap/sap_client.py b/sap/sap_client.py new file mode 100644 index 0000000..11fa369 --- /dev/null +++ b/sap/sap_client.py @@ -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 로: 보통 이지만, 행 타입이 DDIC 구조인 테이블(RPY 의 파라미터 목록 등)은 + , , 처럼 구조 이름이 행 태그가 된다. 같은 태그가 반복되거나 + 부모가 알려진 테이블 필드(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): + """빈 테이블 은 "" 로 오므로 알려진 테이블 필드는 [] 로 통일.""" + 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 diff --git a/sap/tools/test_mcp.py b/sap/tools/test_mcp.py new file mode 100644 index 0000000..292fd30 --- /dev/null +++ b/sap/tools/test_mcp.py @@ -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"" if isinstance(v, str) and len(v) > 60 else brief(v)) for k, v in d.items()} + if isinstance(d, list): + return f"" + (" 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))) diff --git a/tests/test_from_sap.py b/tests/test_from_sap.py new file mode 100644 index 0000000..17204d7 --- /dev/null +++ b/tests/test_from_sap.py @@ -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()) == []