# 설계서: PoC4 MCP AI Console 역설계 및 VPD/FGA 감사 운영 현행화 (#654)

> **상태**: Implemented snapshot documented · follow-up contract pending
> **작성**: [AI] Architect · **최종수정**: 2026-07-14
> **추적성** — Redmine: #654 · 관련 ADR: 없음 · 구현 커밋: `5bdd242`, `33399f9`, `1f6d974`
> · 스냅샷: `poc4_active_source_20260714/` · 원본 archive: `poc4_active_source_20260714/poc4_active_source_20260714.tar.gz` · 현행 구현 파일: `apps/poc4/mcp_discovery_ui.py`, `src/mcp_tool_router.py`, `src/oci_genai_sdk.py`, `src/poc3/model_registry.py`, `config/mcp_servers.json` · 테스트: Python 3.11 `py_compile`, 기존 백오피스 `mvn -q test`

## 1. 목적 (Why)

PoC4 MCP AI Console이 KB VPD MCP를 사용자별 Bearer 토큰으로 안전하게 호출하면서도, 환경별 URL·전송 방식·오류 처리를 하나의 명확한 계약으로 관리한다.

현재 UI의 기본 호출 흐름은 동작한다. 다만 `kb_mcp` 설정에는 `endpoint_url`과 `base_url_env`가 함께 있고, UI는 `endpoint_url`을 우선 사용한다. 따라서 `KB_MCP_BASE_URL`을 바꿔도 실제 호출 대상이 바뀌지 않는다. 또한 `custom_python`은 로컬 8500 MCP용 이름이므로 외부 KB VPD MCP의 통신 계약을 설명하지 못한다.

## 1.1 현행 구현 역설계 요약

이번 문서는 목표 설계를 먼저 정한 것이 아니라, 배포 서버에서 이미 동작하던 PoC4 MCP AI Console 스냅샷을 저장소로 가져온 뒤 구현을 역으로 읽어 작성했다. 따라서 아래 표가 현재 동작의 기준이다.

| 영역 | 현행 구현 |
|---|---|
| 저장소 위치 | `poc4_active_source_20260714/` 단일 폴더에 격리 |
| 실행 진입점 | 직접 실행은 `streamlit run apps/poc4/mcp_discovery_ui.py --server.address 0.0.0.0 --server.port 8622` |
| Python 런타임 | Python 3.11 이상. 배포 서버 검증 런타임은 `/home/opc/poc_4/.python-runtime/cpython-3.11.15+20260610/bin/python3.11` |
| Streamlit 화면 | `apps/poc4/mcp_discovery_ui.py` 단일 대형 UI. KB 테마, 포털 로그인, 대화 이력, MCP discovery/call, evidence 수집, 답변 합성을 포함 |
| MCP registry | `config/mcp_servers.json`. `kb_mcp`와 `kb_vector_mcp` 두 서버를 선언 |
| KB 정형 MCP | `https://kb.cloud-handson.com/mcp`, 기본 tool `ords.query.kb_select_ai_vpd` |
| KB 벡터 MCP | `http://127.0.0.1:9978/mcp`, allowlist `hybrid_rerank_search` |
| 토큰 preset | `config/vpd_token_presets.json`. 실제 토큰 원문은 포함하지 않고 placeholder만 둠 |
| 대화 저장소 | 기본 `data/poc4_mcp_chat.sqlite3`, 환경변수 `POC4_CHAT_DB_PATH`로 변경 가능 |
| DB evidence 연결 | 기본 env file `/home/opc/kbmcp/.env`, wallet fallback `/home/opc/wallet/kbaipoc` |
| OCI GenAI | `OCI_AUTH_TYPE=config_file`, `~/.oci/config`, `DEFAULT` profile 기반. model profile은 `config/poc3_model_profiles.json`에서 로드 |
| 보안 원칙 | wallet, DB password, wallet password, 실제 VPD token, 대화 DB는 저장소에 포함하지 않음 |

현행 데이터 흐름은 다음과 같다.

```text
Streamlit 사용자 로그인
  ↓
VPD 사용자 preset 또는 직접 Bearer token 선택
  ↓
config/mcp_servers.json 로드
  ↓
각 MCP 서버에 initialize → initialized notification → tools/list
  ↓
질문 유형에 따라 single 또는 agent 실행 계획 선택
  ↓
tools/call 호출
  - Authorization: Bearer <현재 VPD token>
  - JSON-RPC body에는 tool name과 arguments만 포함
  ↓
정형 MCP / 벡터 MCP 결과 수집
  ↓
필요 시 /home/opc/kbmcp/.env + /home/opc/wallet/kbaipoc 로 Oracle evidence 조회
  ↓
OCI GenAI로 최종 답변 합성
  ↓
SQLite 대화 이력 저장
```

## 1.2 현행 구현 컴포넌트

