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:
co-authored by
Claude Fable 5.1
parent
11ae3629b2
commit
921b8ce229
@@ -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 로 읽으면 된다.
|
||||
@@ -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
@@ -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)는 원문 그대로 비교.",
|
||||
],
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
# MCP 서버(mcp_server.py) 실행에 필요하다. pip install -r requirements.txt
|
||||
# (웹 테스터는 ../abap-api-tester 로 분리됐고 표준 라이브러리만 쓴다)
|
||||
mcp>=2.1,<3
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
Reference in New Issue
Block a user