| 파일 | 책임 |
|---|---|
| `apps/poc4/mcp_discovery_ui.py` | Streamlit 화면, 포털 로그인, MCP discovery/call, agent loop, evidence 수집, 답변 합성, 대화 이력 저장 |
| `apps/poc4/ui_theme.py` | UI theme 보조 코드 |
| `src/mcp_tool_router.py` | 발견된 MCP tool descriptor를 기반으로 LLM router가 server/tool을 선택하고 tool arguments를 구성 |
| `src/oci_genai_sdk.py` | OCI Generative AI 호출 경계. MCP/VPD token을 알지 않는 최소 completion client |
| `src/poc3/model_registry.py` | 모델 profile registry 로드, region/endpoint 해석, 환경 override 처리 |
| `src/poc3/questions.py` | 데모 질문 목록 |
| `config/mcp_servers.json` | MCP 서버 registry. 현재는 `endpoint_url`이 있으면 이를 우선 사용 |
| `config/poc3_model_profiles.json` | `gpt55_oci`, `gpt54_mini_oci`, `grok43`, `llama4_maverick`, `llama33_70b` profile |
| `config/vpd_token_presets.json` | 데모 사용자 token preset 구조. 실제 토큰은 배포 환경에서 교체 |
| `scripts/poc4/start_8622_langgraph_tc_ui_nohup.sh` | 원본 PoC4 런처 wrapper. 현재 스냅샷의 직접 entrypoint와는 다르게 `/home/opc/poc_4/scripts/poc4/run_8622_langgraph_tc_ui.sh` 및 `apps/poc4/langgraph_tc_ui.py`를 참조 |

런처 주의사항: 스냅샷에는 `mcp_discovery_ui.py` 직접 실행에 필요한 소스가 들어 있지만, 포함된 `start_8622...` script는 원본 서버의 공용 runtime wrapper에 의존한다. 이 저장소에서 독립 실행하려면 `SOURCE_README.md`의 직접 `streamlit run` 명령을 사용하거나, 별도 wrapper를 작성해야 한다.

## 1.3 현행 보안/비밀정보 경계

- `VpdTokenPreset.token`은 dataclass에서 `repr=False`이며, UI는 token을 정규화한 뒤 Authorization header에만 넣는다.
- `_NoRedirectHandler`는 redirect 시 Authorization header가 다른 endpoint로 전달되는 것을 막는다.
- `src/mcp_tool_router.py`의 router는 질문과 tool descriptor만 받으며 bearer token 또는 provider credential을 받지 않는다.
- `src/oci_genai_sdk.py`는 `.env`에서 `OCI_AUTH_TYPE`, `OCI_CONFIG_FILE`, `OCI_GENAI_COMPARTMENT_ID`, `OCI_PROFILE`만 읽는다.
- Oracle audit/business evidence 조회는 `/home/opc/kbmcp/.env`에서 `ORACLE_DB_USER`, `ORACLE_DB_PASSWORD`, `ORACLE_DSN`, `ORACLE_WALLET_PASSWORD`, `ORACLE_WALLET_DIR`를 읽는다.
- wallet 기본 fallback은 `/home/opc/wallet/kbaipoc`이다.
- 저장소에는 실제 `.env`, wallet, SQLite 대화 DB, 실제 VPD bearer token 원문을 포함하지 않는다.

## 1.4 현행 구현과 후속 목표의 차이

| 항목 | 현행 구현 | 후속 목표 |
|---|---|---|
| KB MCP provider/transport 명칭 | `provider=custom_python`, `transport=http` | `kb_vpd_streamable_http`, `streamable_http`처럼 외부 KB VPD MCP 계약을 명확히 표현 |
| endpoint 해석 | `endpoint_url`이 있으면 이를 우선 사용하고, 없으면 env 값을 사용 | KB MCP는 `KB_MCP_BASE_URL + /mcp` 단일 계약으로 해석 |
| tool routing | route가 여러 개면 LLM router/agent mode를 사용할 수 있음 | KB 단일 tool 호출에서는 router 없이 direct 선택 |
| 401/403 표시 | 현재 둘 다 “MCP 인증에 실패” 계열 안전 메시지로 축약 | 토큰 없음/만료/권한 부족을 UI에서 명확히 분리 |
| start script | 원본 `/home/opc/poc_4` runtime wrapper 의존 | 스냅샷 폴더 안에서 독립 실행 가능한 wrapper 추가 |
| 회귀 테스트 | 원본 서버 Python 3.11 `py_compile`, Java `mvn -q test` | PoC4 전용 단위 테스트와 실제 MCP HTTP smoke test 추가 |

## 2. 범위 (Scope)

- **포함**:
  - `apps/poc4/mcp_discovery_ui.py`의 KB MCP endpoint, 인증 헤더, JSON-RPC, 오류 처리 정비
  - `config/mcp_servers.json` 및 sample의 KB MCP 선언 정비
  - KB MCP의 단일 도구 `ords.query.kb_select_ai_vpd` 호출 계약 문서화
  - VPD Backoffice `/mcp`과의 HTTP 상태·프로토콜 버전 호환성 점검 및 필요한 최소 보완
- **제외 (out of scope)**:
  - 기존 `kb_vector_mcp` 및 `custom_python` 8500 RAG MCP의 변경
  - VPD 권한 규칙, Select AI SQL 생성 규칙, ORDS 비즈니스 로직 변경
  - OAuth Authorization Server, 사용자 로그인/토큰 발급 UI 재설계
  - VPD 토큰 원문을 환경변수·레지스트리에 저장하는 방식

## 3. 인수조건 (Acceptance Criteria)

아래 체크박스는 최종 목표 기준이다. 2026-07-14 스냅샷 반입 상태에서는 일부만 충족한다.

- [ ] KB MCP URL은 `KB_MCP_BASE_URL` 하나에서만 해석되고 `/mcp` path가 안전하게 결합된다.
- [ ] `initialize`, `notifications/initialized`, `tools/list`, `tools/call`의 모든 HTTP 요청에 현재 선택된 사용자의 `Authorization: Bearer <VPD token>`만 전송된다.
- [ ] Bearer 토큰은 JSON-RPC arguments, SQLite 대화 이력, UI 결과, 애플리케이션 로그에 저장·출력되지 않는다.
- [ ] 호출 가능한 도구는 `ords.query.kb_select_ai_vpd` 하나이며, 인자는 `prompt`와 `limit`만 허용된다.
- [ ] 단일 도구 구성에서는 LLM router를 호출하지 않고 허용 도구를 직접 선택한다.
- [ ] 토큰 없음/위조/만료는 HTTP `401`, 유효 토큰의 권한 부족은 HTTP `403`으로 UI에 구분 표시된다.
- [ ] 잘못된 Origin의 브라우저 요청은 MCP 서버에서 거부되며, 허용 Origin 및 Origin 없는 네이티브 MCP client 정책이 문서화된다.
- [ ] 기존 `kb_vector_mcp` 및 로컬 8500 MCP 회귀 테스트가 통과한다.

| 조건 | 현행 판단 | 근거/메모 |
|---|---|---|
| URL 단일 해석 | 부분 충족 | `_mcp_endpoint()`가 `/mcp` 결합과 URL 검증은 수행하지만 `config/mcp_servers.json`의 `endpoint_url`이 env보다 우선한다. |
| 요청별 Authorization header | 충족 | `_jsonrpc_exchange()`와 `_jsonrpc_notification()`이 `Authorization: Bearer <token>`을 header에 설정한다. |
| JSON-RPC arguments token 미포함 | 충족 | `build_mcp_tool_arguments()`는 `prompt`, `limit`, `query`, `question` 계열만 구성한다. |
| SQLite token 원문 미저장 | 부분 충족 | chat turn에는 선택 사용자/label, question/answer/details를 저장한다. token 원문 필드는 없지만 `details_json`의 evidence payload는 후속 보안 점검 필요. |
| 단일 도구 direct routing | 부분 충족 | route가 1개면 single로 처리하지만, 복수 route에서는 LLM router/agent mode를 사용할 수 있다. |
| 401/403 구분 | 미충족 | 현재 `401`, `403` 모두 안전한 인증 실패 메시지로 축약한다. |
| Origin 검증 | 서버 범위 | UI client는 redirect 차단을 수행한다. Origin 정책은 VPD Backoffice MCP 서버 설정에서 확인해야 한다. |
| 벡터 MCP 회귀 | 미검증 | 소스 반입 시 Java 테스트와 Python 문법 검증만 수행했다. 실제 `kb_vector_mcp` smoke test는 후속이다. |

## 4. 컨텍스트 & 제약

- KB VPD MCP endpoint: `https://kb.cloud-handson.com/mcp`
- 보호 대상: `ords.query.kb_select_ai_vpd`는 ORDS를 거쳐 VPD 컨텍스트가 적용된 Select AI 조회를 실행한다.
- 토큰 주체: VPD 권한은 정적 서비스 계정이 아니라 현재 선택된 `KB_STAKEHOLDERS` 사용자 토큰에 의해 결정된다.
- UI의 VPD token preset 파일은 데모 편의 기능일 뿐이다. 운영에서는 OS 소유자 전용 권한(`0600`)으로 관리하고 형상관리·로그·SQLite에서 제외한다.
- UI가 현재 사용하는 `2025-11-25` MCP protocol version과 서버의 지원 버전은 handshake에서 협상해야 한다. 지원하지 않는 버전을 무조건 강제하지 않는다.
- 현재 서버는 stateless JSON-RPC POST 호출로도 동작한다. 서버가 `Mcp-Session-Id`를 발급하면 client는 이후 요청에만 그 값을 포함한다.

## 5. 아키텍처 개요

I/O는 PoC4 MCP AI Console의 HTTP transport와 VPD Backoffice `/mcp`에 한정한다. URL 결합, 허용 도구 검증, 요청·응답 검증, 안전한 오류 변환은 순수 함수로 분리해 네트워크 없이 테스트한다.

```
VPD 사용자 선택 / 토큰 입력
        │  (원문은 요청 메모리에만 존재)
        ▼
PoC4 MCP AI Console
  ├─ KB_MCP_BASE_URL + "/mcp"
  ├─ tool allowlist 검증
  └─ Authorization: Bearer <current VPD token>
        │
        ▼ HTTPS JSON-RPC / Streamable HTTP
VPD Backoffice MCP (/mcp)
  ├─ Origin·토큰 검증
  ├─ tools/list: metadata only
  └─ tools/call: ords.query.kb_select_ai_vpd
        │
        ▼
ORDS Select AI API → VPD context → Oracle ADB
```

## 6. 데이터 모델

### 6.1 KB MCP registry 선언

`config/mcp_servers.json`의 KB 선언은 다음 계약을 따른다. 값은 예시이며, 토큰 값은 넣지 않는다.

```json
{
  "id": "kb_mcp",
  "enabled": true,
  "provider": "kb_vpd_streamable_http",
  "transport": "streamable_http",
  "base_url_env": "KB_MCP_BASE_URL",
  "endpoint_path": "/mcp",
  "auth_delivery": "per_request_vpd_bearer",
  "timeout_seconds_env": "POC3_MCP_TIMEOUT_SECONDS",
  "default_tool": "ords.query.kb_select_ai_vpd",
  "tool_allowlist": ["ords.query.kb_select_ai_vpd"],
  "router_mode": "direct",
  "description": "KB VPD Select AI MCP; the current user's VPD bearer is sent only in the Authorization header."
}
```

환경 변수는 아래 두 값만 필요하다.

```dotenv
KB_MCP_BASE_URL=https://kb.cloud-handson.com
POC3_MCP_TIMEOUT_SECONDS=90
```

`endpoint_url`, `token_env`, `POC3_MCP_TOKEN`, `BACKOFFICE_MCP_ACCESS_TOKEN`은 KB MCP 선언에 두지 않는다. URL은 registry에 하드코딩하지 않고, VPD 토큰은 사용자별 요청에서만 받는다.

### 6.2 MCP 요청

모든 요청은 다음 헤더를 사용한다.

```http
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: <negotiated version>
Authorization: Bearer <current-user-vpd-token>
```

`tools/call` body의 `arguments`는 아래와 같이 제한한다.

```json
{
  "name": "ords.query.kb_select_ai_vpd",
  "arguments": {
    "prompt": "담당 고객의 보험료 상세를 보여줘",
    "limit": 50
  }
}
```

경계 검증 규칙:

- tool name은 정확히 allowlist 값 하나와 일치해야 한다.
- `prompt`는 문자열이며 서버와 동일한 최대 길이를 적용한다.
- `limit`은 정수 `1..100`으로 clamp한다.
- 토큰은 공백·`Bearer ` prefix를 정규화한 뒤 헤더에만 넣는다.
- redirect는 허용하지 않는다. 다른 origin으로 Authorization이 전달되어서는 안 된다.

## 7. 함수 명세 (Function Specs)

| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `resolve_kb_mcp_endpoint` | base URL과 고정 path를 안전하게 결합 | `(server, environ) -> str` | registry, env | HTTPS MCP URL | 누락/비정상 URL | 단순 |
| `validate_kb_mcp_server` | KB 선언의 provider·transport·allowlist를 검증 | `(mapping) -> McpServer` | registry row | typed server | 계약 위반 | 단순 |
| `mcp_headers` | 요청별 VPD bearer 헤더 생성 | `(token, version, session_id) -> dict` | 사용자 토큰 | 안전한 headers | 토큰 형식 오류 | 단순 |
| `discover_kb_tools` | initialize 및 tools/list 후 단일 도구를 검증 | `(server, token) -> McpDiscoveryResult` | endpoint, token | tool descriptor | auth/protocol 오류 | **복잡** |
| `call_kb_select_ai` | 고정 도구에 prompt·limit을 전달 | `(server, token, prompt, limit) -> result` | 사용자 요청 | tool result | auth/timeout/JSON-RPC 오류 | **복잡** |
| `to_public_mcp_error` | HTTP/JSON-RPC 오류를 안전한 UI 메시지로 변환 | `(exception) -> PublicMcpError` | 내부 오류 | 사용자 메시지 | 원문 노출 금지 | 단순 |

## 8. 흐름 / 알고리즘

1. UI는 `KB_MCP_BASE_URL`을 읽고 `/mcp`만 결합한다. registry의 임의 `endpoint_url`은 KB 서버에 허용하지 않는다.
2. 사용자가 VPD token preset 또는 일회성 Bearer를 선택한다. 토큰은 현재 실행 변수에만 유지한다.
3. client는 `initialize`를 보내고 서버가 반환한 protocol version과 선택 가능한 session ID를 검증한다.
4. `notifications/initialized`를 보낸 뒤 `tools/list`를 실행한다.
5. 응답 목록이 정확히 허용 도구를 포함하는지, 해당 input schema가 `prompt`, `limit` 계약에 맞는지 확인한다.
6. KB 서버는 단일 도구이므로 router model을 호출하지 않고 `ords.query.kb_select_ai_vpd`를 직접 선택한다.
7. `tools/call`은 prompt·clamp된 limit만 body에 넣고 VPD Bearer는 Authorization에만 넣는다.
8. 결과는 화면용 안전 projection만 SQLite에 저장한다. Authorization 헤더와 원문 token은 저장하지 않으며, 진단이 필요하면 단방향 token fingerprint만 별도 보존할 수 있다.

## 9. 엣지케이스 & 에러 처리

| 상황 | client 처리 | 서버 기대 동작 |
|------|-------------|----------------|
| 토큰 없음 | 호출 전 안내, 네트워크 요청 없음 | 해당 없음 |
| 토큰 위조·만료 | `401` → “토큰이 유효하지 않거나 만료됨” | `WWW-Authenticate` 포함 가능 |
| 유효하지만 권한 없음 | `403` → “이 사용자에게 조회 권한 없음” | VPD fail-closed 유지 |
| allowlist 밖 도구 | 호출 전 차단 | tools/call에서도 차단 |
| 429 | 안전하게 재시도하지 않고 잠시 후 재시도 안내 | rate limit 정책 적용 |
| timeout | tools/call 자동 재시도 금지 | request ID 기반 감사 추적 |
| session ID 미발급 | stateless POST로 진행 | session을 요구하지 않음 |
| session ID 발급 | 이후 요청에 `Mcp-Session-Id` 포함 | 세션 소유·만료 검증 |
| redirect | 즉시 실패 | Authorization 전달 금지 |
| Origin 불일치 | 브라우저 UI에 일반 오류 표시 | `403`으로 거부 |

## 10. 테스트 계획

- registry 단위 테스트
  - `KB_MCP_BASE_URL`만으로 endpoint가 `https://kb.cloud-handson.com/mcp`가 되는지 검증
  - KB registry에 `endpoint_url`, `token_env`, `custom_python`이 있으면 fail-closed 되는지 검증
  - vector MCP 설정은 기존 형식으로 계속 로드되는지 검증
- HTTP transport 단위 테스트
  - initialize/tools/list/tools/call 모두 Authorization header가 있고 JSON body에는 token key가 없는지 검증
  - `401`, `403`, `429`, timeout, redirect, malformed JSON-RPC 응답을 안전한 메시지로 변환하는지 검증
  - session header 반환/재전송 및 stateless fallback을 검증
- 통합 smoke test
  - 허용된 VPD 사용자 토큰으로 `tools/list`와 `tools/call` 성공
  - 잘못된 토큰은 `401`, 타 사용자 권한은 `403`
  - 설계사와 지점장 토큰으로 동일 질문을 실행해 VPD 행/컬럼 결과가 서로 다른지 확인
- 비밀정보 점검
  - chat SQLite, Streamlit log, 예외 메시지에서 토큰 원문 검색 결과 0건

## 11. 리스크 & 대안 검토

- **선택**: KB MCP 전용 `kb_vpd_streamable_http` 선언을 도입하고, 로컬 8500용 `custom_python`과 분리한다. 외부 HTTPS/VPD Bearer 계약을 코드와 운영 화면에서 명확히 할 수 있다.
- **대안 1 — 기존 `custom_python` 재사용**: 동작은 시킬 수 있으나 provider 이름과 endpoint 제약이 실제 KB 서버와 맞지 않아 로컬 MCP와 외부 VPD MCP가 섞인다.
- **대안 2 — 고정 MCP access token 사용**: 구현은 간단하지만 모든 사용자가 동일 VPD 주체가 되어 데이터 권한 분리가 무너진다. 채택하지 않는다.
- **대안 3 — 즉시 OAuth 2.1 전환**: 표준 상호운용성에는 유리하지만 현재 데모의 VPD token 발급·검증 체계를 대체하므로 별도 인증 서버 설계가 필요하다.
- 롤백: 새 registry 선언을 비활성화하고 기존 KB 선언을 복원한다. DB VPD 정책·ORDS endpoint·토큰 데이터는 변경하지 않는다.

## 12. 미해결 질문 (Open Questions)

- VPD Backoffice MCP endpoint가 현재 지원할 MCP protocol version을 어떤 값으로 공식 고정할지 결정이 필요하다.
- Streamable HTTP의 GET/SSE 및 `Mcp-Session-Id`를 완전 지원할지, stateless POST profile로 운영할지 결정이 필요하다.
- 데모 이후 사용자 VPD bearer를 OAuth 2.1 access token으로 전환할지, 현 토큰을 resource-server token으로 계속 운영할지 결정이 필요하다.
- chat 대화 이력의 `basis_json`/`details_json`에 민감 데이터 보존 기간과 삭제 정책을 별도로 정해야 한다.

## 13. 2026-07-14 소스 스냅샷 반입

배포 서버의 PoC4 활성 화면 소스를 현재 저장소에 별도 폴더로 반입했다.

- 원격 실제 위치: `/home/opc/poc_4/poc4_active_source_20260714.tar.gz`
- 사용자 제시 경로 `/home/opc/poc_4/poc4_active_source_20260714/poc4_active_source_20260714.tar.gz`에는 파일이 없었고, 실제 archive는 `/home/opc/poc_4/` 바로 아래에 있었다.
- 저장소 위치: `poc4_active_source_20260714/`
- 포함 파일: Streamlit UI, MCP router, OCI GenAI client, 모델 profile, token preset sample, 기동/status script, requirements
- 보안 확인: 실제 `.env`, 실제 VPD token 원문, 대화 SQLite DB는 포함하지 않았다. `vpd_token_presets.json`에는 placeholder만 있다.
- DB 참조 경로: VPD 개발본 배포 서버에서는 `/home/opc/kbmcp/.env`의 접속 정보를 사용하고, wallet directory는 `/home/opc/wallet/kbaipoc`를 사용한다. 두 경로의 파일 내용은 저장소에 포함하지 않는다.
- 런타임 전제: Python 3.11 이상. 현재 개발 서버 기본 `python3`는 3.6.8이므로 이 소스의 문법 검증에는 맞지 않는다.
- 검증: 배포 서버 원본 경로에서 PoC4 전용 Python `3.11.15`로 `py_compile` 통과. 기존 Java 백오피스 `mvn -q test` 통과.

주의: 이 반입은 소스 스냅샷 보관이다. 아직 본 설계서의 목표 계약인 `kb_vpd_streamable_http`, `KB_MCP_BASE_URL` 단일 endpoint 해석, 단일 도구 direct routing을 구현 완료했다는 의미는 아니다.

## 14. 2026-07-14 역설계 현행화

사용자 요청에 따라 “설계가 먼저 있고 구현을 맞추는 방식”이 아니라, 이미 배포 서버에서 구현·운영 중인 PoC4 MCP AI Console 소스를 기준으로 역설계했다.

현행화한 내용:

- `mcp_discovery_ui.py`의 실제 책임 범위를 Streamlit UI, MCP JSON-RPC client, agent loop, evidence collector, answer synthesis, chat store로 분리해 기록했다.
- `mcp_servers.json`의 현재 계약이 목표 계약과 다르다는 점을 명시했다. 특히 현행은 `endpoint_url` 우선이며, `provider=custom_python`, `transport=http`이다.
- `/home/opc/kbmcp/.env`와 `/home/opc/wallet/kbaipoc`를 배포 서버 DB evidence 조회 기준으로 확정했다.
- 포함된 start/status script가 스냅샷 단독 실행용이 아니라 원본 `/home/opc/poc_4` runtime wrapper에 의존한다는 점을 기록했다.
- 인수조건을 최종 목표와 현행 충족 상태로 분리했다.

검증:

- 배포 서버 원본 소스 기준 Python 3.11.15 `py_compile` 통과
- 백오피스 기존 회귀 `mvn -q test` 통과
- 저장소 문서/스냅샷 커밋 및 `gitea/main` push 완료

## 15. 2026-07-14 Oracle FGA 감사로그 적용 확인

### 15.1 확인 목적

PoC4 MCP AI Console과 VPD 백오피스는 사용자별 Bearer token으로 정형 데이터를 조회한다. 이때 Oracle VPD는 행 접근 predicate를 적용하고, ASO/Data Redaction은 민감 컬럼 원문/마스킹을 결정한다. FGA는 그 결과로 실행된 SELECT를 DB 감사 증적으로 남기는 역할이다.

이번 확인은 “FGA가 설계상 있어야 한다”가 아니라, 배포 서버가 바라보는 실제 ADB에 `DBMS_FGA` 정책과 감사 이벤트가 존재하는지 SQLcl로 직접 확인한 결과다.

### 15.2 SQLcl 확인 환경

배포 서버에서 아래 기준으로 접속했다. 비밀번호와 wallet password는 출력하거나 저장소에 기록하지 않는다.

| 항목 | 값 |
|---|---|
| 접속 서버 | `161.33.6.45` |
| DB 접속 env | `/home/opc/kbmcp/.env` |
| wallet directory | `/home/opc/wallet/kbaipoc` |
| SQLcl | `/home/opc/tools/sqlcl/bin/sql` |
| 접속 DB 사용자 | `ADMIN` |
| 감사 대상 schema | `POC_2` |

SQLcl 실행 구조는 다음과 같다.

```bash
set -a
. /home/opc/kbmcp/.env
set +a
export TNS_ADMIN="${ORACLE_WALLET_DIR:-/home/opc/wallet/kbaipoc}"

/home/opc/tools/sqlcl/bin/sql -s /nolog
connect ${ORACLE_DB_USER}/"<ORACLE_DB_PASSWORD>"@${ORACLE_DSN}
```

### 15.3 적용된 FGA 정책

`DBA_AUDIT_POLICIES`와 `DBA_AUDIT_POLICY_COLUMNS`를 SQLcl로 조회한 결과, `POC_2` schema에는 FGA SELECT 정책 4건이 활성화되어 있다.

| 객체 | 정책명 | 감사 컬럼 | 활성 | SELECT 감사 |
|---|---|---|---|---|
| `KB_CLAIMS` | `KB_FGA_CLAIMS_AMT` | `CLAIM_AMT` | `YES` | `YES` |
| `KB_CLAIMS` | `KB_FGA_CLAIMS_PAID_AMT` | `PAID_AMT` | `YES` | `YES` |
| `KB_CUSTOMERS` | `KB_FGA_CUSTOMERS_PII` | `CUST_NM`, `RRN_MASKED` | `YES` | `YES` |
| `KB_EXTERNAL_HOLDINGS` | `KB_FGA_EXT_HOLDINGS` | `EXT_INSURER`, `EXT_PRODUCT_GRP`, `EXT_PRODUCT_TYPE` | `YES` | `YES` |

요약 쿼리 결과:

```text
POLICY_COUNT = 4
ENABLED_COUNT = 4
SELECT_POLICY_COUNT = 4
```

현재 DB의 정책명 계열은 모두 `KB_FGA_*`다. 저장소의 범용 적용 스크립트 `sql/adb/42_agent_ords_fga_execution_audit.sql`은 `CB_VPD_EXEC_AUDIT_<object_id>` 형태의 정책을 만들도록 작성되어 있으나, 현행 ADB에는 이 계열이 아니라 `KB_FGA_*` 정책이 적용되어 있다. 따라서 운영 확인 시에는 “스크립트 파일명/예상명”보다 `DBA_AUDIT_POLICIES`의 실제 정책명을 기준으로 봐야 한다.

### 15.4 감사 이벤트 저장 위치

ADB 현행 환경에서는 FGA 이벤트가 `UNIFIED_AUDIT_TRAIL`에 기록된다.

SQLcl 확인 결과 `UNIFIED_AUDIT_TRAIL`에는 FGA 조회에 필요한 아래 컬럼이 있다.

| 컬럼 | 용도 |
|---|---|
| `EVENT_TIMESTAMP`, `EVENT_TIMESTAMP_UTC` | 감사 발생 시각 |
| `DBUSERNAME` | SQL을 실행한 DB 사용자 |
| `CLIENT_IDENTIFIER` | ORDS/MCP 요청 식별자 |
| `OBJECT_SCHEMA`, `OBJECT_NAME` | 감사 대상 객체 |
| `ACTION_NAME` | 실행 작업. 현재는 `SELECT` |
| `FGA_POLICY_NAME` | 트리거된 FGA 정책명 |
| `SQL_TEXT` | DB가 감사한 실제 SQL |
| `RLS_INFO` | Oracle이 기록한 VPD 정책명과 predicate |
| `RETURN_CODE` | 실행 결과 코드. `0`이면 성공 |

최근 7일 FGA 이벤트 요약:

```text
UNIFIED_AUDIT_TRAIL event_count_7d = 525
oldest_event = 2026-07-09 06:15:04
newest_event = 2026-07-14 05:09:14
```

반면 `DBA_FGA_AUDIT_TRAIL` 기준 최근 7일 이벤트는 0건이었다.

```text
DBA_FGA_AUDIT_TRAIL event_count_7d = 0
```

해석은 다음과 같다.

- FGA가 미적용이라는 뜻이 아니다.
- 현재 ADB에서는 FGA 감사 행이 Unified Audit Trail에 기록되고 있다.
- 앱 구현도 이 전제를 반영해 `UNIFIED_AUDIT_TRAIL`을 먼저 조회하고, 실패할 때 `DBA_FGA_AUDIT_TRAIL`로 fallback한다.

### 15.5 최근 감사 이벤트 예시

최근 이벤트는 모두 `CB_ORDS` DB 사용자로 기록되었고, `CLIENT_IDENTIFIER`에는 요청별 UUID가 들어가 있었다. 이 값으로 특정 ORDS/MCP 요청과 DB 감사 행을 연결한다.

확인된 최근 이벤트 예:

| 발생시각(KST) | DB 사용자 | 객체 | 정책 | 작업 | 결과 |
|---|---|---|---|---|---|
| 2026-07-14 14:09:14 | `CB_ORDS` | `KB_EXTERNAL_HOLDINGS` | `KB_FGA_EXT_HOLDINGS` | `SELECT` | `0` |
| 2026-07-14 14:06:31 | `CB_ORDS` | `KB_EXTERNAL_HOLDINGS` | `KB_FGA_EXT_HOLDINGS` | `SELECT` | `0` |
| 2026-07-14 14:06:31 | `CB_ORDS` | `KB_CLAIMS` | `KB_FGA_CLAIMS_AMT` | `SELECT` | `0` |
| 2026-07-14 14:06:31 | `CB_ORDS` | `KB_CLAIMS` | `KB_FGA_CLAIMS_PAID_AMT` | `SELECT` | `0` |

`RLS_INFO`도 같이 기록된다. 예를 들어 `KB_CLAIMS` 이벤트에는 `KB_KB_CLAIMS_ROW_POLICY`와 해당 VPD predicate가 포함되어 있었다. 즉 FGA는 “어떤 SQL이 실행됐는가”뿐 아니라 “그 SQL에 어떤 VPD 정책이 붙었는가”를 사후 증적으로 확인하는 데 사용할 수 있다.

### 15.6 소스 구현과 화면 연결

PoC4 스냅샷의 `apps/poc4/mcp_discovery_ui.py`는 감사로그 탭에서 다음 두 쿼리를 사용한다.

| 함수 | 조회 대상 | 역할 |
|---|---|---|
| `_load_fga_inventory()` | `DBA_AUDIT_POLICIES`, `DBA_AUDIT_POLICY_COLUMNS`, `POC_2.KB_SECURITY_POLICY_CATALOG` | 현재 활성 FGA 정책과 관리 카탈로그를 표시 |
| `_load_fga_audit_events()` | `UNIFIED_AUDIT_TRAIL` | 최근 FGA 이벤트, SQL 원문, 사용자, 정책명, 결과 코드를 표시 |

VPD 백오피스의 검증 화면은 `OrdsProbeService`에서 다음 순서로 특정 요청의 FGA 증적을 찾는다.

1. `UNIFIED_AUDIT_TRAIL`
2. `DBA_FGA_AUDIT_TRAIL`

조회 조건은 객체 owner/name, `ACTION_NAME = 'SELECT'`, `CLIENT_IDENTIFIER = <probe request id>`, `FGA_POLICY_NAME IS NOT NULL`이다.

따라서 운영자가 봐야 할 기준은 다음이다.

- “정책이 적용됐는가?” → `DBA_AUDIT_POLICIES.ENABLED = YES`
- “민감 컬럼 접근이 실제 발생했는가?” → `UNIFIED_AUDIT_TRAIL.FGA_POLICY_NAME IS NOT NULL`
- “어떤 VPD predicate가 붙었는가?” → `UNIFIED_AUDIT_TRAIL.RLS_INFO`
- “어떤 요청과 연결되는가?” → `CLIENT_IDENTIFIER`

### 15.7 확인용 SQL

운영 확인 시 사용할 수 있는 최소 SQL은 다음과 같다.

```sql
SELECT policy.object_schema,
       policy.object_name,
       policy.policy_name,
       policy.enabled,
       policy.sel,
       LISTAGG(policy_columns.policy_column, ',') WITHIN GROUP (
         ORDER BY policy_columns.policy_column
       ) AS policy_columns
FROM dba_audit_policies policy
LEFT JOIN dba_audit_policy_columns policy_columns
  ON policy_columns.object_schema = policy.object_schema
 AND policy_columns.object_name = policy.object_name
 AND policy_columns.policy_name = policy.policy_name
WHERE policy.object_schema = 'POC_2'
GROUP BY policy.object_schema,
         policy.object_name,
         policy.policy_name,
         policy.enabled,
         policy.sel
ORDER BY policy.object_name, policy.policy_name;
```

```sql
SELECT *
FROM (
    SELECT TO_CHAR(
             event_timestamp AT TIME ZONE 'Asia/Seoul',
             'YYYY-MM-DD HH24:MI:SS'
           ) AS event_time,
           dbusername,
           client_identifier,
           object_name,
           fga_policy_name,
           action_name,
           return_code,
           DBMS_LOB.SUBSTR(sql_text, 500, 1) AS sql_text_sample,
           DBMS_LOB.SUBSTR(rls_info, 500, 1) AS rls_info_sample
    FROM unified_audit_trail
    WHERE object_schema = 'POC_2'
      AND fga_policy_name IS NOT NULL
    ORDER BY event_timestamp DESC
)
WHERE ROWNUM <= 20;
```

### 15.8 후속 정리 필요사항

현재 FGA 정책 자체는 정상 적용되어 있고 감사 이벤트도 쌓이고 있다. 다만 관리 카탈로그 `POC_2.KB_SECURITY_POLICY_CATALOG`에는 `KB_CUSTOMERS`, `KB_EXTERNAL_HOLDINGS` 중심의 5개 row만 확인되며, 실제 활성 정책에 있는 `KB_CLAIMS.CLAIM_AMT`, `KB_CLAIMS.PAID_AMT` 정책 메타데이터는 카탈로그 조회 결과에 없었다.

따라서 화면이 `DBA_AUDIT_POLICIES`를 직접 조회하는 현재 구조에서는 정책이 보이지만, 카탈로그를 운영 기준으로 삼으려면 `KB_CLAIMS` FGA 메타데이터도 `KB_SECURITY_POLICY_CATALOG`에 맞춰 등록하는 정리가 필요하다.
