From 9c993856955fe2c9901cfb4c0c02be85bb790951 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 8 Aug 2026 00:23:47 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20Admin=20MCP=20#370=20=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=20=EB=B0=8F=201=EC=B0=A8=20=EA=B2=B0=EC=A0=95=20?= =?UTF-8?q?=EB=B8=8C=EB=A6=AC=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plan/mcp-370-decision-brief.md | 386 +++++++++++++++++ plan/mcp-research-codebase-readiness.md | 253 +++++++++++ plan/mcp-research-deploy-clients.md | 536 ++++++++++++++++++++++++ plan/mcp-research-security-checklist.md | 323 ++++++++++++++ plan/mcp-research-spec-trends.md | 117 ++++++ plan/mcp-research-tool-catalog.md | 139 ++++++ 6 files changed, 1754 insertions(+) create mode 100644 plan/mcp-370-decision-brief.md create mode 100644 plan/mcp-research-codebase-readiness.md create mode 100644 plan/mcp-research-deploy-clients.md create mode 100644 plan/mcp-research-security-checklist.md create mode 100644 plan/mcp-research-spec-trends.md create mode 100644 plan/mcp-research-tool-catalog.md diff --git a/plan/mcp-370-decision-brief.md b/plan/mcp-370-decision-brief.md new file mode 100644 index 000000000..563f7c24e --- /dev/null +++ b/plan/mcp-370-decision-brief.md @@ -0,0 +1,386 @@ +# Admin MCP #370 — Decision Brief + +- 작성일: 2026-08-08 +- 이슈: `bottle-note/workspace#370` +- 관련: #340 Agent Key 완료, #341 감사 로그 open +- 입력: `mcp-research-spec-trends` · `mcp-research-codebase-readiness` · `mcp-research-deploy-clients` · `mcp-research-tool-catalog` · `mcp-research-security-checklist` +- 성격: **의사결정 브리프**. 프로덕션 코드·배포·PR 없음. 확정 요구사항 전 define/plan 입력. + +--- + +## Executive Summary + +| 결정 항목 | 권장 | +|---|---| +| 모듈 | 신규 bootJar **`bottlenote-admin-mcp`** (admin-api 내장·product 합류 비권장) | +| 트랜스포트 | **Streamable HTTP only**, 경로 `/mcp`, public `https://mcp.bottlenote.com` | +| 와이어 스펙 | 구현 **`2025-11-25`** (Java SDK v2 + Spring AI) / 설계 **stateless** (2026-07-28 전제) | +| 인증 1차 | **안 B**: `Authorization: Bearer bn_agent_*` → 서버 내부 `#340` 교환 → Admin JWT | +| 토큰 정책 | 클라이언트 Admin JWT **수신·통과 금지**. Admin JWT는 MCP 프로세스 내부만 | +| 첫 PR | 모듈 스캐폴드 + 키 교환 + whisky search/get + allowlist + 구조화 로그 | +| #370 DoD 후속 | 참조 조회 → 쓰기(+confirm)/presign/preview → #341 → prod HA → OAuth(안 A) | + +--- + +## 1. Recommended Architecture + +### 1.1 토폴로지 + +``` +[MCP Client: Claude Code / Cursor / Codex] + | Streamable HTTP POST https://mcp.../mcp + | Authorization: Bearer bn_agent_* (MCP 전용 자격만) + v +[bottlenote-admin-mcp] stateless, multi-pod OK, sticky 없음 + | 1) POST /admin/api/v1/auth/agent (서버 내부, 키 원문 미로그) + | 2) Admin JWT (요청 스코프 / 짧은 TTL 캐시, 클라이언트 미노출) + | 3) 화이트리스트 Admin API만 호출 (NEVER 경로 코드 경로 0) + v +[bottlenote-admin-api] --> mono --> MySQL / Redis / S3 +``` + +### 1.2 모듈 배치 + +| 옵션 | 판정 | 이유 | +|---|---|---| +| **A. 신규 `bottlenote-admin-mcp` bootJar** | **채택** | 프로세스 격리, MCP 전용 게이트웨이·스케일, Admin 계약 HTTP 소비, blast radius 분리 | +| B. admin-api 내장 | 스파이크만 | 배포 단순하나 포트·인증·재시작 커플링, 레이어 혼선 | +| C. mono에 프로토콜 | 거부 | mono는 도메인 라이브러리 | +| D. product-api 합류 | 거부 | Admin 권한 경계 붕괴 | + +- mono Facade/Service **직접 주입 금지**. Admin HTTP 클라이언트(WebClient/Feign)만 사용. +- settings: Gradle multi-module include, Java 21, Spring Boot 3.4.x, Spring AI MCP Server Boot Starter (`protocol=STREAMABLE`). +- 의존: `observability`(선택), Redis(rate limit·선택 handle), Admin 내부 base URL. + +### 1.3 Transport · Endpoint + +| 항목 | 값 | +|---|---| +| Public URL (prod) | `https://mcp.bottlenote.com` (DNS 표기 `bottlenote` vs `bottle-note` 인프라 확정 필요) | +| Dev URL (가칭) | `https://mcp.development.bottle-note.com` | +| Path | `/mcp` | +| Protocol wire | `2025-11-25` (SDK 한계) | +| Design constraint | **stateless**: `Mcp-Session-Id`/커넥션 스코프 비즈니스 상태 금지. 필요 시 Redis handle + `:` 바인딩 | +| Health | `/actuator/health/liveness`, `/actuator/health/readiness` (context-path 없음) | +| TLS | Gateway 종단. 앱 plain `:8080` | +| Session affinity | **없음** (`sessionAffinity` 미설정) | +| CORS 1차 | CLI/native 전제 → allow-origins 비움/최소. 브라우저 커넥터는 이후 | + +### 1.4 Agent Key → Admin JWT (인증 흐름) + +``` +Client --Bearer bn_agent_*--> MCP +MCP --POST /admin/api/v1/auth/agent { agentKey }--> Admin API (#340) +Admin --TokenItem(access, refresh)--> MCP (메모리만) +MCP --Bearer access--> 화이트리스트 Admin API +MCP --툴 결과(토큰 필드 없음)--> Client +``` + +| 규칙 | 내용 | +|---|---| +| 클라이언트 제시 | Agent Key(또는 이후 안 A의 MCP audience JWT)만 | +| 거부 | 사람 Admin JWT, Product JWT, audience 불일치 토큰 제시 → 401 | +| 교환 주체 | MCP 서버만. 교환 API는 내부 네트워크/서비스 호출 | +| Admin JWT 수명 | access 위주. **refresh last-writer-wins** → 파드 간 refresh 공유·재사용 금지. 만료 시 교환 재시도 | +| 캐시 | 요청 단위 또는 Redis `agentId` 바인딩 + TTL ≤ access 잔여. JVM static 금지 | +| 로그 | `bn_agent_*`·JWT 원문 0건. agentId/profileCode만 | +| 시드 주의 | 현재 에이전트 매핑 admin = `ROOT_ADMIN` → 툴/아웃바운드 allowlist로 폭발 반경 축소 (역할 하향은 후속 이슈) | + +**1차 인증 안**: **B (정적 Bearer)**. 소수 내부 에이전트 + Claude/Cursor/Codex 네이티브 헤더 지원. +**이후 안 A**: OAuth 2.1 RS + PRM, audience=`https://mcp.bottlenote.com`, client_credentials. + +### 1.5 인가 · Rate limit (아키텍처 수준) + +- 연결 인증 ≠ 전 툴 허용. scope: `admin:read` 기본 / `admin:whisky:write` / `admin:image:presign`. +- annotation(`readOnlyHint` 등)은 UX만. 인가·confirm은 **서버 디스패처**. +- Rate limit: Redis. 키=`agentId` 또는 XFF IP. write/presign/교환 > read. 로컬 카운터 금지. +- 아웃바운드: **URL allowlist** (툴 미등록 + 호출 경로 이중). + +--- + +## 2. First PR Vertical Slice vs Later Phases (#370 DoD) + +### 2.1 #370 DoD 해석 (브리프 기준) + +| DoD 축 | 완료 조건(합의 초안) | 1차 PR | 이후 | +|---|---|---|---| +| 원격 MCP 서버 | Streamable HTTP + 내부 에이전트 연결 | dev 호스트 | prod 호스트 | +| Agent Key 인증 | #340 교환, 토큰 통과 0 | 안 B | 안 A 선택 | +| 위스키 조회 | search/get (+lookup·참조) | search/get | 나머지 read | +| 위스키 단건 생성·수정 | confirm + 감사 | 제외 | P2 | +| 이미지 준비 | presign | 제외 | P2 | +| 변경 전후 검증 | preview_diff | 제외 | P2 | +| 삭제·bulk 비제공 | 미등록 + allowlist | 강제 | 유지 | +| 감사 | 툴 호출 단위 추적 | 구조화 로그 훅 | #341 풀 | +| 다중 인스턴스 | sticky 없이 동작 | 설계+dev 스모크 | prod replicas≥2 | + +### 2.2 First PR — 최소 수직 슬라이스 + +**목표**: “에이전트가 키로 인증하고, 위스키를 검색·조회하며, 호출이 추적 가능한가?”를 최소 코드로 증명. + +| Step | 내용 | 완료 조건 | +|---|---|---| +| S0 | `bottlenote-admin-mcp` 모듈 + Streamable HTTP `/mcp` + health | bootRun, sticky 불필요 | +| S1 | Agent Key → `POST .../auth/agent` → access 요청 스코프 | 유효/무효 키 테스트, 원문 미로그 | +| S2 | 툴 2개: `bottlenote_whisky_search`, `bottlenote_whisky_get` | Admin 왕복, `size` ≤50 클램프 | +| S3 | 구조화 감사 로그(임시): agentId/profileCode, tool, targetIds?, status, durationMs, correlationId | 스크러버 검증. #341 이식 필드 정렬 | +| S4 | 아웃바운드 allowlist: agent login + alcohols GET만 | DELETE/bulk 코드 경로 0 | +| S5 (배포, 서브모듈) | dev Deployment + Service + HTTPRoute `/mcp`·`/actuator` | 401 무키, 200 tools/list | + +**1차 PR 의도적 제외**: create/update, presign, region/distillery write, preview_diff, OAuth/PRM, #341 스키마 본구현, production multi-replica, Gateway global RL. + +**1차 수락 기준**: + +1. `POST .../mcp` + 유효 Bearer → `tools/list`에 등록 툴 +2. 무효/누락 Bearer → 401, body에 키 미포함 +3. search/get 실 Admin 왕복 성공 +4. 커밋·로그·트레이스에 `bn_agent_` 원문 0건 +5. sticky 없이 list/call 가능 (dev replicas=2 스모크 권장) + +### 2.3 Later Phases (DoD 완성 순서) + +| Phase | 내용 | DoD 기여 | +|---|---|---| +| **P1** (본 문서 First PR) | 모듈 + 교환 + whisky search/get + allowlist + 구조화 로그 + dev 배포 | 연결·조회 증명 | +| **P2** | 참조 조회 6툴 (lookup, category, distillery list/get, region list/get, tasting_tag_list) | 생성 워크플로 입력 | +| **P3** | write: create/update(`confirm=true`), image_presign 어댑터, preview_diff; write rate limit | 단건 변경 워크플로 | +| **P4** | #341 감사 테이블/이벤트 연계 (before/after, agent 차원, 성공·deny 1행) | 운영 감사 DoD | +| **P5** | production 호스트, replicas≥2, PDB, 릴리스 워크플로 편입 | HA | +| **P6** | 안 A OAuth RS + PRM (필요 시) | 범용 클라이언트 | +| **P7** | Gateway RL / `Mcp-Method` 정책, 에이전트 역할 ROOT_ADMIN 하향 | 보안 하드닝 | +| **P8** | Java SDK `2026-07-28` 마이그레이션 (트랜스포트만 교체 가정) | 스펙 정합 | + +**쓰기 슬라이스(P3) 전 게이트**: 최소 구조화 감사 훅 또는 #341 최소 스키마 없이 운영 write 금지 권고. + +--- + +## 3. Tool Inventory — Include vs Never-expose + +예산: **등록 13개** (조회 8 + 변경 5 계열). 명명: `bottlenote_{domain}_{action}`. + +### 3.1 INCLUDE (서버 등록) + +| # | tool | R/W | 1차 PR | Phase | Admin API | +|---|---|---|---|---|---| +| 1 | `bottlenote_whisky_search` | read | **Y** | P1 | `GET /admin/api/v1/alcohols` | +| 2 | `bottlenote_whisky_get` | read | **Y** | P1 | `GET /admin/api/v1/alcohols/{id}` | +| 3 | `bottlenote_whisky_lookup` | read | N | P2 | `GET .../alcohols/lookup` | +| 4 | `bottlenote_category_reference_get` | read | N | P2 | `GET .../alcohols/categories/reference` | +| 5 | `bottlenote_distillery_list` | read | N | P2 | `GET .../distilleries` | +| 6 | `bottlenote_distillery_get` | read | N | P2 | `GET .../distilleries/{id}` | +| 7 | `bottlenote_region_list` | read | N | P2 | `GET .../regions` | +| 8 | `bottlenote_region_get` | read | N | P2 | `GET .../regions/{id}` | +| 9 | `bottlenote_tasting_tag_list` | read | N | P2 | `GET .../tasting-tags` | +| 10 | `bottlenote_whisky_create` | write + **confirm** | N | P3 | `POST .../alcohols` | +| 11 | `bottlenote_whisky_update` | write + **confirm** | N | P3 | `PUT .../alcohols/{id}` | +| 12 | `bottlenote_image_presign` | write (URL만) | N | P3 | `GET .../s3/presign-url` (스키마 어댑터) | +| 13 | `bottlenote_whisky_preview_diff` | read | N | P3 | 로컬 get+diff (전용 API 불필요) | + +공통 규칙: page size 기본 20·최대 50, `additionalProperties: false`, write는 서버 `confirm=true` 강제, 목록은 요약 필드. + +### 3.2 NEVER-EXPOSE (미등록 + 아웃바운드 거부) + +| 금지 | 이유 | 대응 Admin API (존재해도 비노출) | +|---|---|---| +| `*_delete` / 소프트삭제 일괄 | #370 자동 삭제 비제공 | `DELETE /alcohols|distilleries|regions|tasting-tags/{id}` | +| bulk reorder / 대량 수정 단일 툴 | 폭발 반경 | `PATCH .../bulk/reorder` 등 | +| 무페이징 `list_all_*` | 컨텍스트 폭증 | — | +| Agent Key / JWT 발급·조회 툴 | 시크릿 노출 | #340 교환은 MCP 내부 전용 | +| 토큰 통과 프록시 툴 | 스펙 금지 | — | +| region/distillery/tag CUD 툴 | 1차 범위 밖 (참조 조회만) | POST/PUT/DELETE 해당 리소스 | +| tag↔alcohol 전용 attach/detach 툴 | whisky create/update `tastingTagIds`로 흡수 | `POST/DELETE .../tasting-tags/{id}/alcohols` | +| curation / banner / user / review 전면 | #370 범위 밖 | 별도 카탈로그 | +| 웹검색·Whiskybase·출처 판정·태그 자동생성 | 명시 배제 | 리서치 MCP 몫 | + +NEVER의 본방어 = **툴 미등록 + Admin HTTP allowlist**. JWT가 ROOT_ADMIN이어도 MCP가 DELETE를 호출하지 못하게 한다. + +### 3.3 Scope 맵 (구현 시) + +| scope | 툴 | +|---|---| +| `admin:read` | #1–#9, #13 | +| `admin:whisky:write` | #10–#11 (+ read 권장) | +| `admin:image:presign` | #12 | + +초기 에이전트: `admin:read` only → write 필요 시 별 에이전트 또는 step-up. + +--- + +## 4. Risks + +### 4.1 Java SDK gap (스펙 2026-07-28 vs SDK 2025-11-25) + +| 항목 | 내용 | +|---|---| +| 사실 | MCP 최신 스펙 `2026-07-28` = stateless, session 제거, `server/discover` 등. Java SDK GA v2.0.0은 **`2025-11-25`** 트래킹. 2026-07-28 대응 릴리스 조사 시점 없음 | +| 리스크 | 클라이언트가 2026-07-28 only 요구 시 연결 실패; 세션 API에 비즈니스 상태를 묶으면 마이그레이션 재작성 | +| 완화 | **와이어는 2025-11-25, 설계는 stateless**. 세션 미의존. Assumption으로 plan에 고정. SDK 업그레이드 시 트랜스포트 계층만 교체 | +| 차단 가능 | Spring AI ↔ Boot 3.4.11 의존성 해석 실패 → **착수 전 스파이크(A1)** | + +### 4.2 Multi-pod + +| 항목 | 내용 | +|---|---| +| 사실 | AGENTS.md: 다중 인스턴스, JVM 로컬로 카운트·락 금지. product prod replicas 2. **admin-api는 현재 replicas 1**. MCP 스펙도 sticky 불필요 방향 | +| 리스크 | 세션/static rate limit/파드 로컬 JWT 캐시 → 불일치·우회. 동일 Agent Key로 다 파드가 **refresh 경쟁** 시 last-writer-wins로 상호 무효 | +| 완화 | MCP **stateless** + Redis rate limit/handle. Admin JWT **access 위주**, refresh 공유 금지. Service `sessionAffinity` 미사용. 설계부터 multi 전제 (admin 스케일 전에도) | + +### 4.3 #341 Audit gap + +| 항목 | 내용 | +|---|---| +| 사실 | 운영 감사 테이블/툴 호출 로그 **없음**. 있는 것은 JPA `AuditPrincipal`(V5) + `AGENT` enum. `AuditorAwareImpl`은 항상 **ADMIN**. 에이전트 토큰 호출도 ADMIN 주체 계약(통합 테스트) | +| 리스크 | 쓰기 후 “누가 무엇을” 추적 불가. 교환 직후 profileCode 유실. 감사 실패 삼키고 write 성공 시 컴플라이언스 붕괴 | +| 완화 | **1차**: 구조화 앱 로그 + 스크러버 + 필드 세트 정렬(보안 체크리스트 5.1). **JPA Auditor를 AGENT로 바꾸지 않음**(계약 유지). MCP 감사는 별도 경로. **P3 write 전** #341 최소 연동 또는 감사 실패 시 write 실패 정책 합의 | +| 권장 필드 | timestamp, traceId, agentId, toolName, rw, decision, denyReason?, argsRedacted, targetIds, before/after(write), resultCode, durationMs, clientIp | + +### 4.4 기타 상위 리스크 (요약) + +| ID | 리스크 | 완화 | +|---|---|---| +| R1 | 시드 Agent = ROOT_ADMIN → 탈취 JWT = 전 Admin API | MCP allowlist; 역할 하향 후속 이슈 | +| R2 | Envoy Streamable HTTP 버퍼링/타임아웃 | 인프라 실측 후 툴 타임아웃 정합 | +| R3 | presign 스키마 vs Admin `rootPath/uploadSize` 불일치 | P3 어댑터 | +| R4 | 호스트명 `mcp.bottlenote.com` vs `bottle-note.com` | DNS 팀 확정 | +| R5 | Agent Key 로그 유출 | K1–K8 체크리스트 + CI secret scan | + +--- + +## 5. Exact File / Module List — First Implementation PR + +프로덕션 비즈니스 로직은 아래 **신규 MCP 모듈 + 루트 빌드 연결**에 한정. Admin 도메인 서비스 수정 없음(교환 API 기존 사용). + +### 5.1 이 저장소 (API 서버) — First PR 예상 경로 + +| 경로 | 작업 | +|---|---| +| `settings.gradle` (또는 `settings.gradle.kts`) | `bottlenote-admin-mcp` include | +| `build.gradle` / version catalog (`gradle/libs.versions.toml`) | Spring AI MCP starter, WebClient 등 버전 핀 (스파이크 후) | +| `bottlenote-admin-mcp/build.gradle` | bootJar 모듈 정의, mono 비의존 또는 최소 공통만 | +| `bottlenote-admin-mcp/src/main/resources/application.yml` | port, MCP STREAMABLE `/mcp`, Admin base URL, Redis, actuator | +| `bottlenote-admin-mcp/src/main/java/.../AdminMcpApplication.java` | Spring Boot entry | +| `.../config/McpServerConfig.java` (가칭) | Streamable HTTP / tool registration | +| `.../config/SecurityConfig.java` | Bearer Agent Key 필터, 공개 health, 그 외 인증 | +| `.../auth/AgentKeyAuthenticationFilter.java` (가칭) | `bn_agent_*` 검증 흐름 진입 | +| `.../auth/AdminTokenExchangeClient.java` | `POST /admin/api/v1/auth/agent` | +| `.../auth/AdminAccessTokenHolder.java` | 요청 스코프/짧은 캐시 (static 금지) | +| `.../client/AdminApiClient.java` | WebClient + **경로 allowlist** | +| `.../client/AdminApiPaths.java` | 허용 상수: agent, `GET /alcohols`, `GET /alcohols/{id}` | +| `.../tool/WhiskySearchTool.java` | `bottlenote_whisky_search` | +| `.../tool/WhiskyGetTool.java` | `bottlenote_whisky_get` | +| `.../tool/ToolSchemaSupport.java` (선택) | size 클램프, schema 공통 | +| `.../audit/McpAuditLogger.java` | 구조화 로그 (키/JWT 스크러빙) | +| `.../audit/SecretScrubber.java` | Authorization / bn_agent_ / JWT 마스킹 | +| `.../ratelimit/RedisRateLimiter.java` (최소) | agentId + IP | +| `bottlenote-admin-mcp/src/test/java/...` | 단위: scrubber, size clamp, allowlist deny; 통합: Fake Admin / WireMock 교환+alcohols | + +**패키지 루트 권장**: `app.bottlenote.mcp` 또는 프로젝트 기존 `app.bottlenote` 규칙에 맞춤 (구현 시 기존 모듈 패키지 관례 확인). + +**First PR에서 건드리지 않음**: + +- `bottlenote-admin-api` 컨트롤러/서비스 (교환·alcohols 이미 존재) +- `bottlenote-mono` 도메인 +- JPA Auditor / V5 principal (AGENT로 변경 금지) +- Flyway (1차 불필요; #341은 P4) +- product-api + +### 5.2 배포 서브모듈 (`git.environment-variables`) — 동일 이슈 연계 PR 가능, 이 브리프 범위는 목록만 + +| 경로 | 작업 | +|---|---| +| `deploy/base/mcp-server.yaml` | Deployment + Service ClusterIP 80→8080 | +| `deploy/overlays/development/*-patch.yaml` | replicas 1, profile, image | +| `deploy/overlays/development/http-route.yaml` | host `mcp.development...`, `/mcp` + `/actuator`, 나머지 403 | +| `deploy/overlays/development/kustomization.yaml` | resources + images | +| (선택) `deploy/overlays/development/secrets/mcp-server-secret.sops.yaml` | `ADMIN_API_BASE_URL`, Redis, OTel — **Agent Key 원문 넣지 않음** | +| (이후) production overlay + DNS `mcp.bottlenote.com` | P5 | +| `.github/workflows/deploy_development_applications.yml` (API 레포) | 이미지 빌드·태그 슬롯 (1차는 수동 태그 가능) | + +### 5.3 문서 (선택, First PR 또는 직후) + +| 경로 | 내용 | +|---|---| +| `docs/mcp-client-setup.md` 또는 plan 절 승격 | Claude/Cursor/Codex placeholder (`bn_agent_`, env `BOTTLENOTE_AGENT_KEY`) | + +### 5.4 First PR 의존성 스파이크 (PR 전 또는 PR 0) + +| 항목 | 산출 | +|---|---| +| Spring AI MCP starter + Boot 3.4.11 해석 | 호환 버전 핀 표 | +| `McpStateless*` / STREAMABLE 기동 스모크 | `/mcp` initialize 또는 tools/list | + +--- + +## 6. Go / No-Go (배포·확장 전) + +보안 체크리스트 요약 — First PR 완료 시 최소 증명: + +1. [ ] Agent Key 원문 로그/트레이스/응답/커밋 0 +2. [ ] 클라이언트 Admin JWT 제시 → 401, Admin API 전달 0 +3. [ ] DELETE/bulk 툴 미등록 + allowlist 외 호출 0 +4. [ ] tools/call(또는 list) 성공·실패 감사/구조화 로그 1행 +5. [ ] Redis 또는 동등 공유 저장소 전제 설계 (로컬 RL 없음) +6. [ ] sticky 없이 동작 + +Write 확장 전 추가: + +7. [ ] confirm 없이 write → 거부 + deny 감사 +8. [ ] before/after (update) +9. [ ] write rate limit + 429 +10. [ ] #341 연계 정책 합의 + +--- + +## 7. Decisions Locked vs Open + +### Locked (이 브리프 권장 = 구현 기본값) + +1. 모듈: **독립 `bottlenote-admin-mcp`** +2. Transport: **Streamable HTTP `/mcp` only** +3. 자격: **Agent Key → 서버 내부 Admin JWT (#340)**, 토큰 통과 금지 +4. 1차 인증 표면: **안 B** +5. 설계: **stateless / multi-pod / Redis for RL** +6. First tools: **whisky_search + whisky_get only** +7. NEVER: delete, bulk, token tools, 범위 외 도메인 +8. #341: 1차는 구조화 로그; JPA AGENT 전환 안 함 + +### Open (구현 전 확인) + +| # | 항목 | 담당 | +|---|---|---| +| O1 | prod 호스트 최종 문자열 | 인프라/DNS | +| O2 | Envoy 스트리밍·timeout 실측 | 인프라 | +| O3 | Spring AI 버전 핀 결과 | 백엔드 스파이크 | +| O4 | 감사 실패 시 write 실패 여부 | #341 합의 | +| O5 | 안 A 도입 시점 | 제품/보안 | +| O6 | Rate limit 수치 운영 합의 | 운영 | + +--- + +## 8. Anti-patterns (즉시 거부) + +- product-api에 Admin MCP 부착 +- sticky session / 파드 로컬 rate limit +- 클라이언트 JWT를 Admin Authorization에 패스스루 +- 세션·static에 비즈니스 상태 +- 삭제 방지 = description/annotation만 +- `admin:*` 단일 스코프 +- 1차 PR에 OAuth + 전체 13툴 + prod HA 일괄 +- 테스트 fixture에 실키 `bn_agent_` 커밋 +- 레거시 HTTP+SSE 채택 + +--- + +## Source (내부) + +- `plan/mcp-research-spec-trends.md` +- `plan/mcp-research-codebase-readiness.md` +- `plan/mcp-research-deploy-clients.md` +- `plan/mcp-research-tool-catalog.md` +- `plan/mcp-research-security-checklist.md` +- 관련: `plan/agent-key-token-exchange.md` (있다면) + +--- + +**산출물 성격**: #370 define/plan 의사결정 입력. 이 문서만으로 구현·배포 승인으로 간주하지 않는다. +**다음 권장 단계** (실행하지 않음): `/define` 또는 plan Tasks 분해 → First PR 스파이크(O3) → S0–S5 구현. diff --git a/plan/mcp-research-codebase-readiness.md b/plan/mcp-research-codebase-readiness.md new file mode 100644 index 000000000..3abbe0a54 --- /dev/null +++ b/plan/mcp-research-codebase-readiness.md @@ -0,0 +1,253 @@ +# Admin MCP #370 — Codebase Readiness + +- 조사일: 2026-08-08 / 이슈: `bottle-note/workspace#370` +- task_id: `task_15b6beb24a16` / dispatch_id: `ctx_21b5039f2484` +- 근거: Admin/mono 실코드, Flyway V5·V7, deploy overlay, `plan/mcp-research-spec-trends.md`, `plan/mcp-research-tool-catalog.md` +- 성격: 읽기 전용 readiness 매핑. 프로덕션 코드·PR 없음. 확정 요구사항 아님. + +## Summary + +- **Agent 로그인(#340)은 Admin에 이미 존재**한다. 공개 경로 `POST /admin/api/v1/auth/agent` → 매핑 Admin JWT(`TokenItem`) 발급. MCP 서버가 키를 들고 내부에서만 교환하면 토큰 통과를 피할 수 있다. +- **#370 대상 Admin API(위스키·지역·증류소·카테고리·테이스팅태그·이미지)는 컨트롤러 기준으로 전부 실재**한다. 1차 툴 카탈로그(13개) 중 `preview_diff`만 전용 API 없이 서버 로컬 diff로 충분. +- **#341 운영 감사 로그는 미구현**. 있는 것은 JPA 엔티티 `create/last_modify_principal_*` 컬럼(V5)과 `AuditPrincipalType.AGENT` 열거값뿐이다. 에이전트 토큰으로 호출해도 감사 주체는 **ADMIN**으로 찍힌다(통합 테스트가 명시적으로 고정). +- **다중 파드 규칙과 현재 배포 상태가 어긋나 있다.** 프로젝트 규칙·product-api(prod replicas 2)는 multi-pod 전제지만 **admin-api는 prod/dev 모두 replicas: 1**. MCP는 처음부터 stateless로 설계해야 하며, Admin JWT 교환 시 refresh last-writer-wins와 `agents.last_used_at` 미갱신 공백을 인지해야 한다. +- **첫 수직 슬라이스 권장**: 인증 교환 + `whisky_search`/`whisky_get` 읽기 2툴. 모듈은 **신규 `bottlenote-admin-mcp` bootJar**(Admin HTTP 클라이언트)를 1순위. + +## 경로·인증 실측 + +### 글로벌 prefix + +| 레이어 | 값 | 근거 | +|---|---|---| +| servlet context-path | `/admin/api` | `bottlenote-admin-api/.../application.yml` | +| API version prefix | `/v1` (presentation Controller 자동) | `AdminApiVersionConfig` (`/v2/curation*` 제외) | +| 세션 | `STATELESS` | `SecurityConfig` | +| 기본 인가 | `@SecurityPolicy` 없으면 `REQUIRED_AUTH` | `SecurityPolicyConfig.FALLBACK_AUTH_TYPE` | +| PUBLIC | login / refresh / agent / actuator / error / openapi | 어노테이션 + explicit routes | + +### Agent login + +| 항목 | 실측 | +|---|---| +| 메서드·경로 | `POST /admin/api/v1/auth/agent` | +| 컨트롤러 | `AuthController.loginWithAgent` (`@PostMapping("/agent")`, `PUBLIC`) | +| 요청 DTO | `AgentLoginRequest(agentKey)` `@NotBlank` | +| 키 형식 | `^bn_agent_[A-Za-z0-9_-]{43}$` → SHA-256 (`AgentKeyHasher`) | +| 조회 | `AgentFacade.findActiveAgentAccount` → `agents` ACTIVE + 매핑 admin ACTIVE | +| 응답 | `GlobalResponse` 래핑 `TokenItem(accessToken, refreshToken)` | +| JWT | `tokenProvider.generateAdminToken(email, roles, adminId)` — **사람 관리자와 동일 클레임** | +| 부수효과 | admin `refreshToken` 덮어쓰기, `lastLoginAt` 갱신 | +| 오류 | 형식 오류 400(`AGENT_KEY_INVALID_FORMAT`), 그 외 통합 401(`AGENT_AUTHENTICATION_FAILED`) | +| 시드 | V7: 에이전트 6프로필 a–f, 매핑 `admin_users` roles=`ROOT_ADMIN` | +| 미구현 | `agents.last_used_at` 컬럼만 있고 **코드에서 갱신 없음** | + +시드 에이전트가 `ROOT_ADMIN`이라는 점은 MCP 권한 최소화와 충돌한다. MCP 스코프를 툴 레이어에서 강제해도, 탈취된 Admin JWT 자체는 전체 Admin API를 열 수 있다. + +### Security·토큰 소비 + +- Access JWT는 `AdminJwtAuthenticationFilter`가 보호 경로에 적용. +- 에이전트 발급 토큰으로 보호 API 호출 가능(통합 테스트: `GET /v1/users`). +- 재로그인 시 **이전 refresh 무효(last-writer-wins)**. 동일 에이전트 키로 MCP 파드 여러 개가 각자 refresh를 돌리면 서로 쫓아낸다 → MCP는 **access 위주 + 필요 시 교환 재시도**, refresh를 공유 상태로 쓰지 말 것. + +## 대상 Admin API 맵 (컨트롤러 실측) + +전체 외부 경로 = `/admin/api` + `/v1` + 컨트롤러 매핑. 인가 기본 `REQUIRED_AUTH`(Bearer Admin JWT). + +### Whisky (alcohols) + +| Method | Path | 핸들러 | 서비스 | MCP 카탈로그 | +|---|---|---|---|---| +| GET | `/alcohols` | `searchAlcohols` | `AlcoholQueryService.searchAdminAlcohols` | `bottlenote_whisky_search` | +| GET | `/alcohols/{alcoholId}` | `getAlcoholDetail` | `findAdminAlcoholDetailById` | `bottlenote_whisky_get` | +| GET | `/alcohols/lookup` | `getAlcoholLookups` | `AlcoholLookupService.lookup` | `bottlenote_whisky_lookup` | +| GET | `/alcohols/categories/reference` | `getCategoryReference` | `findAllCategoryReferenceMap` | `bottlenote_category_reference_get` | +| POST | `/alcohols` | `createAlcohol` | `AdminAlcoholCommandService.createAlcohol` | `bottlenote_whisky_create` (+confirm) | +| PUT | `/alcohols/{alcoholId}` | `updateAlcohol` | `updateAlcohol` | `bottlenote_whisky_update` (+confirm) | +| DELETE | `/alcohols/{alcoholId}` | `deleteAlcohol` | `deleteAlcohol` | **NEVER 노출** | + +- 검색 DTO: `AdminAlcoholSearchRequest` — keyword, category, regionId, sort, page(default 0), size(default 20), includeDeleted. +- Upsert DTO: `AdminAlcoholUpsertRequest` — kor/eng name, abv, type, categories, regionId, distilleryId, age, cask, imageUrl, description, volume, tastingTagIds?. +- Lookup: Redis 스냅샷 우선 + local-cache(버전 키로 invalidation). multi-pod 허용 패턴. + +### Region + +| Method | Path | MCP | +|---|---|---| +| GET | `/regions`, `/regions/{id}` | list/get (1차) | +| POST/PUT/DELETE | create/update/delete | 1차 비노출 | +| PATCH | `/{id}/sort-order`, `/bulk/reorder`, `/{parentId}/children/bulk/reorder` | **NEVER bulk** | + +### Distillery + +| Method | Path | MCP | +|---|---|---| +| GET | `/distilleries`, `/distilleries/{id}` | list/get (1차) | +| POST/PUT/DELETE | CUD | 1차 비노출 | +| PATCH | sort-order, bulk/reorder | **NEVER bulk** | + +### Category + +- 전용 `/categories` CRUD 없음. +- 참조만: `GET /alcohols/categories/reference` → 그룹 맵. 카탈로그 툴 1개로 충분. + +### Tasting tag + +| Method | Path | MCP | +|---|---|---| +| GET | `/tasting-tags`, `/tasting-tags/{id}` | list (1차), detail 선택 | +| POST/PUT/DELETE | 태그 CUD | 1차 비노출(사람 Admin 전제) | +| POST/DELETE | `/{tagId}/alcohols` | whisky create/update의 `tastingTagIds`로 흡수, 툴 비노출 | + +참조 검색 DTO 공통: `AdminReferenceSearchRequest` page/size default 0/20. + +### Image (presign) + +| Method | Path | 서비스 | MCP | +|---|---|---|---| +| GET | `/s3/presign-url` | `ImageUploadService.getPreSignUrlForAdmin` | `bottlenote_image_presign` | + +- 쿼리: `rootPath`, `uploadSize`(default 1), `contentType`(default `image/jpeg`). +- 툴 카탈로그의 `fileName` 중심 스키마와 **필드명이 불일치** → 어댑터 필요. +- 바이너리 업로드는 클라이언트→S3 PUT(PreSign). MCP 서버는 URL만 중계. +- expiry 5분. adminId는 SecurityContext에서 강제. + +## Audit vs #341 + +### 현재 있는 것 (entity principal audit) + +- `AuditPrincipal` embeddable: `principal_id`, `principal_type`, `principal_email`. +- `AuditPrincipalType`: `USER | ADMIN | AGENT | SYSTEM | ANONYMOUS`. +- `AuditorAwareImpl`: `CustomAdminUserContext` → **항상 ADMIN**. `AGENT` 분기 없음. +- V5: alcohols/distilleries/regions 등 다수 테이블에 principal 컬럼 표준화. +- 에이전트 토큰 회원가입 통합 테스트: **감사 주체 ADMIN 유지가 계약**. + +### 없는 것 (#341 / MCP 완료 기준 대비) + +| 필요 항목 (#370 감사 기대) | 코드베이스 상태 | +|---|---| +| 툴 호출 단위 append-only 로그 | **없음** (audit_log 테이블/서비스 0) | +| toolName, args(마스킹), 결과 코드, 소요시간 | **없음** | +| 변경 전/후 스냅샷 | 엔티티 last_modify만 존재, before JSON 없음 | +| agentId / profileCode 차원 | JWT·Auditor에 없음. Facade payload에 profileCode만 있고 로그인 후 유실 | +| traceId 전파 | admin `management.tracing.enabled: false` | +| Agent Key / JWT 스크러빙 정책 코드화 | 로그 관행만, 전용 필터 미확인 | + +### 해석 + +- **#341이 열림(open)인 상태**에서 #370이 “#341 연계”를 전제하면, MCP 1차 슬라이스는 (a) 구조화 애플리케이션 로그 + 후속 #341 스키마 수용 훅, 또는 (b) #341 최소 스키마를 선행 슬라이스로 묶는 선택이 필요하다. +- 엔티티 principal을 AGENT로 바꾸면 **기존 계약(통합 테스트)이 깨진다**. MCP 감사는 별도 테이블/이벤트가 맞고, JPA Auditor는 당분간 ADMIN 유지가 안전하다. +- 다만 MCP 감사 레코드에는 **반드시** `agentId`/`profileCode`(교환 시점에 Facade에서 확보)를 남겨 “어느 에이전트가 어느 Admin으로 위장했는지” 추적 가능해야 한다. 지금은 교환 직후 profileCode를 버리는 구조다. + +## Multi-pod / 운영 규칙 + +| 규칙·사실 | 함의 | +|---|---| +| AGENTS.md: 인스턴스 다중, JVM 로컬 상태로 카운트·락·스케줄 금지 | MCP 세션/툴 상태 sticky 금지. handle·rate limit은 Redis 등 공유 저장소 | +| MCP 스펙 방향(2026-07-28 stateless) | 세션 없는 Streamable HTTP, round-robin OK (`mcp-research-spec-trends`) | +| admin-api deploy replicas: **1** (dev/prod) | 지금 Admin 자체는 single-pod. MCP 붙여도 당장 sticky 이슈는 적지만 **설계는 multi 전제** | +| product-api prod replicas: **2** | 플랫폼 운영 원칙은 multi. admin/MCP도 확장 시 동일 규칙 | +| JWT access + DB refresh | access는 무상태 검증 가능. refresh는 row last-writer-wins → MCP 파드 간 공유 refresh 금지 | +| Alcohol lookup local-cache + Redis version | 허용된 “짧은 로컬 캐시 + 공유 버전” 패턴. MCP 툴 결과 캐시도 동일 패턴만 | +| OTEL off | 분산 trace 연계는 인프라 켜기 전까지 불완전. 로그에 자체 correlation id 권장 | +| k8s health | `/admin/api/actuator/health/{liveness,readiness}` — MCP 모듈 분리 시 자체 probe 필요 | + +## capability | API | gap | risk + +| capability | API (실측) | gap | risk | +|---|---|---|---| +| Agent 자격 교환 | `POST /admin/api/v1/auth/agent` | 교환 후 profileCode/agentId 유실; last_used_at 미갱신 | 감사·키 사용 추적 공백. 시드 ROOT_ADMIN JWT 탈취 시 전 Admin 권한 | +| Admin JWT 사용 | Bearer on REQUIRED_AUTH routes | MCP 전용 audience/scope 없음 | 토큰이 범용 Admin 키. 스코프 최소화 불가(앱 레이어 가드 필수) | +| Whisky 검색 | `GET /alcohols` | size 상한 서버 강제 여부 툴 어댑터 확인 필요 | 큰 size로 컨텍스트 폭증 | +| Whisky 상세 | `GET /alcohols/{id}` | 없음(준비됨) | 민감 필드 과다 노출 가능 → 툴 응답 축소 권장 | +| Whisky lookup | `GET /alcohols/lookup` | Redis/local-cache 의존 | Redis 장애 시 DB 폴백 지연 | +| Category 참조 | `GET /alcohols/categories/reference` | 없음 | 낮음 | +| Region list/get | `GET /regions`, `/{id}` | 없음 | 낮음 | +| Distillery list/get | `GET /distilleries`, `/{id}` | 없음 | 낮음 | +| Tasting tag list | `GET /tasting-tags` | 없음 | 낮음 | +| Whisky create | `POST /alcohols` | confirm 가드·payload nested 스키마는 MCP 측 신규 | 오생성; 필수 필드 다수 → 모델 실수율 | +| Whisky update | `PUT /alcohols/{id}` | before 스냅샷 자동 감사 없음 | 조용한 덮어쓰기; #341 전 추적 공백 **고** | +| Image presign | `GET /s3/presign-url` | 툴 스키마 vs `rootPath/uploadSize/contentType` 불일치 | 잘못된 경로/MIME; 업로드 남용 | +| preview_diff | 없음(로컬 get+diff) | 전용 API 불필요 | 구현 누락 시 검증 워크플로 약화 | +| Delete / bulk reorder | DELETE·PATCH bulk 존재 | MCP 미등록만으로는 우회 호출 가능(Admin JWT 보유 시) | **고** — JWT 권한 과다 + NEVER 툴만으로는 부족, MCP 서버가 Admin 호출 화이트리스트 강제 필요 | +| 운영 감사 로그 | entity principal only | #341 미구현; AGENT 타입 미사용 | 컴플라이언스·사고 대응 공백 **고** | +| Multi-pod MCP | (미존재) | 모듈·배포 없음; OTEL off | 세션 상태 설계 시 실패; 관측 공백 | +| Spring AI / MCP SDK | 의존성 0 | 버전 핀·Boot 3.4.11 호환 검증 필요 | 착수 차단 가능(가정) | + +## 모듈 배치 권장 + +### 후보 비교 + +| 옵션 | 구조 | 장점 | 단점 | 판정 | +|---|---|---|---|---| +| **A. 신규 `bottlenote-admin-mcp` bootJar** | settings.gradle include, WebClient/Feign → admin-api 내부 URL | 프로세스 격리, Admin 표면과 MCP 표면 분리, 독립 스케일·배포, Admin API 계약을 그대로 소비 | HTTP 홉, 배포 파이프라인 추가, DTO 이중 매핑 | **1순위** | +| B. admin-api 내부 패키지 | Kotlin presentation + Spring AI in-process | 빠른 프로토타입, 서비스 직접 호출 | MCP 포트·인증 혼재, Admin 장애 도메인 공유, 레이어 표준(Controller→Service)과 MCP 툴 계층 혼선 | 스파이크만 | +| C. mono 라이브러리 only | 툴 구현을 mono에 | 공유 로직 | mono는 도메인 라이브러리 — 프로토콜/트랜스포트 넣으면 경계 붕괴 | **비권장** | +| D. product-api 합류 | — | — | Admin 데이터 경로와 무관 | 탈락 | + +### 권장 토폴로지 (A) + +``` +[MCP Client] + | Streamable HTTP + Agent Key (Bearer bn_agent_* 또는 OAuth 안 A) + v +[bottlenote-admin-mcp] --stateless-- + | 1) POST /admin/api/v1/auth/agent (서버 내부만, 키 저장소/ENV) + | 2) Admin JWT로 화이트리스트 Admin API만 호출 + v +[bottlenote-admin-api] --> [mono] --> MySQL/Redis/S3 +``` + +- mono 도메인 서비스 직접 주입 금지(옵션 A). Facade/Service를 MCP가 우회하면 트랜잭션·인가·OpenAPI 계약을 두 번 유지하게 된다. +- 의존성: Spring Boot 3.4.x, Spring AI MCP server starter, WebClient, 기존 observability 모듈 선택. +- 시크릿: Agent Key는 MCP 파드 Secret(또는 호출 클라이언트 제공 키를 요청마다 해시 검증). **Admin JWT는 프로세스 메모리·요청 스코프만**, Redis에 넣더라도 TTL·agent 바인딩. +- NEVER 목록은 **아웃바운드 HTTP 화이트리스트**로 강제(툴 미등록 + URL allowlist 이중). + +## 첫 수직 슬라이스 (권장) + +목표: “에이전트가 키로 인증하고, 위스키를 검색·조회하며, 호출이 추적 가능한가?”를 최소 코드로 증명. + +| Step | 내용 | 완료 조건 | +|---|---|---| +| S0 | 모듈 스캐폴드 `bottlenote-admin-mcp` + Streamable HTTP `/mcp` + health | bootRun, multi-pod sticky 불필요 확인 | +| S1 | Agent Key 수신 → `POST .../auth/agent` → access 캐시(요청 단위) | 통합 테스트: 유효/무효 키, 형식 오류 | +| S2 | 툴 2개: `bottlenote_whisky_search`, `bottlenote_whisky_get` | Admin 실 API 왕복, page size ≤50 클램프 | +| S3 | 구조화 감사 로그(임시): agentProfileCode, tool, alcoholId?, status, durationMs, correlationId | 키/JWT 로그 미출력 검증. #341 테이블 생기면 동일 필드 이식 | +| S4 | 아웃바운드 allowlist: agent + alcohols GET만 | DELETE/ bulk 경로 호출 코드 경로 0 | + +**슬라이스에서 의도적으로 제외**: create/update, presign, region/distillery write, #341 스키마 본구현, OAuth AS(안 A), preview_diff. + +### 후속 슬라이스 순서 (참고) + +1. 참조 조회(category/region/distillery/tasting-tag list) → 생성 워크플로 입력 완비 +2. image presign 어댑터 + whisky create/update(+confirm) + preview_diff +3. #341 감사 테이블 연계 + (선택) JWT에 agent claim 또는 교환 응답 확장 +4. 에이전트 Admin 역할을 ROOT_ADMIN에서 최소 권한 역할로 하향(별 이슈) + +## 차단·가정 + +| ID | 항목 | 조치 | +|---|---|---| +| A1 | Spring AI MCP ↔ Boot 3.4.11 호환 | 착수 전 의존성 해석 스파이크 | +| A2 | Java SDK는 2025-11-25 트래킹, 설계는 stateless | 스펙 트렌드 문서와 동일 Assumption | +| A3 | #341 open | 1차는 구조화 로그 훅; 스키마 확정 시 이식 | +| A4 | 시드 Agent = ROOT_ADMIN | 권한 축소는 보안 후속; MCP allowlist로 완화 | +| A5 | admin-api 단일 레플리카 | MCP multi 배포 시 Admin 스케일·rate limit 재검토 | +| A6 | Gitea/workspace 이슈 본문은 이 조사에서 직접 fetch 안 함 | 요구는 카탈로그·스펙 리서치·코드 실측으로 대체 | + +## 관련 파일 (앵커) + +- Auth: `bottlenote-admin-api/.../auth/presentation/AuthController.kt`, `bottlenote-mono/.../user/service/AdminAuthService.java`, `.../agent/**` +- Alcohols: `.../alcohols/presentation/AdminAlcoholsController.kt`, `AdminAlcoholCommandService.java`, `AdminAlcoholUpsertRequest.java` +- Region/Distillery/Tag: `AdminRegionController.kt`, `AdminDistilleryController.kt`, `AdminTastingTagController.kt` +- Image: `AdminImageUploadController.kt`, `ImageUploadService.java` +- Audit: `AuditPrincipal.java`, `AuditorAwareImpl.java`, `AuditPrincipalType.java`, V5 SQL +- Agent seed: `V7__add_agent_key_auth.sql`, `agent/api-keys.sops.yaml`(원문 비커밋 정책) +- Deploy: `git.environment-variables/deploy/base/admin-api.yaml`, overlays `replicas: 1` +- 선행 리서치: `plan/mcp-research-spec-trends.md`, `plan/mcp-research-tool-catalog.md`, `plan/agent-key-token-exchange.md` + +## 결론 + +1. **코드베이스 readiness: 인증·대상 CRUD/조회 API는 준비됨. 감사·권한 세분화·MCP 모듈은 공백.** +2. **#340으로 자격 교환 경로가 닫혀 있어 #370 착수 가능.** #341 없이도 읽기 슬라이스는 가능하나, 쓰기 슬라이스 전에 감사 훅(최소 로그 또는 #341)이 필요하다. +3. **첫 수직 슬라이스 = 신규 MCP 모듈 + agent 교환 + whisky search/get + allowlist + 구조화 로그.** +4. **삭제·bulk·토큰 통과·세션 상태·Admin JWT 스코프 과다는 최상위 리스크**이며 서버 allowlist와 무상태 설계로 막는다. diff --git a/plan/mcp-research-deploy-clients.md b/plan/mcp-research-deploy-clients.md new file mode 100644 index 000000000..1024d6044 --- /dev/null +++ b/plan/mcp-research-deploy-clients.md @@ -0,0 +1,536 @@ +# MCP 배포·클라이언트 설정 리서치 — Admin MCP #370 + +- 조사일: 2026-08-08 / 이슈: `bottle-note/workspace#370` +- 관련: #340 Agent Key 완료, #341 감사 로그 open +- 근거: `git.environment-variables/deploy/**` 실측(시크릿 값 미열람) + `plan/mcp-research-spec-trends.md` + 클라이언트 설정 공개 문서 +- 성격: 읽기 전용 설계 입력. 프로덕션 코드 변경 없음. 확정 요구사항 아님. + +## Summary + +- **제품 형태**: `https://mcp.bottlenote.com` (Streamable HTTP, 엔드포인트 `/mcp`) 원격 MCP 서버. Agent Key(`bn_agent_*`)를 클라이언트가 보내고, MCP 서버가 내부에서 Admin JWT로 교환(#340) — **토큰 통과 금지**. +- **배포 형태**: 기존 product/admin과 **동일 GitOps 패턴**을 복제한다. `deploy/base` + `overlays/{development,production}` + Envoy Gateway `HTTPRoute` + KSOPS Secret + Argo CD auto-sync. 매니페스트 원본은 서브모듈 `git.environment-variables`(원격 `bottle-note/environment-variables`). +- **TLS**: 클러스터 밖 TLS 종단은 `main-gateway`(Envoy Gateway, `envoy-gateway-system`). 앱 컨테이너는 HTTP `:8080`만 노출. 앱에서 TLS 종료하지 않음. +- **CORS**: MCP 클라이언트는 브라우저 Origin이 아니라 **네이티브 앱/CLI 헤더 호출**이 기본. 서버 CORS allowlist는 비우거나 최소 유지. 브라우저 커넥터(ChatGPT 등 OAuth)를 나중에 열 때만 별도 정책. +- **Health**: product/admin과 동일하게 Actuator liveness/readiness. 게이트웨이는 `/actuator` prefix 허용 패턴 유지. +- **Rate limit / XFF**: 게이트웨이가 XFF를 재작성하므로 앱이 받는 XFF는 신뢰 가능. **Rate limit 키 = Agent Key 주체(agentId) 우선, fallback = XFF 클라이언트 IP**. Redis 공유 카운터. **sticky session 불필요**(stateless MCP). +- **클라이언트**: Claude Code / Cursor / Codex(CLI)는 Streamable HTTP + `Authorization: Bearer` 네이티브 지원. Claude Desktop은 `mcp-remote` 브릿지 또는 stdio. 문서·온보딩에는 **원문 키 placeholder만** (`bn_agent_` / env var). +- **1차 PR vs 이후**: 1차는 앱 모듈(또는 admin-api 내장 옵션 결정) + dev HTTPRoute + 읽기 툴 + 정적 Bearer + 클라이언트 문서. 이후 OAuth/PRM, prod multi-replica, 게이트웨이 rate limit, 쓰기 툴, 감사(#341). + +## 1. 제품 형태 (`mcp.bottlenote.com`) + +| 항목 | 권장 값 | 근거 | +|---|---|---| +| Public URL | `https://mcp.bottlenote.com` | 스펙 트렌드 문서 audience 예시, #370 원격 서버 전제 | +| Dev URL | `https://mcp.development.bottle-note.com` (가칭) | 기존 호스트 규칙: `*.development.bottle-note.com` / `api.development...` | +| Transport | **Streamable HTTP only** | 레거시 HTTP+SSE Deprecated. Spring AI `protocol=STREAMABLE` | +| Path | `/mcp` | Spring AI MCP Server Boot Starter 기본 경로 | +| Protocol wire | 구현: `2025-11-25` (Java SDK v2), 설계: stateless(2026-07-28 전제) | 스펙 트렌드 문서 | +| Auth (1차) | `Authorization: Bearer bn_agent_...` | 안 B 최소 비용. 소수 내부 에이전트 | +| Auth (이후) | OAuth 2.1 RS + PRM, audience=`https://mcp.bottlenote.com` | 안 A. 범용 클라이언트 호환 | +| Upstream | Admin API (내부 ClusterIP 또는 동일 프로세스) | 툴 카탈로그 13개 → Admin 엔드포인트 | +| Session affinity | **없음** | sticky session 금지. handle 필요 시 Redis | + +### 모듈 배치 옵션 + +| 옵션 | 설명 | 1차 추천 | +|---|---|---| +| **A. 독립 Deployment `mcp-server`** | 별도 jar/이미지, admin-api를 HTTP 클라이언트로 호출 | **권장** — blast radius·스케일·게이트웨이 정책을 MCP 전용으로 분리 | +| B. admin-api 내장 | 같은 프로세스에 `/mcp` 노출 | 배포 단순. 단 admin 부하·재시작 커플링 | +| C. product-api 내장 | 비권장 | Admin 권한 툴을 product에 넣으면 경계 붕괴 | + +옵션 A를 전제로 아래 배포 패턴을 기술한다. B로 가면 HTTPRoute만 admin 호스트에 `/mcp`를 추가하는 축소판이 된다. + +## 2. 기존 deploy 패턴 실측 (시크릿 미포함) + +### 2.1 디렉터리 구조 + +``` +git.environment-variables/deploy/ + base/ # 공통 Deployment + Service + product-api.yaml + admin-api.yaml + admin-dashboard.yaml + frontend.yaml + batch-module.yaml + kustomization.yaml + overlays/ + development/ # ns: bottlenote-development + kustomization.yaml # resources: ../../base + http-route + redis + *-patch.yaml # replicas, profile, nodeSelector + http-route.yaml # Envoy Gateway HTTPRoute + secrets-generator.yaml # KSOPS + secrets/*.sops.yaml + redis-replication.yaml + production/ # ns: bottlenote-production + (동일 + product-api-pdb.yaml) + argocd/bottlenote/applications/ + bottlenote-development.yaml + bottlenote-production.yaml # source path = deploy/overlays/{env} +``` + +### 2.2 Deployment / Service 관례 (product-api 기준) + +| 항목 | 값 | +|---|---| +| labels | `app: product-api` (+ overlay `env: dev|prod`) | +| container port | `8080` name `http` | +| Service | ClusterIP `port: 80` → `targetPort: 8080` | +| envFrom | `secretRef: product-api-env` (KSOPS 생성 Secret) | +| env 고정 | `TZ=Asia/Seoul`, overlay에서 `SPRING_PROFILES_ACTIVE`, `SERVER_NAME`=pod name | +| imagePullSecrets | `private-registry-secret` | +| registry | `docker-registry.bottle-note.com/...` | +| image tag | overlay `kustomization.yaml` `images[].newTag` — GH Actions가 갱신 | +| nodeSelector | `kubernetes.io/arch: arm64` | +| replicas | product prod **2**, admin/dev **1** | +| PDB | product prod only: `minAvailable: 1` | +| resources (product base) | req 500m/2Gi, lim 1500m/6Gi | +| resources (admin base) | req 250m/1Gi, lim 750m/3Gi | + +**MCP 1차 제안 리소스** (admin과 유사, 트래픽 소수): + +- req: cpu 250m / mem 512Mi–1Gi +- lim: cpu 500m–750m / mem 1–2Gi +- replicas: dev 1 / prod 1(1차) → 2 + PDB(이후) + +### 2.3 Health probe 관례 + +| 서비스 | liveness / readiness path | +|---|---| +| product-api | `/actuator/health/liveness`, `/actuator/health/readiness` | +| admin-api | `/admin/api/actuator/health/liveness` (context-path 포함), readiness 동일 | + +공통 probe 타이밍(base): + +- startup: initialDelay 90s, period 10s, failureThreshold 25, timeout 5s +- liveness: period 10s, timeout 5s, failureThreshold 3 +- readiness: period 5s, timeout 3s, failureThreshold 3 + +**MCP 서버**: context-path 없이 `/actuator/health/{liveness,readiness}` 권장. 게이트웨이 허용 prefix에 `/actuator` 포함. + +### 2.4 HTTPRoute / 게이트웨이 관례 + +- Gateway: `main-gateway` in `envoy-gateway-system` (Gateway API) +- API 종류: `gateway.networking.k8s.io/v1` `HTTPRoute` +- 호스트별 라우트 분리 (prod 예): + - `api.product.bottle-note.com` → product-api + - `admin-api.bottle-note.com` → admin-api (`/admin/api`, `/actuator`) + - `bottle-note.com` → frontend + - `admin.bottle-note.com` → admin-dashboard +- **경로 화이트리스트 + 나머지 403**: `HTTPRouteFilter` `block-unknown-paths` (DirectResponse JSON `{"error":"Forbidden"}`) +- product 허용: `/api/v1`, `/api/v2`, `/actuator` — 그 외 `/` → 403 +- admin 허용: `/admin/api`, `/actuator` — 그 외 `/` → 403 + +**MCP용 HTTPRoute 초안 (prod, 매니페스트 예시 — 아직 미적용)** + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: mcp-server-route + namespace: bottlenote-production +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: main-gateway + namespace: envoy-gateway-system + hostnames: + - mcp.bottlenote.com + rules: + - matches: + - path: + type: PathPrefix + value: /mcp + backendRefs: + - name: mcp-server + port: 80 + - matches: + - path: + type: PathPrefix + value: /actuator + backendRefs: + - name: mcp-server + port: 80 + - matches: + - path: + type: PathPrefix + value: / + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: block-unknown-paths +``` + +dev는 hostname만 `mcp.development.bottle-note.com`(가칭)으로 교체. + +**Streamable HTTP 게이트웨이 주의** (스펙 트렌드 미확인 항목 승격): + +- POST `/mcp` 응답 스트리밍을 버퍼링하면 장기 툴 호출이 끊긴다. +- 타임아웃: 툴 호출 상한(예: 30–60s)보다 게이트웨이/Envoy idle timeout이 짧으면 실패. +- 바디 크기 제한: 목록 응답·diff 스냅샷을 고려해 기본 제한 확인. +- 확인 담당: 인프라. 앱 착수 전 체크리스트에 포함. + +### 2.5 TLS + +- 공개 호스트 TLS는 Gateway 레이어에서 종단 (이 저장소 매니페스트에 Certificate 리소스는 없음 — 클러스터/Gateway 공통 설정 추정). +- 앱: plain HTTP 8080. 컨테이너 간 mTLS 강제 흔적 없음. +- MCP 클라이언트 → `https://mcp.bottlenote.com` 만 문서화. HTTP 평문 공개 금지. + +### 2.6 Secret / 설정 (키 이름만) + +KSOPS `secrets-generator.yaml` → `product-api-secret.sops.yaml` 등이 Secret `product-api-env`로 주입. + +관측된 **키 이름**(값 미열람): `SERVER_PORT`, `DB_*`, `REDIS_*`, `JWT_SECRET_KEY`, `AWS_*`, OTel, Discord, OAuth 쿠키 관련, Root Admin 등. + +Agent Key 원문 보관: + +- 서브모듈 루트 `agent/api-keys.sops.yaml` — 프로필 `0001`–`0006`, 필드 `alias` / `api_key` (SOPS age) +- DB V7: `agents.api_key_hash` SHA-256만 저장, 원문 없음 +- **배포 Secret에 Agent Key 원문을 넣지 않는다.** 원문은 사람/에이전트 클라이언트 로컬 설정 전용. MCP 서버는 해시 검증 또는 Admin 교환 API만 사용. + +MCP 서버 전용 env 후보(신규, 값 설계만): + +| 키 | 용도 | +|---|---| +| `SPRING_PROFILES_ACTIVE` | dev/prod | +| `ADMIN_API_BASE_URL` | 클러스터 내부 `http://admin-api` (+ context `/admin/api`) | +| `MCP_SERVER_PUBLIC_URL` | `https://mcp.bottlenote.com` (OAuth audience / 문서) | +| `REDIS_*` | rate limit·(선택) handle 저장 — 기존 redis 공유 가능 | +| OTel 계열 | 기존 product/admin과 동일 패턴 | +| `JWT`/`AGENT` 교환 관련 | 서버가 Admin 교환 호출 시 필요한 내부 설정 (시크릿은 별도 SOPS) | + +### 2.7 GitOps / 배포 파이프라인 + +| 환경 | 트리거 | 동작 | +|---|---|---| +| development | main CI 성공 또는 `deploy_development_applications.yml` 수동 | 이미지 빌드 → overlay `kustomization.yaml` 이미지 태그 커밋 → Argo sync | +| production | `backend/vX.Y.Z` release published → `deploy_release_applications.yml` | 동일, production overlay | +| batch | `deploy_batch.yml` 수동 | 별도 | + +Argo Application: + +- source: `https://github.com/bottle-note/environment-variables`, path `deploy/overlays/{env}` +- automated prune + selfHeal +- Secret `/data` ignoreDifferences + +**MCP 추가 시 작업 위치** (이 API 서버 레포가 아닌 environment-variables 서브모듈/원격): + +1. `deploy/base/mcp-server.yaml` (Deployment+Service) +2. overlays patch + images 항목 +3. `http-route.yaml`에 호스트/경로 규칙 +4. (선택) `mcp-server-secret.sops.yaml` + secrets-generator +5. GH workflow에 이미지 빌드·태그 갱신 job (1차 수동 태그도 가능) +6. DNS: `mcp.bottlenote.com` / dev 호스트 → Gateway + +로컬에서 이미지 빌드·푸시·매니페스트 직접 운영 배포 금지(AGENTS.md). + +### 2.8 Redis + +- Opstree `RedisReplication` (prod clusterSize 3, AOF, maxmemory 1gb LRU) +- MCP rate limit·짧은 TTL handle에 **기존 Redis 공유** 가능. 별도 Redis는 불필요(1차). + +## 3. Streamable HTTP · CORS · Health 상세 + +### 3.1 Streamable HTTP + +| 항목 | 권장 | +|---|---| +| Method/Path | MCP JSON-RPC over HTTP, 주로 `POST /mcp` (+ 스펙이 요구하는 GET 스트림이 있으면 동일 path) | +| Content-Type | `application/json` (및 스트림 시 스펙 규정 MIME) | +| 필수 헤더(미래 스펙) | `Mcp-Method` / `Mcp-Name` — 게이트웨이 정책 승격 여지. 지금은 앱 레이어에서 툴명 기준 인가 | +| Session | `Mcp-Session-Id`에 서버 상태 묶지 않음. 2025-11-25 SDK가 세션을 쓰더라도 **비즈니스 상태를 세션에 두지 않음** | +| 압축 | product/admin은 Tomcat compression on (json 등). 스트리밍 응답은 압축 off 또는 스트리밍 MIME 제외 확인 | + +### 3.2 CORS + +현재 product/admin: + +- allowlist origin (localhost, github.io 문서 등) +- methods: GET/POST/PUT/DELETE/PATCH/OPTIONS +- headers: `Authorization`, `Content-Type` +- credentials: false +- OpenAPI 문서는 별도 빈/최소 docs CORS + +**MCP 권장**: + +| 클라이언트 유형 | CORS 필요? | +|---|---| +| Claude Code / Cursor / Codex CLI / Desktop(mcp-remote) | **아니오** (Origin 없는 서버-사이드 또는 네이티브 HTTP) | +| 브라우저 기반 커넥터 (ChatGPT custom connector 등) | **예** — OAuth + 제한적 Origin. 1차 범위 밖 | + +1차 서버 설정: + +- 일반 MCP 경로: allow-origins **비움** 또는 미사용. preflight가 오면 403이어도 CLI는 무관. +- 허용 헤더에 최소한 `Authorization`, `Content-Type` (및 스펙 추가 헤더 `Mcp-Method`, `Mcp-Name`을 쓸 계획이면 포함). +- `Access-Control-Allow-Origin: *` + credentials 조합 금지. +- Agent Key를 쿼리스트링에 싣는 방식 금지 (로그·Referer 유출). + +### 3.3 Health · 관측 + +- `/actuator/health/liveness`: 프로세스 생존만 (의존성 제외) +- `/actuator/health/readiness`: Admin API 또는 DB/Redis 중 MCP 동작에 필수인 의존성만 +- 게이트웨이 `/actuator` 공개: 현재 product/admin과 동일하게 **클러스터 밖에서도 경로 허용**. 민감 엔드포인트(`env`, `heapdump` 등)는 Spring 노출 제한으로 막혀 있어야 함 — MCP도 동일 정책 강제. +- OTel: product 시크릿에 이미 OTLP 엔드포인트 키 존재. MCP도 동일 exporter. 툴 호출 span에 `gen_ai.tool.name`, agentId(해시/ID만). + +## 4. Rate limit · XFF · stickiness + +### 4.1 XFF 신뢰 모델 + +AGENTS.md / CLAUDE.md: + +> 앞단 게이트웨이가 클라이언트를 통해 들어온 `X-Forwarded-For`를 제거하고 실제 접속 주소로 다시 채운다. 따라서 앱이 받는 XFF는 신뢰할 수 있다. + +코드 실측(product `SecurityConfig` visitor telemetry): XFF 첫 유효 IP 사용. 동일 해석기를 MCP rate limit·감사 IP 필드에 재사용 가능. + +### 4.2 Rate limit 설계 + +현재 코드베이스에 Bucket4j/전역 rate limit 구현 **없음**. MCP에서 신규 도입. + +| 차원 | 키 | 권장 한도(초안) | 비고 | +|---|---|---|---| +| 인증 주체 | `agentId` (검증 후) | 분당 60 req / 에이전트 | 툴 호출·initialize·tools/list 포함 여부 명시 | +| 미인증/실패 | XFF IP | 분당 20 req | 브루트포스·키 스캔 완화 | +| 쓰기 툴 | `agentId` + tool name | 분당 10 (create/update) | confirm 필수와 병행 | +| 응답 | HTTP 429 + `Retry-After` | 클라이언트 재시도 가이드 | | + +저장소: **Redis** (다중 파드 공유). 로컬 메모리 카운터 금지. + +구현 위치: + +1. 1차: Spring Filter / Interceptor in MCP 앱 (빠름) +2. 이후: Envoy Gateway global rate limit (인프라) — 앱 한도와 이중화 가능 + +### 4.3 Stickiness + +| 대상 | sticky 필요? | +|---|---| +| MCP 세션 → 파드 | **아니오** — stateless 설계 | +| Rate limit | Redis 공유로 sticky 불필요 | +| Admin JWT 캐시(서버 내부) | 파드 로컬 캐시 TTL 짧게 또는 Redis. sticky로 해결하지 않음 | +| Service `sessionAffinity` | **설정하지 않음** (기본 None) | + +Gateway HTTPRoute에 cookie affinity / consistent hash 파드 고정 넣지 않는다. + +## 5. 클라이언트 설정 (Agent Key placeholder) + +원칙: + +- 문서·레포·커밋에 **실키 금지**. placeholder: `bn_agent_` 또는 env `BOTTLENOTE_AGENT_KEY`. +- 원문 키는 `agent/api-keys.sops.yaml` 복호화 권한이 있는 운영자만 로컬에 설정. +- 서버는 키를 툴 인자로 받지 않음. 헤더(또는 OAuth)만. + +Public endpoint (placeholder 호스트): + +```text +https://mcp.bottlenote.com/mcp +``` + +dev: + +```text +https://mcp.development.bottle-note.com/mcp +``` + +### 5.1 Claude Code + +네이티브 Streamable HTTP. + +```bash +claude mcp add --scope user --transport http bottlenote-admin \ + https://mcp.bottlenote.com/mcp \ + --header "Authorization: Bearer ${BOTTLENOTE_AGENT_KEY}" +``` + +또는 프로젝트 `.mcp.json` (키가 파일에 남지 않게 env 치환 지원 시 문서화): + +```json +{ + "mcpServers": { + "bottlenote-admin": { + "type": "http", + "url": "https://mcp.bottlenote.com/mcp", + "headers": { + "Authorization": "Bearer bn_agent_" + } + } + } +} +``` + +검증: `/mcp` 또는 툴 목록에 `bottlenote_whisky_search` 등 노출. + +### 5.2 Cursor + +- Global: `~/.cursor/mcp.json` +- Project: `.cursor/mcp.json` + +```json +{ + "mcpServers": { + "bottlenote-admin": { + "url": "https://mcp.bottlenote.com/mcp", + "headers": { + "Authorization": "Bearer bn_agent_" + } + } + } +} +``` + +Settings → MCP에서 토글/재연결. 프로젝트 레포에 실키 커밋 금지 — 팀 공유 시 env 기반 또는 개인 global 설정. + +### 5.3 Codex CLI + +`~/.codex/config.toml` — Streamable HTTP + bearer env 권장(파일에 키 미기록). + +```toml +[mcp_servers.bottlenote_admin] +url = "https://mcp.bottlenote.com/mcp" +bearer_token_env_var = "BOTTLENOTE_AGENT_KEY" +# 대안: 정적 헤더 (비권장 — 파일에 키 잔존) +# http_headers = { "Authorization" = "Bearer bn_agent_" } +``` + +CLI: + +```bash +export BOTTLENOTE_AGENT_KEY='bn_agent_' +codex mcp add bottlenote_admin \ + --url https://mcp.bottlenote.com/mcp \ + --bearer-token-env-var BOTTLENOTE_AGENT_KEY +codex mcp get bottlenote_admin --json # transport.type == streamable_http 확인 +``` + +### 5.4 Claude Desktop (부록) + +원격 HTTP 네이티브 미흡 시 `mcp-remote` 브릿지: + +```json +{ + "mcpServers": { + "bottlenote-admin": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://mcp.bottlenote.com/mcp", + "--header", + "Authorization:Bearer bn_agent_" + ] + } + } +} +``` + +경로: macOS `~/Library/Application Support/Claude/claude_desktop_config.json`. Node.js PATH 필요. + +### 5.5 클라이언트 호환 매트릭스 + +| 클라이언트 | Transport | Auth 설정 | 1차 지원 | +|---|---|---|---| +| Claude Code | Streamable HTTP native | `--header` / `.mcp.json` | Yes | +| Cursor | Streamable HTTP native | `url` + `headers` | Yes | +| Codex CLI | Streamable HTTP native | `url` + `bearer_token_env_var` | Yes | +| Claude Desktop | stdio bridge (`mcp-remote`) | `--header` | Yes (문서화) | +| ChatGPT connector | OAuth 위주 | 정적 Bearer 비적합 | **이후** (안 A) | + +### 5.6 온보딩 체크리스트 (운영자) + +1. SOPS로 `agent/api-keys.sops.yaml`에서 본인 프로필 키 확인 (원문 재표시 정책은 운영 규약 따름) +2. `export BOTTLENOTE_AGENT_KEY=...` (shell profile 또는 secret manager) +3. 위 클라이언트 중 하나로 연결 +4. 읽기 툴 1회 호출 (`bottlenote_whisky_search`) +5. 401 → 키/헤더 공백, 403 → 에이전트 status, 429 → rate limit 대기 + +## 6. First PR vs Later + +### 6.1 1차 PR 범위 (최소 동작 수직 슬라이스) + +**목표**: 내부 에이전트 1–2명이 dev에서 읽기 툴을 호출할 수 있다. + +| 영역 | 포함 | 제외 | +|---|---|---| +| 앱 | MCP 모듈/서버 스캐폴드, Streamable HTTP `/mcp`, 정적 Bearer Agent Key 검증, Admin 교환 내부 호출 | OAuth/PRM, EMA | +| 툴 | 조회 툴 서브셋 (예: search/get/lookup 3–5개) | 쓰기·presign·preview 전체, 삭제 | +| 배포 | `deploy/base/mcp-server.yaml` + **development** overlay + HTTPRoute + 이미지 파이프라인(또는 임시 수동 태그) | production 다중 레플리카, PDB | +| DNS/TLS | dev 호스트만 | prod `mcp.bottlenote.com` (준비만) | +| Rate limit | 앱 레벨 agentId + XFF, Redis | Gateway global RL | +| CORS | 기본 거부/미사용 | 브라우저 커넥터 | +| 문서 | 이 plan 기반 클라이언트 설정 절 (placeholder) | 공개 레지스트리 등재 | +| 감사 | 기존 로그에 agentId·툴명 최소 필드 | #341 풀 모델 | +| 테스트 | 단위(Fake Admin)·계약 테스트 | 풀 e2e 부하 | + +**1차 수락 기준 초안**: + +1. `POST https://mcp.development.../mcp` + 유효 Bearer → `tools/list`에 등록 툴 +2. 무효/누락 Bearer → 401 +3. 파드 2개로 올려도 sticky 없이 list/call 성공 (dev에서 replicas=2 스모크 가능) +4. 시크릿·커밋에 `bn_agent_` 원문 0건 +5. HTTPRoute: `/mcp`, `/actuator`만 백엔드, 기타 403 + +### 6.2 이후 PR / 단계 + +| 단계 | 내용 | +|---|---| +| P2 | 쓰기 툴 + `confirm=true` 서버 가드, presign, preview_diff | +| P3 | production 호스트, replicas≥2, PDB, 릴리스 워크플로 정식 편입 | +| P4 | OAuth 2.1 + Protected Resource Metadata (안 A), audience 검증 | +| P5 | #341 감사 로그 풀 연동 (before/after, traceId) | +| P6 | Gateway 단 rate limit / `Mcp-Method` 헤더 정책 (스펙·SDK 지원 시) | +| P7 | 브라우저 커넥터·ChatGPT 등 — CORS/OAuth 별도 설계 | +| P8 | Java SDK `2026-07-28` 마이그레이션 (트랜스포트만 교체 가정) | + +### 6.3 의사결정이 필요한 항목 (구현 전 확인) + +1. **모듈 배치**: 독립 `mcp-server` vs admin-api 내장 (이 문서 권장: 독립) +2. **prod 호스트 최종 문자열**: `mcp.bottlenote.com` vs `mcp.bottle-note.com` (기존 도메인은 `bottle-note.com` 하이픈 패턴 — **DNS 팀과 확정 필요**. 스펙 트렌드 문서는 `mcp.bottlenote.com` 표기) +3. **게이트웨이 스트리밍/타임아웃** 실측 +4. **Rate limit 수치** (분당 60 등) 운영 합의 +5. 1차 인증 안 B 유지 기간 — OAuth 이전이라도 Claude/Cursor/Codex는 Bearer로 충분 + +## 7. 안티패턴 (배포·클라이언트) + +- product-api에 Admin MCP 툴을 붙여 권한 경계 붕괴 +- sticky session / `sessionAffinity: ClientIP`로 상태 숨기기 +- Agent Key 원문을 k8s Secret·환경변수 서브모듈 plain·CI 로그에 저장 +- 클라이언트 설정 예시에 실키 하드코딩 후 레포 커밋 +- 게이트웨이에서 `/` 전체 개방 (스캐닝 노출) — 반드시 path allowlist +- MCP 경로에 `Access-Control-Allow-Origin: *` +- rate limit을 파드 로컬 메모리로만 구현 +- 레거시 SSE URL을 클라이언트 문서에 병기 +- 1차 PR에 OAuth+전체 툴+prod HA를 한 번에 넣기 + +## 8. 미확인 / 후속 확인 + +- [ ] Envoy Gateway가 Streamable HTTP 응답 스트리밍을 버퍼링하는지, idle/request timeout 기본값 +- [ ] 호스트명 표기: `bottlenote.com` vs `bottle-note.com` 최종 DNS +- [ ] 신규 MCP 이미지용 GH workflow 슬롯 (development/release 워크플로 확장 vs 독립) +- [ ] admin-api 내부 ClusterIP 호출 시 인증: 교환 API 경로·네트워크 정책 +- [ ] Actuator 공개 범위 재검토 (MCP·admin·product 공통 보안 하드닝) +- [ ] Claude Desktop `mcp-remote`와 서버 401 challenge 호환성 실기기 테스트 + +## Source / 근거 + +### 레포 실측 + +- `git.environment-variables/deploy/base/{product-api,admin-api}.yaml` +- `git.environment-variables/deploy/overlays/{development,production}/{http-route,kustomization,*-patch,product-api-pdb,secrets-generator,redis-replication}.yaml` +- `git.environment-variables/deploy/argocd/bottlenote/applications/*.yaml` +- `git.environment-variables/storage/db/migration/V7__add_agent_key_auth.sql` +- `git.environment-variables/agent/api-keys.sops.yaml` (구조만, 값 미열람) +- `.github/workflows/deploy_{development,release}_applications.yml` +- `bottlenote-product-api/.../SecurityConfig.java` (XFF·CORS) +- `plan/mcp-research-spec-trends.md`, `plan/mcp-research-tool-catalog.md`, `plan/agent-key-token-exchange.md` +- AGENTS.md / CLAUDE.md 다중 인스턴스·XFF·배포 규칙 + +### 외부 (클라이언트 설정 패턴) + +- MCP Streamable HTTP 원격 + Bearer: Cursor `url`/`headers`, Claude Code `claude mcp add --transport http`, Codex `url` + `bearer_token_env_var` +- Claude Desktop: `mcp-remote` stdio 브릿지 관례 +- 스펙·Spring AI Streamable HTTP: 스펙 트렌드 문서 Source links 참조 + +--- + +**산출물 성격**: #370 define/plan 입력. 이 문서만으로 구현·배포 승인으로 간주하지 않는다. diff --git a/plan/mcp-research-security-checklist.md b/plan/mcp-research-security-checklist.md new file mode 100644 index 000000000..d77c1a5ef --- /dev/null +++ b/plan/mcp-research-security-checklist.md @@ -0,0 +1,323 @@ +# Remote Admin MCP 보안 체크리스트 — #370 설계 입력 + +- 조사일: 2026-08-08 / 이슈: `bottle-note/workspace#370` +- 관련: #340 Agent Key 완료, #341 감사 로그 open +- 근거: MCP Security Best Practices(Token Passthrough 금지·State Handle Hijacking), Authorization(OAuth 2.1 RS·RFC 8707 audience), `plan/mcp-research-spec-trends.md`, `plan/mcp-research-tool-catalog.md`, `plan/agent-key-token-exchange.md` +- 성격: 읽기 전용 설계 입력. 프로덕션 코드 변경 없음. 확정 요구사항 아님. +- 대상: 원격 Admin MCP (`https://mcp.bottlenote.com`, Streamable HTTP) + +## Summary + +| 영역 | 한 줄 결론 | 구현 위치(권장) | +|---|---|---| +| Agent Key 원문 | **로그·메트릭·트레이스·예외·응답에 절대 기록 금지**. DB는 SHA-256 해시만 (#340) | 필터/스크러버 + 감사 직렬화 | +| JWT 재사용·통과 | 클라이언트 Admin JWT **수신·전달 금지**. MCP 전용 자격만 수락, Admin JWT는 서버 내부 교환 | 인증 필터 + Admin API 클라이언트 | +| 툴 인가 | 인증(identity) ≠ 툴 권한. **툴별 scope** + 서버 가드(`confirm`, 건수 상한, 삭제 미등록) | 툴 디스패처 | +| 프롬프트 인젝션(쓰기) | description/annotation은 보안 경계가 **아님**. 쓰기 툴은 **서버 로직 + confirm + 감사**로 강제 | write 툴 핸들러 | +| 호출당 감사 | 툴 호출마다 고정 필드 세트 기록 (#341 연계). 민감값 마스킹 | 감사 인터셉터 | +| Rate limit | `mcp.bottlenote.com` 게이트웨이 + 앱 이중 제한. 쓰기 툴·presign·교환 API 우선 | Ingress/Gateway + Redis | + +--- + +## 1. Agent Key 원문 비노출 (MUST) + +### 1.1 정책 + +- 형식: `bn_agent_*` (에이전트당 활성 키 1개). +- 저장: **SHA-256 해시만** DB/`agents` 테이블. 원문은 `agent/api-keys.sops.yaml`(SOPS)에만 존재 (#340). +- 전송: `Authorization: Bearer bn_agent_...` 또는 OAuth client_credentials의 secret으로만. 쿼리스트링·툴 인자·본문에 키 금지. + +### 1.2 체크리스트 (구현 시 전부 통과) + +| # | 항목 | 검증 방법 | +|---|---|---| +| K1 | 액세스/앱/감사 로그에 `bn_agent_` 원문 0건 | 통합 테스트: 요청 후 로그 캡처 후 정규식 `bn_agent_[A-Za-z0-9_-]{8,}` 매칭 0 | +| K2 | 예외 메시지·`toString()`·Jackson 직렬화에 원문 없음 | 인증 실패 응답 body에 Bearer 값 미포함 | +| K3 | OTel span attribute / baggage에 원문 없음 | 스팬 덤프에서 `authorization`, `api_key`, `bn_agent_` 부재 | +| K4 | 메트릭 라벨에 키·토큰 값 금지 (agentId, outcome만) | Prometheus 라벨 화이트리스트 | +| K5 | 교환 실패 시 "invalid credentials" 단일 메시지 (키 일부 노출 금지) | 401 body 고정 문자열 | +| K6 | 디버그 로그 레벨에서도 헤더 덤프 금지 | `HttpLogging`/`CommonsRequestLoggingFilter` 비활성 또는 스크러빙 | +| K7 | 툴 응답·에러 payload에 키/JWT 미포함 | schema 단위 테스트 | +| K8 | CI 시크릿 스캔: 커밋·SQL·테스트 픽스처에 `bn_agent_` 원문 금지 | gitleaks / custom grep | + +### 1.3 허용 기록 형태 + +| 허용 | 금지 | +|---|---| +| `agentId` (예: `0001`~`0006`) | `bn_agent_xxxx...` 전체/부분 | +| 키 지문 앞 4자 + `***` (운영 디버그 한시, 기본 off) | Authorization 헤더 전체 | +| 해시 조회 성공/실패 boolean | JWT access/refresh 원문 | +| `keyVersion` / `rotatedAt` | SOPS 복호화 평문 로그 | + +### 1.4 스크러빙 규칙 (권장 구현) + +요청 로그·감사 인자 직렬화 직전 공통 스크러버: + +1. 헤더 `Authorization` → `[REDACTED]` +2. 값 정규식 `bn_agent_[A-Za-z0-9_-]+` → `[REDACTED_AGENT_KEY]` +3. JWT형 `eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+` → `[REDACTED_JWT]` +4. 필드명 화이트리스트 외 `password`, `secret`, `token`, `apiKey`, `api_key`, `refreshToken` → 마스킹 + +--- + +## 2. JWT 재사용·토큰 통과 금지 (MUST) + +스펙: MCP 서버는 **자기에게 발급된 토큰만** 수락. 다운스트림으로 클라이언트 토큰을 통과(passthrough)하면 안 된다. + +### 2.1 신뢰 경계 + +``` +[MCP Client] + | Bearer: MCP 전용 자격 (Agent Key 또는 audience=https://mcp.bottlenote.com JWT) + v +[MCP Server @ mcp.bottlenote.com] --(내부)--> Agent Key로 Admin JWT 교환 (#340) + | Bearer: Admin JWT (서버 메모리/짧은 TTL 캐시, 클라이언트 미노출) + v +[Admin API] +``` + +### 2.2 체크리스트 + +| # | 항목 | 동작 | +|---|---|---| +| J1 | 클라이언트가 보낸 `admin` audience JWT 거부 | 401. audience MUST = MCP 리소스 (`https://mcp.bottlenote.com` 또는 합의 canonical URI) | +| J2 | 클라이언트가 보낸 Product/Admin 사람 JWT 거부 | 동일 401. "토큰 통과 프록시" 툴 미등록 (카탈로그 NEVER) | +| J3 | Admin API 호출용 JWT는 **MCP 서버만** #340 교환으로 획득 | 교환 엔드포인트는 MCP 내부 전용 네트워크 또는 서비스 계정 | +| J4 | 교환으로 얻은 Admin JWT를 클라이언트 응답/툴 결과에 넣지 않음 | 응답 스키마에 token 필드 없음 | +| J5 | 교환 JWT 캐시 시 Redis 키 = `agentId` 바인딩, TTL ≤ access token 잔여, 평문 로그 금지 | 다중 파드 전제(로컬 static 금지) | +| J6 | 탈취 대비: access token 짧은 TTL + 키 로테이션 절차 | 키 유출 시 해시 교체 + 기존 캐시 무효 | +| J7 | (안 A 채택 시) RFC 8707 `resource` / aud 검증 필수 | `aud` 불일치 즉시 401 | +| J8 | 토큰을 URI 쿼리에 싣지 않음 | 로깅 프록시 유출 방지 | + +### 2.3 안 A vs 안 B (인증 표면) + +| | 안 A OAuth RS + client_credentials | 안 B 정적 Bearer Agent Key | +|---|---|---| +| 클라이언트 제시 | MCP audience JWT | `bn_agent_*` | +| 스펙 정합 | 높음 (PRM·WWW-Authenticate) | 내부 전용 실용 | +| 공통 필수 | **Admin JWT 통과 금지**, 키 원문 비로그, 툴 인가·감사 | 동일 | + +--- + +## 3. 툴 단위 인가 (Tool Auth) (MUST) + +### 3.1 원칙 + +- **연결 인증 성공 ≠ 모든 툴 허용.** +- annotation(`readOnlyHint` 등)은 UX 힌트일 뿐 **인가 결정에 사용 금지**. +- 스코프는 서버 카탈로그 기준으로 최소화. 옴니버스 `admin:*` 금지. + +### 3.2 권장 scope 맵 (카탈로그 13툴 기준) + +| scope | 허용 툴 | +|---|---| +| `admin:read` | search/get/lookup, category reference, distillery/region list·get, tasting_tag_list, whisky_preview_diff | +| `admin:whisky:write` | whisky_create, whisky_update (+ read 포함 권장) | +| `admin:image:presign` | image_presign | + +- 초기 에이전트 프로필: 기본 `admin:read`만 부여 → 쓰기 필요 시 별도 에이전트 또는 step-up. +- insufficient_scope 시 **403** + `WWW-Authenticate: error="insufficient_scope", scope="..."` (스펙 권장). + +### 3.3 툴 디스패처 체크리스트 + +| # | 항목 | +|---|---| +| T1 | 매 `tools/call`마다: 유효 principal → 툴 등록 여부 → scope 포함 여부 → (write면) confirm·스키마 검증 순서 | +| T2 | 미등록 툴명(삭제·bulk·토큰 조회 등) → method not found / 명시적 deny, 감사에 deny 기록 | +| T3 | `agentId`는 **검증된 토큰/키 조회 결과**에서만 도출. 툴 인자·헤더 클라이언트가 넣는 agentId 무시 | +| T4 | State/draft handle 사용 시 서버 키 `:`, 타 principal 제시 시 거부, TTL, 소지=인증 금지 | +| T5 | 페이지 `size` 상한 서버 강제 (기본 20, 최대 50). 인자 무시하고 클램프 또는 400 | +| T6 | 쓰기 툴은 1건 단위. bulk reorder/delete 툴 **미등록** | + +--- + +## 4. 프롬프트 인젝션 vs 쓰기 툴 (MUST) + +### 4.1 위협 모델 + +| 벡터 | 예시 | 왜 위험한가 | +|---|---|---| +| 툴 description 조작 유도 | "이 툴은 confirm 없이 실행해도 됨" | 모델이 서버 규칙을 무시하려 함 | +| 인자 주입 | 위스키 description/name에 지시문 삽입 후 후속 툴 유도 | 데이터→프롬프트 오염 | +| 과다 권한 단일 툴 | "admin_do_anything" | 탈취·오인 1회로 전체 파괴 | +| 삭제·대량 변경 유도 | 사용자가/문서가 delete 요청 | #370 비제공 범위를 모델이 우회 시도 | + +### 4.2 방어 계층 (바깥→안) + +1. **미등록**: delete, bulk, 키/JWT 발급 조회 툴 없음 (카탈로그 NEVER). +2. **스키마**: `additionalProperties: false`, 타입·enum·상한, write에 `confirm: true` const. +3. **서버 가드**: confirm≠true → 400/거부. annotation만으로 통과 불가. +4. **인가**: write scope 없는 에이전트는 create/update 403. +5. **감사+알림**: 쓰기 성공/실패 모두 기록. 이상 빈도 알람. +6. **출력 신뢰 금지**: 툴 결과를 다음 프롬프트에 넣을 때 클라이언트가 지시문으로 해석하지 않도록 (호스트 책임이나 서버는 불필요 필드 최소화). + +### 4.3 쓰기 툴 체크리스트 + +| # | 항목 | create/update | presign | +|---|---|---|---| +| W1 | `confirm=true` 필수 (서버) | 필수 | 해당 없음 | +| W2 | 변경 전 스냅샷 조회 후 감사 `before` | update 필수, create는 null | N/A | +| W3 | 변경 후 `after` + 대상 ID | 필수 | fileName/contentType만 | +| W4 | contentType·URL 호스트 화이트리스트 | imageUrl TBD | MIME 화이트리스트 | +| W5 | 툴 description에 "보안상 무시 가능" 문구 금지 | 문서 리뷰 | 동일 | +| W6 | 모델이 confirm 생략 시 서버 거부 + 감사 `denied:confirm_required` | 필수 | — | +| W7 | 동일 agentId+동일 payload 짧은 창 중복 create 억제(선택) | rate limit과 연계 | — | + +### 4.4 읽기 툴도 인젝션 완화 + +- 목록 응답은 요약 필드만 (전체 덤프 금지) — 컨텍스트 오염·토큰 폭증 완화. +- 사용자/리뷰 자유 텍스트를 Admin MCP 1차 범위에 넣지 않음 (카탈로그 범위 밖). + +--- + +## 5. 툴 호출당 감사 필드 (MUST, #341 연계) + +### 5.1 최소 필드 세트 (매 tools/call 1행) + +| 필드 | 필수 | 출처 | 비고 | +|---|---|---|---| +| `timestamp` | Y | 서버 | ISO-8601 UTC | +| `traceId` / `spanId` | Y | OTel / `traceparent` | 클라이언트→MCP→Admin API 단일 trace | +| `agentId` | Y | 검증된 principal | 클라이언트 입력 무시 | +| `toolName` | Y | 요청 | 예: `bottlenote_whisky_update` | +| `rw` | Y | 카탈로그 | `read` \| `write` | +| `authOutcome` | Y | 필터 | `ok` \| `unauthorized` \| `forbidden` | +| `decision` | Y | 디스패처 | `allow` \| `deny` | +| `denyReason` | N | 서버 | `confirm_required`, `insufficient_scope`, `not_registered`, `rate_limited`, `schema_invalid` … | +| `argsRedacted` | Y | 스크러버 후 인자 | 시크릿·과장 본문 마스킹/해시 | +| `targetType` / `targetIds` | Y* | 인자·결과 | whisky/distillery/region 등. 목록 조회는 생략 가능 | +| `before` / `after` | Y* | write만 | update 필수. create는 before=null | +| `resultCode` | Y | 핸들러 | 성공/도메인에러/5xx 구분 | +| `durationMs` | Y | 인터셉터 | | +| `clientIp` | Y | 신뢰 XFF (게이트웨이 재작성 전제) | | +| `protocolVersion` | N | 요청 `_meta`/헤더 | | +| `mcpMethod` | N | `tools/call` 등 | 게이트웨이 정책 승격용 | +| `requestId` | N | 서버 생성 UUID | 멱등·지원용 | + +\* write 또는 단건 get에서 필수에 가깝게 취급. + +### 5.2 기록 금지 + +- Agent Key 원문, Admin/Product JWT, refresh token +- S3 presigned URL 쿼리 시그니처 전체 (path·bucket·만료만) +- 불필요 PII 확대 수집 (1차 카탈로그에 user/review 없음) + +### 5.3 운영 + +- 보관: 최소 **90일** 권장 (CSA agentic MCP 가이드 수준). 조직 정책에 맞춤. +- 전송: 중앙 SIEM/로그 파이프. 앱 로컬 디스크만으로 끝내지 않음. +- #341: MCP 전용 테이블 신설보다 **기존 감사 모델에 주체 차원=에이전트** 추가 우선. +- 시맨틱: OTel GenAI/`gen_ai.tool.name`, `mcp.server.name=bottlenote-admin-mcp`. + +### 5.4 감사 체크리스트 + +| # | 항목 | +|---|---| +| A1 | 성공·실패·deny 모두 1레코드 (실패 시 무로그 금지) | +| A2 | write 누락 `before`/`after` 시 배포 게이트 실패 (테스트) | +| A3 | 스크러버 유닛 테스트: 키·JWT 샘플 입력 → 출력에 원문 0 | +| A4 | traceId 없으면 서버가 생성해 응답/로그에 상관 ID 유지 | +| A5 | 감사 기록 실패 시 write는 **실패 처리**(감사 없는 변경 금지) 또는 동기 outbox — 정책 확정 필요 | + +--- + +## 6. Rate limit — `mcp.bottlenote.com` (MUST) + +다중 인스턴스 전제 → **Redis(또는 동등 공유 저장소)** 토큰 버킷. JVM 로컬 카운터 금지. + +### 6.1 계층 + +| 계층 | 대상 | 목적 | +|---|---|---| +| L1 게이트웨이/Ingress | IP, TLS 종료 호스트 | 볼류메트릭 DDoS·비인증 폭주 | +| L2 앱 (인증 후) | `agentId` + 툴 클래스 | 에이전트 오남용·인젝션 루프 | +| L3 다운스트림 | Admin API·S3 presign 기존 한도 | 폭주 전파 차단 | + +### 6.2 권장 초기 한도 (조정 가능 수치, 설계 출발점) + +| 키 | 한도 | 비고 | +|---|---|---| +| 비인증 IP → `/mcp` | 30 req/min | 401 폭풍 완화 | +| agentId 전체 | 120 req/min | 읽기 위주 에이전트 | +| agentId + read 툴 | 100 req/min | | +| agentId + write 툴 | **10 req/min**, burst 3 | create/update | +| agentId + `image_presign` | **20 req/min** | 남용 업로드 URL 발급 | +| agentId + 토큰 교환 | **5 req/min** | 키 스터핑 완화 | +| 전역 write (클러스터) | 60 req/min | 사고 시 상한 | + +초과 시: **HTTP 429** + `Retry-After`. 감사 `denyReason=rate_limited`. 본문에 키/토큰 미포함. + +### 6.3 체크리스트 + +| # | 항목 | +|---|---| +| R1 | 한도 키에 원문 키/JWT 사용 금지 → `agentId` 또는 IP 해시 | +| R2 | sticky session 없이 동작 (stateless MCP + Redis) | +| R3 | write·presign·교환이 read보다 엄격 | +| R4 | 429도 감사·메트릭 기록 (`mcp_rate_limited_total{tool,agentId}`) | +| R5 | 운영 런북: 특정 agentId 즉시 차단(킬 스위치) Redis flag | +| R6 | Streamable HTTP 장기 스트림: 게이트웨이가 바디 버퍼로 타임아웃 내지 않는지 인프라 확인 (스펙 트렌드 미확인 항목) | + +--- + +## 7. 배포·네트워크 가드 (SHOULD) + +| # | 항목 | +|---|---| +| N1 | 외부 노출은 `https://mcp.bottlenote.com` only. Admin API는 클러스터 내부 | +| N2 | TLS 필수. HSTS 게이트웨이 | +| N3 | CORS: 브라우저 일반 사용 없다면 최소/비허용. 자격 쿠키 사용 안 함 (`Bearer` only) | +| N4 | 헬스/ready는 인증 없이 가능하되 내부 정보·키 미노출 | +| N5 | 의존 Admin API·교환 API 타임아웃·재시도 상한 (재시도 폭풍=쓰기 중복 주의, idempotency 키 검토) | + +--- + +## 8. 구현 전 Go / No-Go 게이트 + +배포 전 아래가 모두 문서·테스트로 증명되어야 한다. + +1. [ ] Agent Key 원문이 로그/트레이스/응답/커밋에 0건 +2. [ ] 클라이언트 Admin JWT 제시 → 401, Admin API로 전달 0건 +3. [ ] 삭제·bulk·키 조회 툴 미등록 + 호출 시도 deny 감사 +4. [ ] write 툴 confirm 없이 호출 → 거부 + 감사 +5. [ ] 툴 호출 1회 = 감사 1행 (5.1 필드) +6. [ ] write 시 before/after 존재 +7. [ ] agentId·write 클래스 rate limit + 429 +8. [ ] scope 없는 write → 403 insufficient_scope +9. [ ] handle 사용 시 agent 바인딩 검증 (해당 시) +10. [ ] 다중 파드에서 한도·JWT 캐시 공유 저장소 사용 + +--- + +## 9. Anti-patterns (즉시 거부) + +- 클라이언트가 준 JWT를 Admin API Authorization에 그대로 설정 +- `log.debug(request.headers)` 무스크러빙 +- 툴 description/“AI 안전 수칙”만으로 삭제 방지 +- `admin:*` 단일 스코프 +- 로컬 static Map rate limit (다중 파드에서 무력) +- 감사 실패를 삼키고 write 성공 처리 +- 테스트 fixture에 실키 `bn_agent_` 커밋 +- handle 소지자 = 인증된 사용자로 간주 + +--- + +## 10. 후속·미확정 + +| 항목 | 상태 | +|---|---| +| 안 A(OAuth RS) vs 안 B(정적 Bearer) 최종 선택 | 스펙 트렌드 문서 권장 A, 비용 시 B | +| 감사 기록 실패 시 write 트랜잭션 정책 | #341과 합의 필요 | +| imageUrl 허용 호스트 화이트리스트 | TBD | +| 게이트웨이 Streamable HTTP 스트리밍 버퍼링 | 인프라 확인 | +| 사람 관리자 SSO(EMA) | 에이전트 수 적을 때 비우선 | + +--- + +## Source links + +- [MCP Security Best Practices (Token Passthrough, State Handle)](https://modelcontextprotocol.io/specification/draft/basic/security_best_practices) +- [MCP Authorization (OAuth 2.1 RS, audience, no transit tokens)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [CSA Agentic MCP Security Best Practices](https://labs.cloudsecurityalliance.org/agentic/agentic-mcp-security-best-practices-v1/) +- [OWASP MCP Top 10](https://owasp.org/www-project-mcp-top-10/) +- 내부: `plan/mcp-research-spec-trends.md`, `plan/mcp-research-tool-catalog.md`, `plan/agent-key-token-exchange.md` diff --git a/plan/mcp-research-spec-trends.md b/plan/mcp-research-spec-trends.md new file mode 100644 index 000000000..fb388cb06 --- /dev/null +++ b/plan/mcp-research-spec-trends.md @@ -0,0 +1,117 @@ +# MCP 스펙·트렌드 리서치 (2025-2026) — Admin MCP #370 설계 입력 + +- 조사일: 2026-08-07 / 대상 이슈: `bottle-note/workspace#370` +- 성격: 읽기 전용 리서치. 코드 변경 없음. 결론은 설계 판단의 근거이며 확정된 요구사항이 아니다. + +## Summary + +- **최신 스펙은 `2026-07-28`** (2026-07-28 릴리스, 직전은 `2025-11-25`). 출범 이후 최대 개정이며 **프로토콜이 stateless로 전환**됐다. + - `initialize`/`notifications/initialized` 핸드셰이크 제거, `Mcp-Session-Id` 헤더 제거. 매 요청이 `_meta`에 프로토콜 버전과 클라이언트 capability를 싣는다 (SEP-2567, SEP-2575). + - `server/discover` RPC 신설(서버 MUST 구현), 서버 간 sticky session 불필요 → 평범한 round-robin LB 뒤에서 다중 파드 운영 가능. + - `tools/list` 등 목록 응답에 `ttlMs`/`cacheScope` 필수(SEP-2549), `Mcp-Method`/`Mcp-Name` 요청 헤더 필수(SEP-2243) → 게이트웨이가 body 파싱 없이 라우팅·인가·레이트리밋 가능. + - Roots / Sampling / Logging **deprecated**(SEP-2577, 최소 12개월 유예). HTTP+SSE 레거시 트랜스포트도 Deprecated로 재분류(SEP-2596). + - 서버 주도 요청(sampling/elicitation/roots)은 **MRTR 패턴**으로 교체: 서버가 `resultType: "input_required"` 반환 → 클라이언트가 재시도에 답을 실어 보냄(SEP-2322). +- **그런데 Java 생태계는 아직 `2025-11-25`에 머물러 있다.** MCP Java SDK 최신 GA는 **v2.0.0 (2026-06-11)**, 명시적으로 `2025-11-25` 스펙을 트래킹한다. 2026-07-28 대응 릴리스는 조사 시점(2026-08-07) 기준 없음. 공식 Tier 1 SDK는 TS/Python/Go/C#이고 Java는 여기에 없다. +- **결론(#370 관점)**: 지금 착수하면 **와이어는 `2025-11-25`(Java SDK v2.0.0 + Spring AI 2.x)로 구현하되, 설계는 stateless 전제로** 해야 한다. 세션에 의존하는 서버 상태를 만들면 2026-07-28 마이그레이션 때 통째로 다시 짜야 한다. 이건 이 저장소의 "인스턴스는 다중이다" 제약과도 정확히 일치한다. +- 인증은 스펙상 OAuth 2.1 리소스 서버가 정도(正道)지만, #370의 Agent Key(`bn_agent_*`, SHA-256 해시 저장, 에이전트당 1개)는 사실상 client_credentials용 정적 시크릿이다. 아래 "Must-adopt" 3번에서 두 안을 비교한다. + +## Must-adopt for #370 + +### 1. Stateless 서버로 설계 (최우선) + +- MCP 세션/커넥션에 어떤 서버 상태도 붙이지 않는다. 툴 호출 간 상태가 필요하면 **서버가 발급한 명시적 handle을 툴 인자로 주고받는다**(스펙이 지정한 유일한 방식). +- Java SDK의 `McpStatelessSyncServer` / Spring AI `mcp-stateless-server-boot-starter` 계열을 기본값으로 잡는다. Spring AI에서 Streamable HTTP는 `spring.ai.mcp.server.protocol=STREAMABLE`, 엔드포인트 기본 `/mcp`. +- 트랜스포트는 **Streamable HTTP만**. 레거시 HTTP+SSE는 채택하지 않는다(주요 클라이언트가 2026 중반 sunset 공지, 스펙상 Deprecated). +- k8s 다중 파드 전제이므로 sticky session 설정을 만들지 않는다. 상태가 필요하면 Redis. + +### 2. State handle 하이재킹 방어 (변경 전후 검증 워크플로에 직결) + +- #370의 "변경 전후 데이터 조회 및 검증"은 draft/change-set handle을 낳기 쉽다. 스펙 보안 문서가 이걸 **State Handle Hijacking** 공격면으로 명시한다. +- 필수: handle은 `SecureRandom` 기반 비순차 값, **서버 측에서 `:` 형태로 인증 주체에 바인딩**, 다른 principal이 제시하면 거부, TTL 만료. **handle 소지를 인증으로 취급 금지.** + +### 3. 인증: Agent Key를 어떻게 노출할 것인가 + +스펙 요구(HTTP 트랜스포트에서 authorization을 지원한다면): MCP 서버는 OAuth 2.1 리소스 서버로 동작하고 **RFC 9728 Protected Resource Metadata를 MUST 구현**, 토큰의 **audience가 자기 자신인지 MUST 검증**, 401에 `WWW-Authenticate: Bearer resource_metadata=..., scope=...`를 실어야 한다. + +- **안 A (권장) — OAuth2 리소스 서버 + client_credentials**: Agent Key를 `client_id/client_secret`으로 매핑해 자체 AS(또는 admin-api)가 audience `https://mcp.bottlenote.com`인 JWT를 발급. `spring-ai-community/mcp-security`의 `McpServerOAuth2Configurer` + `spring-boot-starter-oauth2-resource-server`로 붙는다. 범용 MCP 클라이언트 호환성과 감사 추적이 가장 좋다. +- **안 B (최소 비용) — 정적 Bearer**: `Authorization: Bearer bn_agent_...`를 그대로 받고 해시 조회로 검증. 클라이언트에 수동 헤더 설정이 필요하고, OAuth 디스커버리를 요구하는 클라이언트와는 붙지 않는다. 내부 소수 에이전트 한정이면 실용적. +- 어느 안이든 **토큰 통과(token passthrough) 금지**: 클라이언트가 보낸 Admin JWT를 그대로 받아 Admin API로 넘기면 안 된다. MCP 서버는 자기 앞으로 발급된 자격만 받고, Admin JWT는 **서버가 Agent Key로 직접 교환해 내부에서만** 쓴다. #370 설계가 이미 이 형태이므로 유지하면 된다. +- 스코프 최소화: `scopes_supported`에 전체 카탈로그를 싣지 말고 읽기 기본(`admin:read`) → 쓰기(`admin:whisky:write`)를 `insufficient_scope` 403 챌린지로 승격시킨다. +- 참고 수치: 2026-05 기준 원격 MCP 서버 중 OAuth 2.1을 실제 구현한 비율은 **8.5%**. 즉 여기서 제대로 하면 상위 10%다. + +### 4. 툴 설계 — 파괴적 동작은 서버가 막는다 + +- **명명**: `bottlenote_whisky_search`처럼 `{서비스}_{도메인}_{동작}` 스네이크. 클라이언트가 여러 MCP를 동시에 물기 때문에 접두사가 충돌 방지가 된다. +- **파라미터 8개 이하**, 멀티 목적 툴은 쪼갠다. `inputSchema`는 JSON Schema 2020-12. 날짜/enum은 포맷을 명시하지 않으면 모델이 추측한다 — ISO-8601, enum 값 목록을 스키마에 박는다. +- **annotation**: 조회 툴에 `readOnlyHint: true`, 수정 툴에 `destructiveHint`/`idempotentHint`를 정확히 단다. 단 **annotation은 클라이언트 UX 힌트일 뿐 보안 경계가 아니다**(스펙: 서버 어노테이션은 신뢰 대상 아님). +- **#370의 "비제공" 항목은 툴 설명이 아니라 서버 로직으로 강제한다**: 자동 삭제 툴은 아예 등록하지 않음, 대량 수정은 건수 상한 + 승인 handle 없으면 거부. 프롬프트로 막는 것은 방어가 아니다. +- **페이지네이션**: 조회 툴은 반드시 커서 기반 + 상한(예: 기본 20, 최대 50). 프로젝트의 `PageResponse`/`CursorPageable`을 그대로 매핑한다. 응답에 위스키 전체 필드를 덤프하지 말고 목록/상세를 분리한다. +- **툴 개수 예산**: 툴 정의 1개가 100~500 토큰. 58개 툴 세팅이 55K 토큰을 먹은 측정치가 있다. #370 범위는 조회 7~9 + 변경 4~6 정도로 **15개 내외에 묶는다**. 넘어가면 클라이언트가 tool search로 lazy-load하기 시작해 발견율이 떨어진다. +- **`tools/list`는 결정적 순서로 반환**(2026-07-28 SHOULD). LLM 프롬프트 캐시 적중률에 직접 영향. + +### 5. 감사 로그·관측 (이슈 완료 기준에 포함된 항목) + +- MCP의 `logging` 기능은 deprecated다. **OpenTelemetry로 간다.** 2026-07-28은 `_meta`의 `traceparent`/`tracestate`/`baggage` 전파 규약을 문서화했다(SEP-414) — 클라이언트 trace가 MCP 서버를 지나 Admin API까지 한 trace로 이어진다. +- 툴 호출당 감사 레코드에 최소: `agentId`(검증된 토큰에서 도출, 클라이언트 입력 금지), 툴 이름, 인자(민감값 마스킹), 대상 리소스 ID, **변경 전/후 스냅샷**, 결과 코드, traceId, 소요시간. +- 시맨틱 컨벤션은 OTel GenAI 규약(`gen_ai.tool.name`, `mcp.server.name`)에 맞춘다. +- 로그에 Agent Key 원문·JWT가 절대 남지 않도록 스크러빙. `agent/api-keys.sops.yaml` 정책과 일관되게. +- 이 저장소는 `#341` 감사 로그 모델과 연계하도록 되어 있으므로, MCP 전용 로그를 새로 만들지 말고 기존 감사 모델에 "요청 주체 = 에이전트" 차원을 추가하는 방향이 맞다. + +### 6. Java/Spring 스택 선택 + +| 옵션 | 상태 (2026-08) | 판단 | +|---|---|---| +| **MCP Java SDK v2.0.0** (`io.modelcontextprotocol.sdk:mcp`) | GA 2026-06-11, `2025-11-25` 트래킹. STDIO/SSE/Streamable HTTP 내장, 웹 프레임워크 불필요 | 저수준 제어가 필요할 때. Spring AI가 이걸 감쌈 | +| **Spring AI MCP Server Boot Starter** (`spring-ai-starter-mcp-server-webmvc`) | Spring AI 2.x. `@McpTool`/`@McpToolParam` 어노테이션 API가 코어에 편입 | **권장.** 이 저장소가 Spring Boot 3.4.11/Java 21이므로 Spring AI 버전이 요구하는 Boot 하한만 확인하면 됨 | +| **spring-ai-community/mcp-security** | `McpServerOAuth2Configurer` + 자동설정. PRM/DCR/SSRF 가드 포함 | 안 A 채택 시 필수 | +| Quarkus MCP (quarkus-mcp-server) | 성숙하지만 런타임이 다름 | **부적합** — 이 저장소는 Spring 단일 스택 | + +- **버전 리스크를 plan 문서에 Assumption으로 못 박을 것**: "Java SDK는 `2026-07-28`을 아직 지원하지 않으며, 지원 릴리스가 나오면 트랜스포트 계층만 교체한다." 이 가정이 깨지면(예: 클라이언트가 2026-07-28만 요구) 재개봉 대상이다. + +## Nice-to-have + +- **`ttlMs`/`cacheScope`**: 2026-07-28 필수 필드. 지금 SDK가 안 내보내도, 툴 카탈로그를 "정적이며 캐시 가능"하게 설계해두면 나중에 값만 채우면 된다. Admin 툴 목록은 배포 단위로만 바뀌므로 긴 TTL이 가능하다. +- **`Mcp-Method`/`Mcp-Name` 헤더 기반 게이트웨이 정책**: 앞단에서 body 파싱 없이 "쓰기 툴은 특정 에이전트만" 같은 정책을 걸 수 있다. 지금은 애플리케이션 레이어에서 같은 판정을 하되, 판정 로직을 툴 이름 기준으로 짜두면 나중에 게이트웨이로 승격 가능. +- **EMA (Enterprise-Managed Authorization) 확장**: 2026-06-18 stable, Anthropic/Microsoft/Okta 채택. 조직 IdP SSO로 MCP 접근을 통제한다. Bottle Note는 에이전트 수가 적어 지금은 과하지만, 사람 관리자가 MCP를 직접 쓰게 되면 재검토 대상. +- **MCP Registry 등재**: 공식 레지스트리에 2026-05 기준 9,652개 서버 등록. Admin MCP는 비공개라 등재 대상이 아니지만, 사내 카탈로그를 같은 스키마로 두면 나중에 확장이 쉽다. +- **Tasks 확장** (`io.modelcontextprotocol/tasks`): 이미지 업로드·대량 검증처럼 오래 걸리는 작업을 폴링형 task handle로 넘기는 공식 확장. 지금은 동기 처리로 충분하지만 이미지 업로드가 커지면 후보. + +## Avoid / anti-patterns + +- **레거시 HTTP+SSE 트랜스포트 채택** — Deprecated. 새로 만들면서 이걸 고를 이유가 없다. +- **세션 기반 상태**(`Mcp-Session-Id`, 커넥션 스코프 캐시, static 필드) — 스펙에서 제거됐고, 이 저장소의 다중 인스턴스 제약과도 정면 충돌. +- **토큰 통과** — 클라이언트가 준 토큰을 검증 없이 Admin API로 전달. 스펙이 명시적으로 금지. audience 검증 없이 토큰을 받는 것도 같은 범주. +- **파괴적 동작을 툴 description/annotation으로만 막기** — 서버 어노테이션은 신뢰 대상이 아니다. "승인 없는 대량 수정 금지"는 서버 코드의 건수 상한과 승인 handle로 강제한다. +- **옴니버스 스코프**(`admin:*`, `full-access`) — 탈취 시 폭발 반경이 전체가 되고 감사 로그에서 의도를 구분할 수 없다. +- **전체 카탈로그 툴 폭증** — Admin API 엔드포인트를 1:1로 툴에 매핑하는 것. 토큰 예산이 먼저 터지고 모델의 툴 선택 정확도가 떨어진다. 업무 단위로 묶는다. +- **인자 없는 무제한 조회 툴** — 페이징/상한 없는 `list_all_whiskies`류. 컨텍스트를 날린다. +- **Roots / Sampling / Logging 신규 채택** — 전부 deprecated. MCP 서버가 LLM 호출이 필요하면 provider API를 직접 쓴다. +- **외부 조사 기능을 슬쩍 넣기** — #370이 명시적으로 배제한 범위(웹 검색, Whiskybase 탐색, 출처 신뢰도 판단, 테이스팅 태그 자동 생성). 별도 리서치 MCP의 몫이다. + +## 미확인 / 후속 확인 필요 + +- Java SDK / Spring AI의 `2026-07-28` 지원 릴리스 일정 — 조사 시점에 공개 로드맵 없음. 착수 전 java-sdk 릴리스 노트 재확인 권장. +- Spring AI 2.x가 요구하는 Spring Boot 최소 버전과 현재 3.4.11의 호환성 — 실제 의존성 해석으로 검증 필요. +- `mcp.bottlenote.com` 앞단 게이트웨이가 Streamable HTTP의 장기 응답 스트림(POST 응답 스트리밍)을 버퍼링 없이 통과시키는지 — 인프라 확인 항목. + +## Source links + +- [MCP Specification (latest, 2026-07-28)](https://modelcontextprotocol.io/specification/latest) +- [Key Changes — 2026-07-28 changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog) +- [The 2026-07-28 Specification (blog)](https://blog.modelcontextprotocol.io/posts/2026-07-28/) +- [Authorization — 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization) +- [Security Best Practices — 2026-07-28](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices) +- [Enterprise-Managed Authorization: Zero-touch OAuth for MCP](https://blog.modelcontextprotocol.io/posts/enterprise-managed-auth/) +- [The 2026 MCP Roadmap](https://blog.modelcontextprotocol.io/posts/2026-mcp-roadmap/) +- [MCP Java SDK — releases (v2.0.0, 2026-06-11)](https://github.com/modelcontextprotocol/java-sdk/releases) +- [MCP Java SDK — Server docs](https://java.sdk.modelcontextprotocol.io/latest/server/) +- [Spring AI — MCP overview](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html) +- [Spring AI — Streamable-HTTP MCP Servers](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-streamable-http-server-boot-starter-docs.html) +- [Spring AI — MCP Security](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-security.html) +- [spring-ai-community/mcp-security](https://github.com/spring-ai-community/mcp-security) +- [AWS — MCP tool design: practical approaches and tradeoffs](https://aws.amazon.com/blogs/machine-learning/mcp-tool-design-practical-approaches-and-tradeoffs/) +- [MCP Tool Schema Bloat: The Hidden Token Tax](https://layered.dev/mcp-tool-schema-bloat-the-hidden-token-tax-and-how-to-fix-it/) +- [Progressive Tool Discovery for Token Efficiency (discussion #1923)](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/1923) +- [MCP Observability: From Tool Call to Full-Stack Trace](https://www.groundcover.com/blog/mcp-spec-update-2026-07-28) +- [MCP Observability — monitoring AI agent tool access (Obot)](https://obot.ai/blog/mcp-observability-how-to-monitor-ai-agent-activity-in-the-enterprise/) +- [MCP Ecosystem H1 2026 Retrospective: Adoption Data Points](https://www.digitalapplied.com/blog/mcp-ecosystem-h1-2026-retrospective-adoption-data-points) diff --git a/plan/mcp-research-tool-catalog.md b/plan/mcp-research-tool-catalog.md new file mode 100644 index 000000000..a4e4c59ec --- /dev/null +++ b/plan/mcp-research-tool-catalog.md @@ -0,0 +1,139 @@ +# MCP Tool Catalog 리서치 — Admin MCP #370 + +- 조사일: 2026-08-08 / 이슈: `bottle-note/workspace#370` +- 관련: #340 Agent Key 완료, #341 감사 로그 open +- 근거: Admin API 컨트롤러·DTO 실측 + `plan/mcp-research-spec-trends.md` +- 성격: 읽기 전용 설계 입력. 프로덕션 코드 변경 없음. 확정 요구사항 아님. + +## Summary + +- 예산: **등록 툴 13개** (조회 8 + 변경 5). 스펙 트렌드 권장 15 이내. +- 명명: `bottlenote_{domain}_{action}` 스네이크. 접두사로 다중 MCP 충돌 방지. +- 인증: Agent Key(`bn_agent_*`) → 서버 내부 Admin JWT 교환(#340). 클라이언트 토큰 통과 금지. +- 파괴 정책: **삭제 툴 미등록**. 생성/수정은 서버 가드 + 클라이언트 `destructiveHint`/확인 UX. +- 페이지: 조회 기본 20·최대 50. 무제한 list 금지. `inputSchema`는 JSON Schema 2020-12 축약. +- 감사: 툴 호출마다 agentId·툴명·대상 ID·변경 전/후·traceId (#341 연계 예정). + +## 공통 규칙 + +| 항목 | 규칙 | +|---|---| +| R/W | `read` = GET 계열, `write` = POST/PUT/PATCH | +| confirm | 파괴·비가역·다건 변경 시 `confirm=true` 필수(서버 거부). annotation만으로 막지 않음 | +| hints | read: `readOnlyHint`; write: `destructiveHint`/`idempotentHint` (UX 힌트, 보안 경계 아님) | +| 파라미터 | 툴당 8개 이하. enum·ISO 날짜 스키마 명시 | +| handle | draft/승인 handle 사용 시 `:` 바인딩·TTL. 소지=인증 금지 | + +## 등록 카탈로그 (13) + +### A. 조회 (read, confirm 불필요) + +| # | tool | inputSchema (필수·주요) | Admin API | 비고 | +|---|---|---|---|---| +| 1 | `bottlenote_whisky_search` | `keyword?`, `category?` enum, `regionId?`, `sortType?`, `sortOrder?`, `page?`≥0, `size?`1–50, `includeDeleted?` | `GET /alcohols` | 목록 요약만 | +| 2 | `bottlenote_whisky_get` | `alcoholId` integer **req** | `GET /alcohols/{alcoholId}` | 상세 전 필드 | +| 3 | `bottlenote_whisky_lookup` | `keyword?`, `category?`, `regionId?`, `distilleryId?`, `cursor?`≥0, `pageSize?`1–50 | `GET /alcohols/lookup` | 커서 검색 | +| 4 | `bottlenote_category_reference_get` | _(없음)_ | `GET /alcohols/categories/reference` | 생성 전 참조 | +| 5 | `bottlenote_distillery_list` | `keyword?`, `page?`, `size?`1–50 | `GET /distilleries` | 목록 | +| 6 | `bottlenote_distillery_get` | `distilleryId` **req** | `GET /distilleries/{id}` | 상세 | +| 7 | `bottlenote_region_list` | `keyword?`, `page?`, `size?`1–50 | `GET /regions` | 계층 포함 시 요약 | +| 8 | `bottlenote_region_get` | `regionId` **req** | `GET /regions/{id}` | 상세 | + +### B. 변경 (write) + +| # | tool | inputSchema | R/W · confirm | Admin API | 비고 | +|---|---|---|---|---|---| +| 9 | `bottlenote_whisky_create` | `korName`, `engName`, `abv`, `type`, `korCategory`, `engCategory`, `categoryGroup`, `regionId`, `distilleryId`, `age`, `cask`, `imageUrl`, `description`, `volume`, `tastingTagIds?` + `confirm` **req true** | write · **confirm** | `POST /alcohols` | Upsert DTO 1:1. 파라미터 8초과 → 구현 시 nested `payload` object 1개로 축소 권장 | +| 10 | `bottlenote_whisky_update` | `alcoholId` **req** + create와 동일 본문 + `confirm` **req true** | write · **confirm** | `PUT /alcohols/{id}` | 호출 전 서버가 현재 스냅샷 조회해 감사 로그에 before 기록 | +| 11 | `bottlenote_image_presign` | `fileName` **req**, `contentType` **req** enum 이미지 MIME | write · 불필요 | `GET /s3/presign-url` | URL만 발급. 바이너리 업로드는 MCP 밖(클라이언트→S3) | +| 12 | `bottlenote_tasting_tag_list` | `keyword?`, `page?`, `size?`1–50 | read · 불필요 | `GET /tasting-tags` | 위스키 태깅 참조 | +| 13 | `bottlenote_whisky_preview_diff` | `alcoholId` **req**, `payload` object(변경 후보 필드) | read · 불필요 | **TBD** (로컬 get+diff 또는 전용 preview API) | 변경 전후 검증 워크플로. 서버 state handle 불필요(무상태 diff) | + +> 9·10 스키마 축소안: `payload: AdminAlcoholUpsert` 단일 object + `alcoholId?` + `confirm`. 토큰·파라미터 예산 준수. + +### C. 입력 스키마 예시 (축약) + +```json +// bottlenote_whisky_search +{ "type":"object", "properties": { + "keyword":{"type":"string"}, "category":{"type":"string"}, + "regionId":{"type":"integer"}, "page":{"type":"integer","minimum":0}, + "size":{"type":"integer","minimum":1,"maximum":50} +}, "additionalProperties": false } + +// bottlenote_whisky_update +{ "type":"object", "required":["alcoholId","payload","confirm"], "properties": { + "alcoholId":{"type":"integer"}, + "payload":{"type":"object", "required":["korName","engName","abv","type","korCategory", + "engCategory","categoryGroup","regionId","distilleryId","age","cask","imageUrl", + "description","volume"], + "properties":{ "korName":{"type":"string"}, "engName":{"type":"string"}, + "abv":{"type":"string"}, "type":{"type":"string"}, "regionId":{"type":"integer"}, + "distilleryId":{"type":"integer"}, "tastingTagIds":{"type":"array","items":{"type":"integer"}} }}, + "confirm":{"type":"boolean", "const": true} +}, "additionalProperties": false } +``` + +## NEVER 목록 (서버 미등록 + 로직 거부) + +| 금지 | 이유 | 대응 Admin API (존재해도 MCP 비노출) | +|---|---|---| +| `*_delete` / 소프트삭제 일괄 | #370 자동 삭제 비제공 | `DELETE /alcohols/{id}`, distillery/region/tag delete | +| 무페이징 `list_all_*` | 컨텍스트 폭증 | 없음 — 검색·커서만 | +| bulk reorder / 대량 수정 단일 툴 | 폭발 반경 | `PATCH .../bulk/reorder` (region·distillery) | +| 웹검색·Whiskybase·출처 판정 | #370 범위 외 | 없음 (리서치 MCP) | +| 테이스팅 태그 자동 생성·추천 | #370 비제공 | tag create는 사람 Admin UI 전제 | +| Agent Key/JWT 발급·조회 툴 | 시크릿 노출 | #340 교환 API는 MCP 서버 내부 전용 | +| 토큰 통과 프록시 툴 | 스펙 금지 | — | +| curation/banner/user/review 전면 | 1차 #370 범위 밖 | 별도 카탈로그 확장 시 | + +## confirm 정책 (파괴·쓰기) + +| 동작 | confirm | 서버 가드 | +|---|---|---| +| create whisky | `confirm=true` 필수 | 필수 필드 검증, 이미지 URL 허용 호스트 검사(TBD) | +| update whisky | `confirm=true` 필수 | before 스냅샷 + after 감사; 1건만 | +| image presign | 불필요 | contentType 화이트리스트, rate limit | +| preview_diff | 불필요 | 읽기 전용, 부작용 없음 | +| 삭제·bulk | N/A | **툴 없음** — 호출 시도 시 method not found | + +## Admin API 매핑 요약 + +``` +READ GET /alcohols → bottlenote_whisky_search + GET /alcohols/{id} → bottlenote_whisky_get + GET /alcohols/lookup → bottlenote_whisky_lookup + GET /alcohols/categories/reference → bottlenote_category_reference_get + GET /distilleries[/{id}] → bottlenote_distillery_list|get + GET /regions[/{id}] → bottlenote_region_list|get + GET /tasting-tags → bottlenote_tasting_tag_list + GET /s3/presign-url → bottlenote_image_presign +WRITE POST /alcohols → bottlenote_whisky_create (+confirm) + PUT /alcohols/{id} → bottlenote_whisky_update (+confirm) +TBD (로컬 diff) → bottlenote_whisky_preview_diff +NEVER DELETE /alcohols|distilleries|regions|tasting-tags/{id} + PATCH /**/bulk/reorder +``` + +## 구현 메모 (합성 입력) + +1. create/update는 `payload` 객체로 파라미터 예산 준수. +2. preview_diff는 신규 Admin API 없이 `get` + 서버측 필드 diff로 충분 → **TBD API 최소화**. +3. distillery/region **쓰기·삭제·reorder** 1차 제외(참조 조회만). 필요 시 2차 카탈로그. +4. tasting tag: list만. alcohol↔tag 연결은 whisky create/update의 `tastingTagIds`로 흡수 (`POST/DELETE /tasting-tags/{id}/alcohols` 비노출). +5. `tools/list` 결정적 순서: 위 표 #1→#13. +6. 스코프 제안: `admin:read` 기본 / `admin:whisky:write` 로 create·update 승격. + +## 미확인 + +- Admin API 글로벌 prefix(`/api/v1` 등) — 배포 설정 확인 후 문서 경로 보정. +- presign query 파라미터 정식 이름·MIME 화이트리스트. +- #341 감사 스키마에 toolName·before/after 컬럼 존재 여부. +- nested `payload` vs flat 필드 중 클라이언트( Claude/Cursor ) 스키마 호환 실측. + +## 결론 (Key findings) + +- **13툴 / 삭제 0 / bulk 0** 으로 #370 위스키 조회·단건 생성·수정·이미지 준비·변경 diff에 충분. +- Admin 매핑은 alcohols·distillery·region·tasting-tags·s3 실경로 기준. preview_diff만 TBD. +- confirm은 스키마 `const:true` + 서버 검증 이중. NEVER는 미등록이 본방어. +- #340 자격 교환·#341 감사 차원을 전제로 하면 토큰 통과·고아 감사 로그를 피할 수 있다. From a9d1cf18225334647dac832075143d3efd25ac82 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 8 Aug 2026 01:19:08 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20Admin=20MCP=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=9C=84=EC=8A=A4=ED=82=A4=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/bottlenote/mcp/dto/McpWhiskyDtos.kt | 56 ++++++++ .../presentation/AdminMcpWhiskyController.kt | 47 +++++++ .../mcp/service/AdminMcpWhiskyService.kt | 130 ++++++++++++++++++ plan/mcp-architecture-isolation.md | 66 +++++++++ 4 files changed, 299 insertions(+) create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt create mode 100644 plan/mcp-architecture-isolation.md diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt new file mode 100644 index 000000000..0f6549a57 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt @@ -0,0 +1,56 @@ +package app.bottlenote.mcp.dto + +/** + * MCP-optimized whisky payloads: compact fields for agent context. + * Not a 1:1 dump of AdminAlcoholDetailResponse. + */ +data class McpWhiskySummary( + val alcoholId: Long, + val korName: String?, + val engName: String?, + val korCategory: String?, + val engCategory: String?, + val imageUrl: String?, +) + +data class McpTastingTag( + val id: Long, + val korName: String?, + val engName: String?, +) + +data class McpWhiskyDetail( + val alcoholId: Long, + val korName: String?, + val engName: String?, + val korCategory: String?, + val engCategory: String?, + val imageUrl: String?, + val abv: String?, + val age: String?, + val cask: String?, + val volume: String?, + val description: String?, + val regionId: Long?, + val korRegion: String?, + val engRegion: String?, + val distilleryId: Long?, + val korDistillery: String?, + val engDistillery: String?, + val tastingTags: List, +) + +data class McpWhiskySearchResult( + val items: List, + val page: Int, + val size: Int, + val totalElements: Long?, + val hasNext: Boolean?, +) + +data class McpWhiskySearchRequest( + val keyword: String? = null, + val regionId: Long? = null, + val page: Int? = null, + val size: Int? = null, +) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt new file mode 100644 index 000000000..7c0949f11 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt @@ -0,0 +1,47 @@ +package app.bottlenote.mcp.presentation + +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.mcp.dto.McpWhiskySearchRequest +import app.bottlenote.mcp.service.AdminMcpWhiskyService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +/** + * MCP-optimized admin whisky APIs. + * + * Consumed only by bottlenote-mcp gateway (not public admin UI contract). + * Full path: /admin/api/v1/mcp/whiskies + */ +@RestController +@RequestMapping("/mcp/whiskies") +class AdminMcpWhiskyController( + private val adminMcpWhiskyService: AdminMcpWhiskyService, +) { + @GetMapping + fun search( + @RequestParam(required = false) keyword: String?, + @RequestParam(required = false) regionId: Long?, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + ): ResponseEntity { + val result = + adminMcpWhiskyService.search( + McpWhiskySearchRequest( + keyword = keyword, + regionId = regionId, + page = page, + size = size, + ), + ) + return GlobalResponse.ok(result) + } + + @GetMapping("/{alcoholId}") + fun getDetail( + @PathVariable alcoholId: Long, + ): ResponseEntity = GlobalResponse.ok(adminMcpWhiskyService.getDetail(alcoholId)) +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt new file mode 100644 index 000000000..c5610c8bd --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt @@ -0,0 +1,130 @@ +package app.bottlenote.mcp.service + +import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest +import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse +import app.bottlenote.alcohols.dto.response.AdminAlcoholItem +import app.bottlenote.alcohols.service.AlcoholQueryService +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.mcp.dto.McpTastingTag +import app.bottlenote.mcp.dto.McpWhiskyDetail +import app.bottlenote.mcp.dto.McpWhiskySearchRequest +import app.bottlenote.mcp.dto.McpWhiskySearchResult +import app.bottlenote.mcp.dto.McpWhiskySummary +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.stereotype.Service + +/** + * MCP-facing whisky read model. Reuses domain query services; shapes payloads for agents. + */ +@Service +class AdminMcpWhiskyService( + private val alcoholQueryService: AlcoholQueryService, + private val objectMapper: ObjectMapper, +) { + fun search(request: McpWhiskySearchRequest): McpWhiskySearchResult { + val page = (request.page ?: 0).coerceAtLeast(0) + val size = (request.size ?: 20).coerceIn(1, 50) + // Java record: positional args only from Kotlin + val adminRequest = + AdminAlcoholSearchRequest( + request.keyword, + null, + request.regionId, + null, + null, + page, + size, + false, + ) + // AlcoholQueryService.searchAdminAlcohols uses GlobalResponse.fromPage: + // data = List content, meta.totalElements / meta.hasNext + val global: GlobalResponse = alcoholQueryService.searchAdminAlcohols(adminRequest) + val items = extractPageItems(global).map { it.toSummary() } + val total = metaLong(global, "totalElements") + val hasNext = metaBoolean(global, "hasNext") ?: (items.size >= size) + return McpWhiskySearchResult( + items = items, + page = page, + size = size, + totalElements = total, + hasNext = hasNext, + ) + } + + fun getDetail(alcoholId: Long): McpWhiskyDetail { + val detail = alcoholQueryService.findAdminAlcoholDetailById(alcoholId) + return detail.toMcpDetail() + } + + private fun extractPageItems(global: GlobalResponse): List { + val data = global.data ?: return emptyList() + if (data is List<*>) { + return data.mapNotNull { convertItem(it) } + } + return emptyList() + } + + private fun metaLong(global: GlobalResponse, key: String): Long? { + val value = global.meta?.get(key) ?: return null + return when (value) { + is Number -> value.toLong() + is String -> value.toLongOrNull() + else -> null + } + } + + private fun metaBoolean(global: GlobalResponse, key: String): Boolean? { + val value = global.meta?.get(key) ?: return null + return when (value) { + is Boolean -> value + is String -> value.toBooleanStrictOrNull() + else -> null + } + } + + private fun convertItem(raw: Any?): AdminAlcoholItem? { + if (raw == null) return null + return when (raw) { + is AdminAlcoholItem -> raw + else -> + runCatching { + objectMapper.convertValue(raw, AdminAlcoholItem::class.java) + }.getOrNull() + } + } + + private fun AdminAlcoholItem.toSummary(): McpWhiskySummary = + McpWhiskySummary( + alcoholId = alcoholId, + korName = korName, + engName = engName, + korCategory = korCategoryName, + engCategory = engCategoryName, + imageUrl = imageUrl, + ) + + private fun AdminAlcoholDetailResponse.toMcpDetail(): McpWhiskyDetail = + McpWhiskyDetail( + alcoholId = alcoholId, + korName = korName, + engName = engName, + korCategory = korCategory, + engCategory = engCategory, + imageUrl = imageUrl, + abv = abv, + age = age, + cask = cask, + volume = volume, + description = description, + regionId = regionId, + korRegion = korRegion, + engRegion = engRegion, + distilleryId = distilleryId, + korDistillery = korDistillery, + engDistillery = engDistillery, + tastingTags = + tastingTags.map { tag -> + McpTastingTag(id = tag.id, korName = tag.korName, engName = tag.engName) + }, + ) +} diff --git a/plan/mcp-architecture-isolation.md b/plan/mcp-architecture-isolation.md new file mode 100644 index 000000000..227bc908e --- /dev/null +++ b/plan/mcp-architecture-isolation.md @@ -0,0 +1,66 @@ +# Admin MCP 아키텍처 — 격리 게이트웨이 + +- 작성: 2026-08-08 +- 이슈: bottle-note/workspace#370 +- 결정: **별도 앱 `bottlenote-mcp`** + **백엔드 MCP 전용 API** + +## Topology + +``` +관리자 / Agent 클라이언트 (Claude, Codex, Cursor) + | Streamable HTTP https://mcp.bottlenote.com/mcp + | Authorization: Bearer bn_agent_* + v + bottlenote-mcp (/Users/hgkim/workspace/bottlenote/mcp) + | 내부 HTTP allowlist only + | 1) POST /admin/api/v1/auth/agent + | 2) GET /admin/api/v1/mcp/... + | Admin JWT 클라이언트 미노출 + v + bottlenote-admin-api +``` + +## 왜 API 서버 엔드포인트만으로 부족한가 + +- MCP는 REST가 아니라 **JSON-RPC tools 프로토콜** (Streamable HTTP). +- 게이트웨이를 격리해야 권한 폭발 반경·배포·스케일을 Admin UI API와 분리 가능. + +## 왜 TS 별도 앱인가 (이번 결정) + +| 옵션 | 결과 | +|------|------| +| admin-api 내장 MCP | 비채택 — 프로세스/배포 커플링 | +| Java monorepo 모듈 | 가능했으나 사용자 지정 경로 `bottlenote/mcp` 별도 앱 | +| **TS `bottlenote-mcp`** | **채택** — Codex/Claude MCP SDK, multi-arch Node 이미지, k9s 독립 배포 | + +## 서브모듈 + +- 동일: `git.environment-variables` → `https://github.com/bottle-note/environment-variables.git` +- 시크릿/배포 매니페스트 원천은 서브모듈. 앱 레포에 키 커밋 금지. + +## Multi-arch + +- Dockerfile: `node:22-alpine` base (amd64/arm64) +- buildx: `--platform linux/amd64,linux/arm64` + +## 백엔드 MCP 최적화 API (admin-api) + +| Method | Path | 용도 | +|--------|------|------| +| GET | `/admin/api/v1/mcp/whiskies` | 요약 검색 (size≤50) | +| GET | `/admin/api/v1/mcp/whiskies/{id}` | MCP용 상세 | + +일반 Admin UI 계약(`/alcohols`)과 분리. 필드 축소·페이지 클램프·agent 친화 페이로드. + +## 보안 + +1. 클라이언트 → MCP: Agent Key만 +2. MCP → Admin: Agent Key 교환 후 JWT (요청 스코프) +3. 아웃바운드 allowlist: `/auth/agent`, `/mcp/*` +4. 로그 스크럽: `bn_agent_*`, JWT +5. 삭제/bulk 툴 미등록 + +## 로컬 경로 + +- MCP app: `/Users/hgkim/workspace/bottlenote/mcp` +- Backend worktree: `bottle-note-api-server/feat-issues-mcp` From da6606f72be7588f1b2bdfa63ecf8ff9769b24db Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 8 Aug 2026 01:25:28 +0900 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20MCP=20plan=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20SSOT=20?= =?UTF-8?q?=ED=95=9C=20=EC=9E=A5=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plan/mcp-370-decision-brief.md | 386 ----------------- plan/mcp-architecture-isolation.md | 80 ++-- plan/mcp-research-codebase-readiness.md | 253 ----------- plan/mcp-research-deploy-clients.md | 536 ------------------------ plan/mcp-research-security-checklist.md | 323 -------------- plan/mcp-research-spec-trends.md | 117 ------ plan/mcp-research-tool-catalog.md | 139 ------ 7 files changed, 45 insertions(+), 1789 deletions(-) delete mode 100644 plan/mcp-370-decision-brief.md delete mode 100644 plan/mcp-research-codebase-readiness.md delete mode 100644 plan/mcp-research-deploy-clients.md delete mode 100644 plan/mcp-research-security-checklist.md delete mode 100644 plan/mcp-research-spec-trends.md delete mode 100644 plan/mcp-research-tool-catalog.md diff --git a/plan/mcp-370-decision-brief.md b/plan/mcp-370-decision-brief.md deleted file mode 100644 index 563f7c24e..000000000 --- a/plan/mcp-370-decision-brief.md +++ /dev/null @@ -1,386 +0,0 @@ -# Admin MCP #370 — Decision Brief - -- 작성일: 2026-08-08 -- 이슈: `bottle-note/workspace#370` -- 관련: #340 Agent Key 완료, #341 감사 로그 open -- 입력: `mcp-research-spec-trends` · `mcp-research-codebase-readiness` · `mcp-research-deploy-clients` · `mcp-research-tool-catalog` · `mcp-research-security-checklist` -- 성격: **의사결정 브리프**. 프로덕션 코드·배포·PR 없음. 확정 요구사항 전 define/plan 입력. - ---- - -## Executive Summary - -| 결정 항목 | 권장 | -|---|---| -| 모듈 | 신규 bootJar **`bottlenote-admin-mcp`** (admin-api 내장·product 합류 비권장) | -| 트랜스포트 | **Streamable HTTP only**, 경로 `/mcp`, public `https://mcp.bottlenote.com` | -| 와이어 스펙 | 구현 **`2025-11-25`** (Java SDK v2 + Spring AI) / 설계 **stateless** (2026-07-28 전제) | -| 인증 1차 | **안 B**: `Authorization: Bearer bn_agent_*` → 서버 내부 `#340` 교환 → Admin JWT | -| 토큰 정책 | 클라이언트 Admin JWT **수신·통과 금지**. Admin JWT는 MCP 프로세스 내부만 | -| 첫 PR | 모듈 스캐폴드 + 키 교환 + whisky search/get + allowlist + 구조화 로그 | -| #370 DoD 후속 | 참조 조회 → 쓰기(+confirm)/presign/preview → #341 → prod HA → OAuth(안 A) | - ---- - -## 1. Recommended Architecture - -### 1.1 토폴로지 - -``` -[MCP Client: Claude Code / Cursor / Codex] - | Streamable HTTP POST https://mcp.../mcp - | Authorization: Bearer bn_agent_* (MCP 전용 자격만) - v -[bottlenote-admin-mcp] stateless, multi-pod OK, sticky 없음 - | 1) POST /admin/api/v1/auth/agent (서버 내부, 키 원문 미로그) - | 2) Admin JWT (요청 스코프 / 짧은 TTL 캐시, 클라이언트 미노출) - | 3) 화이트리스트 Admin API만 호출 (NEVER 경로 코드 경로 0) - v -[bottlenote-admin-api] --> mono --> MySQL / Redis / S3 -``` - -### 1.2 모듈 배치 - -| 옵션 | 판정 | 이유 | -|---|---|---| -| **A. 신규 `bottlenote-admin-mcp` bootJar** | **채택** | 프로세스 격리, MCP 전용 게이트웨이·스케일, Admin 계약 HTTP 소비, blast radius 분리 | -| B. admin-api 내장 | 스파이크만 | 배포 단순하나 포트·인증·재시작 커플링, 레이어 혼선 | -| C. mono에 프로토콜 | 거부 | mono는 도메인 라이브러리 | -| D. product-api 합류 | 거부 | Admin 권한 경계 붕괴 | - -- mono Facade/Service **직접 주입 금지**. Admin HTTP 클라이언트(WebClient/Feign)만 사용. -- settings: Gradle multi-module include, Java 21, Spring Boot 3.4.x, Spring AI MCP Server Boot Starter (`protocol=STREAMABLE`). -- 의존: `observability`(선택), Redis(rate limit·선택 handle), Admin 내부 base URL. - -### 1.3 Transport · Endpoint - -| 항목 | 값 | -|---|---| -| Public URL (prod) | `https://mcp.bottlenote.com` (DNS 표기 `bottlenote` vs `bottle-note` 인프라 확정 필요) | -| Dev URL (가칭) | `https://mcp.development.bottle-note.com` | -| Path | `/mcp` | -| Protocol wire | `2025-11-25` (SDK 한계) | -| Design constraint | **stateless**: `Mcp-Session-Id`/커넥션 스코프 비즈니스 상태 금지. 필요 시 Redis handle + `:` 바인딩 | -| Health | `/actuator/health/liveness`, `/actuator/health/readiness` (context-path 없음) | -| TLS | Gateway 종단. 앱 plain `:8080` | -| Session affinity | **없음** (`sessionAffinity` 미설정) | -| CORS 1차 | CLI/native 전제 → allow-origins 비움/최소. 브라우저 커넥터는 이후 | - -### 1.4 Agent Key → Admin JWT (인증 흐름) - -``` -Client --Bearer bn_agent_*--> MCP -MCP --POST /admin/api/v1/auth/agent { agentKey }--> Admin API (#340) -Admin --TokenItem(access, refresh)--> MCP (메모리만) -MCP --Bearer access--> 화이트리스트 Admin API -MCP --툴 결과(토큰 필드 없음)--> Client -``` - -| 규칙 | 내용 | -|---|---| -| 클라이언트 제시 | Agent Key(또는 이후 안 A의 MCP audience JWT)만 | -| 거부 | 사람 Admin JWT, Product JWT, audience 불일치 토큰 제시 → 401 | -| 교환 주체 | MCP 서버만. 교환 API는 내부 네트워크/서비스 호출 | -| Admin JWT 수명 | access 위주. **refresh last-writer-wins** → 파드 간 refresh 공유·재사용 금지. 만료 시 교환 재시도 | -| 캐시 | 요청 단위 또는 Redis `agentId` 바인딩 + TTL ≤ access 잔여. JVM static 금지 | -| 로그 | `bn_agent_*`·JWT 원문 0건. agentId/profileCode만 | -| 시드 주의 | 현재 에이전트 매핑 admin = `ROOT_ADMIN` → 툴/아웃바운드 allowlist로 폭발 반경 축소 (역할 하향은 후속 이슈) | - -**1차 인증 안**: **B (정적 Bearer)**. 소수 내부 에이전트 + Claude/Cursor/Codex 네이티브 헤더 지원. -**이후 안 A**: OAuth 2.1 RS + PRM, audience=`https://mcp.bottlenote.com`, client_credentials. - -### 1.5 인가 · Rate limit (아키텍처 수준) - -- 연결 인증 ≠ 전 툴 허용. scope: `admin:read` 기본 / `admin:whisky:write` / `admin:image:presign`. -- annotation(`readOnlyHint` 등)은 UX만. 인가·confirm은 **서버 디스패처**. -- Rate limit: Redis. 키=`agentId` 또는 XFF IP. write/presign/교환 > read. 로컬 카운터 금지. -- 아웃바운드: **URL allowlist** (툴 미등록 + 호출 경로 이중). - ---- - -## 2. First PR Vertical Slice vs Later Phases (#370 DoD) - -### 2.1 #370 DoD 해석 (브리프 기준) - -| DoD 축 | 완료 조건(합의 초안) | 1차 PR | 이후 | -|---|---|---|---| -| 원격 MCP 서버 | Streamable HTTP + 내부 에이전트 연결 | dev 호스트 | prod 호스트 | -| Agent Key 인증 | #340 교환, 토큰 통과 0 | 안 B | 안 A 선택 | -| 위스키 조회 | search/get (+lookup·참조) | search/get | 나머지 read | -| 위스키 단건 생성·수정 | confirm + 감사 | 제외 | P2 | -| 이미지 준비 | presign | 제외 | P2 | -| 변경 전후 검증 | preview_diff | 제외 | P2 | -| 삭제·bulk 비제공 | 미등록 + allowlist | 강제 | 유지 | -| 감사 | 툴 호출 단위 추적 | 구조화 로그 훅 | #341 풀 | -| 다중 인스턴스 | sticky 없이 동작 | 설계+dev 스모크 | prod replicas≥2 | - -### 2.2 First PR — 최소 수직 슬라이스 - -**목표**: “에이전트가 키로 인증하고, 위스키를 검색·조회하며, 호출이 추적 가능한가?”를 최소 코드로 증명. - -| Step | 내용 | 완료 조건 | -|---|---|---| -| S0 | `bottlenote-admin-mcp` 모듈 + Streamable HTTP `/mcp` + health | bootRun, sticky 불필요 | -| S1 | Agent Key → `POST .../auth/agent` → access 요청 스코프 | 유효/무효 키 테스트, 원문 미로그 | -| S2 | 툴 2개: `bottlenote_whisky_search`, `bottlenote_whisky_get` | Admin 왕복, `size` ≤50 클램프 | -| S3 | 구조화 감사 로그(임시): agentId/profileCode, tool, targetIds?, status, durationMs, correlationId | 스크러버 검증. #341 이식 필드 정렬 | -| S4 | 아웃바운드 allowlist: agent login + alcohols GET만 | DELETE/bulk 코드 경로 0 | -| S5 (배포, 서브모듈) | dev Deployment + Service + HTTPRoute `/mcp`·`/actuator` | 401 무키, 200 tools/list | - -**1차 PR 의도적 제외**: create/update, presign, region/distillery write, preview_diff, OAuth/PRM, #341 스키마 본구현, production multi-replica, Gateway global RL. - -**1차 수락 기준**: - -1. `POST .../mcp` + 유효 Bearer → `tools/list`에 등록 툴 -2. 무효/누락 Bearer → 401, body에 키 미포함 -3. search/get 실 Admin 왕복 성공 -4. 커밋·로그·트레이스에 `bn_agent_` 원문 0건 -5. sticky 없이 list/call 가능 (dev replicas=2 스모크 권장) - -### 2.3 Later Phases (DoD 완성 순서) - -| Phase | 내용 | DoD 기여 | -|---|---|---| -| **P1** (본 문서 First PR) | 모듈 + 교환 + whisky search/get + allowlist + 구조화 로그 + dev 배포 | 연결·조회 증명 | -| **P2** | 참조 조회 6툴 (lookup, category, distillery list/get, region list/get, tasting_tag_list) | 생성 워크플로 입력 | -| **P3** | write: create/update(`confirm=true`), image_presign 어댑터, preview_diff; write rate limit | 단건 변경 워크플로 | -| **P4** | #341 감사 테이블/이벤트 연계 (before/after, agent 차원, 성공·deny 1행) | 운영 감사 DoD | -| **P5** | production 호스트, replicas≥2, PDB, 릴리스 워크플로 편입 | HA | -| **P6** | 안 A OAuth RS + PRM (필요 시) | 범용 클라이언트 | -| **P7** | Gateway RL / `Mcp-Method` 정책, 에이전트 역할 ROOT_ADMIN 하향 | 보안 하드닝 | -| **P8** | Java SDK `2026-07-28` 마이그레이션 (트랜스포트만 교체 가정) | 스펙 정합 | - -**쓰기 슬라이스(P3) 전 게이트**: 최소 구조화 감사 훅 또는 #341 최소 스키마 없이 운영 write 금지 권고. - ---- - -## 3. Tool Inventory — Include vs Never-expose - -예산: **등록 13개** (조회 8 + 변경 5 계열). 명명: `bottlenote_{domain}_{action}`. - -### 3.1 INCLUDE (서버 등록) - -| # | tool | R/W | 1차 PR | Phase | Admin API | -|---|---|---|---|---|---| -| 1 | `bottlenote_whisky_search` | read | **Y** | P1 | `GET /admin/api/v1/alcohols` | -| 2 | `bottlenote_whisky_get` | read | **Y** | P1 | `GET /admin/api/v1/alcohols/{id}` | -| 3 | `bottlenote_whisky_lookup` | read | N | P2 | `GET .../alcohols/lookup` | -| 4 | `bottlenote_category_reference_get` | read | N | P2 | `GET .../alcohols/categories/reference` | -| 5 | `bottlenote_distillery_list` | read | N | P2 | `GET .../distilleries` | -| 6 | `bottlenote_distillery_get` | read | N | P2 | `GET .../distilleries/{id}` | -| 7 | `bottlenote_region_list` | read | N | P2 | `GET .../regions` | -| 8 | `bottlenote_region_get` | read | N | P2 | `GET .../regions/{id}` | -| 9 | `bottlenote_tasting_tag_list` | read | N | P2 | `GET .../tasting-tags` | -| 10 | `bottlenote_whisky_create` | write + **confirm** | N | P3 | `POST .../alcohols` | -| 11 | `bottlenote_whisky_update` | write + **confirm** | N | P3 | `PUT .../alcohols/{id}` | -| 12 | `bottlenote_image_presign` | write (URL만) | N | P3 | `GET .../s3/presign-url` (스키마 어댑터) | -| 13 | `bottlenote_whisky_preview_diff` | read | N | P3 | 로컬 get+diff (전용 API 불필요) | - -공통 규칙: page size 기본 20·최대 50, `additionalProperties: false`, write는 서버 `confirm=true` 강제, 목록은 요약 필드. - -### 3.2 NEVER-EXPOSE (미등록 + 아웃바운드 거부) - -| 금지 | 이유 | 대응 Admin API (존재해도 비노출) | -|---|---|---| -| `*_delete` / 소프트삭제 일괄 | #370 자동 삭제 비제공 | `DELETE /alcohols|distilleries|regions|tasting-tags/{id}` | -| bulk reorder / 대량 수정 단일 툴 | 폭발 반경 | `PATCH .../bulk/reorder` 등 | -| 무페이징 `list_all_*` | 컨텍스트 폭증 | — | -| Agent Key / JWT 발급·조회 툴 | 시크릿 노출 | #340 교환은 MCP 내부 전용 | -| 토큰 통과 프록시 툴 | 스펙 금지 | — | -| region/distillery/tag CUD 툴 | 1차 범위 밖 (참조 조회만) | POST/PUT/DELETE 해당 리소스 | -| tag↔alcohol 전용 attach/detach 툴 | whisky create/update `tastingTagIds`로 흡수 | `POST/DELETE .../tasting-tags/{id}/alcohols` | -| curation / banner / user / review 전면 | #370 범위 밖 | 별도 카탈로그 | -| 웹검색·Whiskybase·출처 판정·태그 자동생성 | 명시 배제 | 리서치 MCP 몫 | - -NEVER의 본방어 = **툴 미등록 + Admin HTTP allowlist**. JWT가 ROOT_ADMIN이어도 MCP가 DELETE를 호출하지 못하게 한다. - -### 3.3 Scope 맵 (구현 시) - -| scope | 툴 | -|---|---| -| `admin:read` | #1–#9, #13 | -| `admin:whisky:write` | #10–#11 (+ read 권장) | -| `admin:image:presign` | #12 | - -초기 에이전트: `admin:read` only → write 필요 시 별 에이전트 또는 step-up. - ---- - -## 4. Risks - -### 4.1 Java SDK gap (스펙 2026-07-28 vs SDK 2025-11-25) - -| 항목 | 내용 | -|---|---| -| 사실 | MCP 최신 스펙 `2026-07-28` = stateless, session 제거, `server/discover` 등. Java SDK GA v2.0.0은 **`2025-11-25`** 트래킹. 2026-07-28 대응 릴리스 조사 시점 없음 | -| 리스크 | 클라이언트가 2026-07-28 only 요구 시 연결 실패; 세션 API에 비즈니스 상태를 묶으면 마이그레이션 재작성 | -| 완화 | **와이어는 2025-11-25, 설계는 stateless**. 세션 미의존. Assumption으로 plan에 고정. SDK 업그레이드 시 트랜스포트 계층만 교체 | -| 차단 가능 | Spring AI ↔ Boot 3.4.11 의존성 해석 실패 → **착수 전 스파이크(A1)** | - -### 4.2 Multi-pod - -| 항목 | 내용 | -|---|---| -| 사실 | AGENTS.md: 다중 인스턴스, JVM 로컬로 카운트·락 금지. product prod replicas 2. **admin-api는 현재 replicas 1**. MCP 스펙도 sticky 불필요 방향 | -| 리스크 | 세션/static rate limit/파드 로컬 JWT 캐시 → 불일치·우회. 동일 Agent Key로 다 파드가 **refresh 경쟁** 시 last-writer-wins로 상호 무효 | -| 완화 | MCP **stateless** + Redis rate limit/handle. Admin JWT **access 위주**, refresh 공유 금지. Service `sessionAffinity` 미사용. 설계부터 multi 전제 (admin 스케일 전에도) | - -### 4.3 #341 Audit gap - -| 항목 | 내용 | -|---|---| -| 사실 | 운영 감사 테이블/툴 호출 로그 **없음**. 있는 것은 JPA `AuditPrincipal`(V5) + `AGENT` enum. `AuditorAwareImpl`은 항상 **ADMIN**. 에이전트 토큰 호출도 ADMIN 주체 계약(통합 테스트) | -| 리스크 | 쓰기 후 “누가 무엇을” 추적 불가. 교환 직후 profileCode 유실. 감사 실패 삼키고 write 성공 시 컴플라이언스 붕괴 | -| 완화 | **1차**: 구조화 앱 로그 + 스크러버 + 필드 세트 정렬(보안 체크리스트 5.1). **JPA Auditor를 AGENT로 바꾸지 않음**(계약 유지). MCP 감사는 별도 경로. **P3 write 전** #341 최소 연동 또는 감사 실패 시 write 실패 정책 합의 | -| 권장 필드 | timestamp, traceId, agentId, toolName, rw, decision, denyReason?, argsRedacted, targetIds, before/after(write), resultCode, durationMs, clientIp | - -### 4.4 기타 상위 리스크 (요약) - -| ID | 리스크 | 완화 | -|---|---|---| -| R1 | 시드 Agent = ROOT_ADMIN → 탈취 JWT = 전 Admin API | MCP allowlist; 역할 하향 후속 이슈 | -| R2 | Envoy Streamable HTTP 버퍼링/타임아웃 | 인프라 실측 후 툴 타임아웃 정합 | -| R3 | presign 스키마 vs Admin `rootPath/uploadSize` 불일치 | P3 어댑터 | -| R4 | 호스트명 `mcp.bottlenote.com` vs `bottle-note.com` | DNS 팀 확정 | -| R5 | Agent Key 로그 유출 | K1–K8 체크리스트 + CI secret scan | - ---- - -## 5. Exact File / Module List — First Implementation PR - -프로덕션 비즈니스 로직은 아래 **신규 MCP 모듈 + 루트 빌드 연결**에 한정. Admin 도메인 서비스 수정 없음(교환 API 기존 사용). - -### 5.1 이 저장소 (API 서버) — First PR 예상 경로 - -| 경로 | 작업 | -|---|---| -| `settings.gradle` (또는 `settings.gradle.kts`) | `bottlenote-admin-mcp` include | -| `build.gradle` / version catalog (`gradle/libs.versions.toml`) | Spring AI MCP starter, WebClient 등 버전 핀 (스파이크 후) | -| `bottlenote-admin-mcp/build.gradle` | bootJar 모듈 정의, mono 비의존 또는 최소 공통만 | -| `bottlenote-admin-mcp/src/main/resources/application.yml` | port, MCP STREAMABLE `/mcp`, Admin base URL, Redis, actuator | -| `bottlenote-admin-mcp/src/main/java/.../AdminMcpApplication.java` | Spring Boot entry | -| `.../config/McpServerConfig.java` (가칭) | Streamable HTTP / tool registration | -| `.../config/SecurityConfig.java` | Bearer Agent Key 필터, 공개 health, 그 외 인증 | -| `.../auth/AgentKeyAuthenticationFilter.java` (가칭) | `bn_agent_*` 검증 흐름 진입 | -| `.../auth/AdminTokenExchangeClient.java` | `POST /admin/api/v1/auth/agent` | -| `.../auth/AdminAccessTokenHolder.java` | 요청 스코프/짧은 캐시 (static 금지) | -| `.../client/AdminApiClient.java` | WebClient + **경로 allowlist** | -| `.../client/AdminApiPaths.java` | 허용 상수: agent, `GET /alcohols`, `GET /alcohols/{id}` | -| `.../tool/WhiskySearchTool.java` | `bottlenote_whisky_search` | -| `.../tool/WhiskyGetTool.java` | `bottlenote_whisky_get` | -| `.../tool/ToolSchemaSupport.java` (선택) | size 클램프, schema 공통 | -| `.../audit/McpAuditLogger.java` | 구조화 로그 (키/JWT 스크러빙) | -| `.../audit/SecretScrubber.java` | Authorization / bn_agent_ / JWT 마스킹 | -| `.../ratelimit/RedisRateLimiter.java` (최소) | agentId + IP | -| `bottlenote-admin-mcp/src/test/java/...` | 단위: scrubber, size clamp, allowlist deny; 통합: Fake Admin / WireMock 교환+alcohols | - -**패키지 루트 권장**: `app.bottlenote.mcp` 또는 프로젝트 기존 `app.bottlenote` 규칙에 맞춤 (구현 시 기존 모듈 패키지 관례 확인). - -**First PR에서 건드리지 않음**: - -- `bottlenote-admin-api` 컨트롤러/서비스 (교환·alcohols 이미 존재) -- `bottlenote-mono` 도메인 -- JPA Auditor / V5 principal (AGENT로 변경 금지) -- Flyway (1차 불필요; #341은 P4) -- product-api - -### 5.2 배포 서브모듈 (`git.environment-variables`) — 동일 이슈 연계 PR 가능, 이 브리프 범위는 목록만 - -| 경로 | 작업 | -|---|---| -| `deploy/base/mcp-server.yaml` | Deployment + Service ClusterIP 80→8080 | -| `deploy/overlays/development/*-patch.yaml` | replicas 1, profile, image | -| `deploy/overlays/development/http-route.yaml` | host `mcp.development...`, `/mcp` + `/actuator`, 나머지 403 | -| `deploy/overlays/development/kustomization.yaml` | resources + images | -| (선택) `deploy/overlays/development/secrets/mcp-server-secret.sops.yaml` | `ADMIN_API_BASE_URL`, Redis, OTel — **Agent Key 원문 넣지 않음** | -| (이후) production overlay + DNS `mcp.bottlenote.com` | P5 | -| `.github/workflows/deploy_development_applications.yml` (API 레포) | 이미지 빌드·태그 슬롯 (1차는 수동 태그 가능) | - -### 5.3 문서 (선택, First PR 또는 직후) - -| 경로 | 내용 | -|---|---| -| `docs/mcp-client-setup.md` 또는 plan 절 승격 | Claude/Cursor/Codex placeholder (`bn_agent_`, env `BOTTLENOTE_AGENT_KEY`) | - -### 5.4 First PR 의존성 스파이크 (PR 전 또는 PR 0) - -| 항목 | 산출 | -|---|---| -| Spring AI MCP starter + Boot 3.4.11 해석 | 호환 버전 핀 표 | -| `McpStateless*` / STREAMABLE 기동 스모크 | `/mcp` initialize 또는 tools/list | - ---- - -## 6. Go / No-Go (배포·확장 전) - -보안 체크리스트 요약 — First PR 완료 시 최소 증명: - -1. [ ] Agent Key 원문 로그/트레이스/응답/커밋 0 -2. [ ] 클라이언트 Admin JWT 제시 → 401, Admin API 전달 0 -3. [ ] DELETE/bulk 툴 미등록 + allowlist 외 호출 0 -4. [ ] tools/call(또는 list) 성공·실패 감사/구조화 로그 1행 -5. [ ] Redis 또는 동등 공유 저장소 전제 설계 (로컬 RL 없음) -6. [ ] sticky 없이 동작 - -Write 확장 전 추가: - -7. [ ] confirm 없이 write → 거부 + deny 감사 -8. [ ] before/after (update) -9. [ ] write rate limit + 429 -10. [ ] #341 연계 정책 합의 - ---- - -## 7. Decisions Locked vs Open - -### Locked (이 브리프 권장 = 구현 기본값) - -1. 모듈: **독립 `bottlenote-admin-mcp`** -2. Transport: **Streamable HTTP `/mcp` only** -3. 자격: **Agent Key → 서버 내부 Admin JWT (#340)**, 토큰 통과 금지 -4. 1차 인증 표면: **안 B** -5. 설계: **stateless / multi-pod / Redis for RL** -6. First tools: **whisky_search + whisky_get only** -7. NEVER: delete, bulk, token tools, 범위 외 도메인 -8. #341: 1차는 구조화 로그; JPA AGENT 전환 안 함 - -### Open (구현 전 확인) - -| # | 항목 | 담당 | -|---|---|---| -| O1 | prod 호스트 최종 문자열 | 인프라/DNS | -| O2 | Envoy 스트리밍·timeout 실측 | 인프라 | -| O3 | Spring AI 버전 핀 결과 | 백엔드 스파이크 | -| O4 | 감사 실패 시 write 실패 여부 | #341 합의 | -| O5 | 안 A 도입 시점 | 제품/보안 | -| O6 | Rate limit 수치 운영 합의 | 운영 | - ---- - -## 8. Anti-patterns (즉시 거부) - -- product-api에 Admin MCP 부착 -- sticky session / 파드 로컬 rate limit -- 클라이언트 JWT를 Admin Authorization에 패스스루 -- 세션·static에 비즈니스 상태 -- 삭제 방지 = description/annotation만 -- `admin:*` 단일 스코프 -- 1차 PR에 OAuth + 전체 13툴 + prod HA 일괄 -- 테스트 fixture에 실키 `bn_agent_` 커밋 -- 레거시 HTTP+SSE 채택 - ---- - -## Source (내부) - -- `plan/mcp-research-spec-trends.md` -- `plan/mcp-research-codebase-readiness.md` -- `plan/mcp-research-deploy-clients.md` -- `plan/mcp-research-tool-catalog.md` -- `plan/mcp-research-security-checklist.md` -- 관련: `plan/agent-key-token-exchange.md` (있다면) - ---- - -**산출물 성격**: #370 define/plan 의사결정 입력. 이 문서만으로 구현·배포 승인으로 간주하지 않는다. -**다음 권장 단계** (실행하지 않음): `/define` 또는 plan Tasks 분해 → First PR 스파이크(O3) → S0–S5 구현. diff --git a/plan/mcp-architecture-isolation.md b/plan/mcp-architecture-isolation.md index 227bc908e..22abbcf09 100644 --- a/plan/mcp-architecture-isolation.md +++ b/plan/mcp-architecture-isolation.md @@ -1,8 +1,8 @@ -# Admin MCP 아키텍처 — 격리 게이트웨이 +# Admin MCP — 아키텍처 SSOT -- 작성: 2026-08-08 -- 이슈: bottle-note/workspace#370 -- 결정: **별도 앱 `bottlenote-mcp`** + **백엔드 MCP 전용 API** +- 이슈: bottle-note/workspace#370 · 관련 #340(Agent Key) · #341(감사, open) +- 작성/정리: 2026-08-08 +- 이 문서가 plan 내 **유일한** MCP 설계 문서다. 조사 원본(research/brief)은 폐기했다. ## Topology @@ -11,7 +11,7 @@ | Streamable HTTP https://mcp.bottlenote.com/mcp | Authorization: Bearer bn_agent_* v - bottlenote-mcp (/Users/hgkim/workspace/bottlenote/mcp) + bottlenote-mcp (/Users/hgkim/workspace/bottlenote/mcp, TS) | 내부 HTTP allowlist only | 1) POST /admin/api/v1/auth/agent | 2) GET /admin/api/v1/mcp/... @@ -20,47 +20,57 @@ bottlenote-admin-api ``` -## 왜 API 서버 엔드포인트만으로 부족한가 +## 결정 -- MCP는 REST가 아니라 **JSON-RPC tools 프로토콜** (Streamable HTTP). -- 게이트웨이를 격리해야 권한 폭발 반경·배포·스케일을 Admin UI API와 분리 가능. +| 항목 | 값 | +|------|-----| +| 게이트웨이 | 별도 TS 앱 `bottlenote-mcp` (admin-api 내장·Java monorepo MCP 모듈 비채택) | +| Transport | Streamable HTTP `/mcp`, **stateless** (sticky 없음, multi-pod OK) | +| Wire | 구현은 SDK 현실에 맞춤; 비즈니스 상태 세션 금지 | +| 인증 | Agent Key만 수신 → 서버 내부 #340 교환 → Admin JWT(요청 스코프) | +| 배포 | multi-arch Node 22 (`linux/amd64`, `linux/arm64`), k9s/GitOps | +| 서브모듈 | `git.environment-variables` (api-server와 동일). 키 커밋 금지 | -## 왜 TS 별도 앱인가 (이번 결정) +## 백엔드 MCP API (admin-api) -| 옵션 | 결과 | -|------|------| -| admin-api 내장 MCP | 비채택 — 프로세스/배포 커플링 | -| Java monorepo 모듈 | 가능했으나 사용자 지정 경로 `bottlenote/mcp` 별도 앱 | -| **TS `bottlenote-mcp`** | **채택** — Codex/Claude MCP SDK, multi-arch Node 이미지, k9s 독립 배포 | +일반 `/alcohols` UI 계약과 분리. 필드 축소·size≤50. -## 서브모듈 +| Method | Path | 용도 | +|--------|------|------| +| GET | `/admin/api/v1/mcp/whiskies` | 요약 검색 | +| GET | `/admin/api/v1/mcp/whiskies/{id}` | MCP용 상세 | -- 동일: `git.environment-variables` → `https://github.com/bottle-note/environment-variables.git` -- 시크릿/배포 매니페스트 원천은 서브모듈. 앱 레포에 키 커밋 금지. +## Tools -## Multi-arch +### v0.1 (구현 중/게이트웨이 스캐폴드) -- Dockerfile: `node:22-alpine` base (amd64/arm64) -- buildx: `--platform linux/amd64,linux/arm64` +| tool | backend | +|------|---------| +| `bottlenote_whisky_search` | `GET /mcp/whiskies` | +| `bottlenote_whisky_get` | `GET /mcp/whiskies/{id}` | -## 백엔드 MCP 최적화 API (admin-api) +### 이후 (미구현) -| Method | Path | 용도 | -|--------|------|------| -| GET | `/admin/api/v1/mcp/whiskies` | 요약 검색 (size≤50) | -| GET | `/admin/api/v1/mcp/whiskies/{id}` | MCP용 상세 | +조회: lookup, category reference, distillery/region list·get, tasting_tag list +쓰기: whisky create/update(`confirm=true`), image presign, preview_diff +감사: #341 연계 + +### NEVER (미등록 + 아웃바운드 거부) -일반 Admin UI 계약(`/alcohols`)과 분리. 필드 축소·페이지 클램프·agent 친화 페이로드. +- delete / bulk / 무페이징 list_all +- 토큰 발급·통과 프록시 +- 웹검색·외부 출처 판단·태그 자동생성 +- curation / banner / user / review 전면 -## 보안 +## 보안 (필수) -1. 클라이언트 → MCP: Agent Key만 -2. MCP → Admin: Agent Key 교환 후 JWT (요청 스코프) -3. 아웃바운드 allowlist: `/auth/agent`, `/mcp/*` -4. 로그 스크럽: `bn_agent_*`, JWT -5. 삭제/bulk 툴 미등록 +1. 클라이언트 → MCP: Agent Key만 (Admin JWT 수신·통과 금지) +2. MCP → Admin: allowlist `/auth/agent`, `/mcp/*` 만 +3. 로그·메트릭·예외에 `bn_agent_*` / JWT 원문 금지 +4. 쓰기 툴은 서버 `confirm=true` 강제 (annotation은 UX 힌트일 뿐) +5. Rate limit 키: agentId 우선, fallback XFF (게이트웨이 XFF 신뢰) -## 로컬 경로 +## 로컬 -- MCP app: `/Users/hgkim/workspace/bottlenote/mcp` -- Backend worktree: `bottle-note-api-server/feat-issues-mcp` +- MCP app: `/Users/hgkim/workspace/bottlenote/mcp` (푸시·본구현은 별도) +- Backend: 이 저장소 `feat-issues-mcp` 등 diff --git a/plan/mcp-research-codebase-readiness.md b/plan/mcp-research-codebase-readiness.md deleted file mode 100644 index 3abbe0a54..000000000 --- a/plan/mcp-research-codebase-readiness.md +++ /dev/null @@ -1,253 +0,0 @@ -# Admin MCP #370 — Codebase Readiness - -- 조사일: 2026-08-08 / 이슈: `bottle-note/workspace#370` -- task_id: `task_15b6beb24a16` / dispatch_id: `ctx_21b5039f2484` -- 근거: Admin/mono 실코드, Flyway V5·V7, deploy overlay, `plan/mcp-research-spec-trends.md`, `plan/mcp-research-tool-catalog.md` -- 성격: 읽기 전용 readiness 매핑. 프로덕션 코드·PR 없음. 확정 요구사항 아님. - -## Summary - -- **Agent 로그인(#340)은 Admin에 이미 존재**한다. 공개 경로 `POST /admin/api/v1/auth/agent` → 매핑 Admin JWT(`TokenItem`) 발급. MCP 서버가 키를 들고 내부에서만 교환하면 토큰 통과를 피할 수 있다. -- **#370 대상 Admin API(위스키·지역·증류소·카테고리·테이스팅태그·이미지)는 컨트롤러 기준으로 전부 실재**한다. 1차 툴 카탈로그(13개) 중 `preview_diff`만 전용 API 없이 서버 로컬 diff로 충분. -- **#341 운영 감사 로그는 미구현**. 있는 것은 JPA 엔티티 `create/last_modify_principal_*` 컬럼(V5)과 `AuditPrincipalType.AGENT` 열거값뿐이다. 에이전트 토큰으로 호출해도 감사 주체는 **ADMIN**으로 찍힌다(통합 테스트가 명시적으로 고정). -- **다중 파드 규칙과 현재 배포 상태가 어긋나 있다.** 프로젝트 규칙·product-api(prod replicas 2)는 multi-pod 전제지만 **admin-api는 prod/dev 모두 replicas: 1**. MCP는 처음부터 stateless로 설계해야 하며, Admin JWT 교환 시 refresh last-writer-wins와 `agents.last_used_at` 미갱신 공백을 인지해야 한다. -- **첫 수직 슬라이스 권장**: 인증 교환 + `whisky_search`/`whisky_get` 읽기 2툴. 모듈은 **신규 `bottlenote-admin-mcp` bootJar**(Admin HTTP 클라이언트)를 1순위. - -## 경로·인증 실측 - -### 글로벌 prefix - -| 레이어 | 값 | 근거 | -|---|---|---| -| servlet context-path | `/admin/api` | `bottlenote-admin-api/.../application.yml` | -| API version prefix | `/v1` (presentation Controller 자동) | `AdminApiVersionConfig` (`/v2/curation*` 제외) | -| 세션 | `STATELESS` | `SecurityConfig` | -| 기본 인가 | `@SecurityPolicy` 없으면 `REQUIRED_AUTH` | `SecurityPolicyConfig.FALLBACK_AUTH_TYPE` | -| PUBLIC | login / refresh / agent / actuator / error / openapi | 어노테이션 + explicit routes | - -### Agent login - -| 항목 | 실측 | -|---|---| -| 메서드·경로 | `POST /admin/api/v1/auth/agent` | -| 컨트롤러 | `AuthController.loginWithAgent` (`@PostMapping("/agent")`, `PUBLIC`) | -| 요청 DTO | `AgentLoginRequest(agentKey)` `@NotBlank` | -| 키 형식 | `^bn_agent_[A-Za-z0-9_-]{43}$` → SHA-256 (`AgentKeyHasher`) | -| 조회 | `AgentFacade.findActiveAgentAccount` → `agents` ACTIVE + 매핑 admin ACTIVE | -| 응답 | `GlobalResponse` 래핑 `TokenItem(accessToken, refreshToken)` | -| JWT | `tokenProvider.generateAdminToken(email, roles, adminId)` — **사람 관리자와 동일 클레임** | -| 부수효과 | admin `refreshToken` 덮어쓰기, `lastLoginAt` 갱신 | -| 오류 | 형식 오류 400(`AGENT_KEY_INVALID_FORMAT`), 그 외 통합 401(`AGENT_AUTHENTICATION_FAILED`) | -| 시드 | V7: 에이전트 6프로필 a–f, 매핑 `admin_users` roles=`ROOT_ADMIN` | -| 미구현 | `agents.last_used_at` 컬럼만 있고 **코드에서 갱신 없음** | - -시드 에이전트가 `ROOT_ADMIN`이라는 점은 MCP 권한 최소화와 충돌한다. MCP 스코프를 툴 레이어에서 강제해도, 탈취된 Admin JWT 자체는 전체 Admin API를 열 수 있다. - -### Security·토큰 소비 - -- Access JWT는 `AdminJwtAuthenticationFilter`가 보호 경로에 적용. -- 에이전트 발급 토큰으로 보호 API 호출 가능(통합 테스트: `GET /v1/users`). -- 재로그인 시 **이전 refresh 무효(last-writer-wins)**. 동일 에이전트 키로 MCP 파드 여러 개가 각자 refresh를 돌리면 서로 쫓아낸다 → MCP는 **access 위주 + 필요 시 교환 재시도**, refresh를 공유 상태로 쓰지 말 것. - -## 대상 Admin API 맵 (컨트롤러 실측) - -전체 외부 경로 = `/admin/api` + `/v1` + 컨트롤러 매핑. 인가 기본 `REQUIRED_AUTH`(Bearer Admin JWT). - -### Whisky (alcohols) - -| Method | Path | 핸들러 | 서비스 | MCP 카탈로그 | -|---|---|---|---|---| -| GET | `/alcohols` | `searchAlcohols` | `AlcoholQueryService.searchAdminAlcohols` | `bottlenote_whisky_search` | -| GET | `/alcohols/{alcoholId}` | `getAlcoholDetail` | `findAdminAlcoholDetailById` | `bottlenote_whisky_get` | -| GET | `/alcohols/lookup` | `getAlcoholLookups` | `AlcoholLookupService.lookup` | `bottlenote_whisky_lookup` | -| GET | `/alcohols/categories/reference` | `getCategoryReference` | `findAllCategoryReferenceMap` | `bottlenote_category_reference_get` | -| POST | `/alcohols` | `createAlcohol` | `AdminAlcoholCommandService.createAlcohol` | `bottlenote_whisky_create` (+confirm) | -| PUT | `/alcohols/{alcoholId}` | `updateAlcohol` | `updateAlcohol` | `bottlenote_whisky_update` (+confirm) | -| DELETE | `/alcohols/{alcoholId}` | `deleteAlcohol` | `deleteAlcohol` | **NEVER 노출** | - -- 검색 DTO: `AdminAlcoholSearchRequest` — keyword, category, regionId, sort, page(default 0), size(default 20), includeDeleted. -- Upsert DTO: `AdminAlcoholUpsertRequest` — kor/eng name, abv, type, categories, regionId, distilleryId, age, cask, imageUrl, description, volume, tastingTagIds?. -- Lookup: Redis 스냅샷 우선 + local-cache(버전 키로 invalidation). multi-pod 허용 패턴. - -### Region - -| Method | Path | MCP | -|---|---|---| -| GET | `/regions`, `/regions/{id}` | list/get (1차) | -| POST/PUT/DELETE | create/update/delete | 1차 비노출 | -| PATCH | `/{id}/sort-order`, `/bulk/reorder`, `/{parentId}/children/bulk/reorder` | **NEVER bulk** | - -### Distillery - -| Method | Path | MCP | -|---|---|---| -| GET | `/distilleries`, `/distilleries/{id}` | list/get (1차) | -| POST/PUT/DELETE | CUD | 1차 비노출 | -| PATCH | sort-order, bulk/reorder | **NEVER bulk** | - -### Category - -- 전용 `/categories` CRUD 없음. -- 참조만: `GET /alcohols/categories/reference` → 그룹 맵. 카탈로그 툴 1개로 충분. - -### Tasting tag - -| Method | Path | MCP | -|---|---|---| -| GET | `/tasting-tags`, `/tasting-tags/{id}` | list (1차), detail 선택 | -| POST/PUT/DELETE | 태그 CUD | 1차 비노출(사람 Admin 전제) | -| POST/DELETE | `/{tagId}/alcohols` | whisky create/update의 `tastingTagIds`로 흡수, 툴 비노출 | - -참조 검색 DTO 공통: `AdminReferenceSearchRequest` page/size default 0/20. - -### Image (presign) - -| Method | Path | 서비스 | MCP | -|---|---|---|---| -| GET | `/s3/presign-url` | `ImageUploadService.getPreSignUrlForAdmin` | `bottlenote_image_presign` | - -- 쿼리: `rootPath`, `uploadSize`(default 1), `contentType`(default `image/jpeg`). -- 툴 카탈로그의 `fileName` 중심 스키마와 **필드명이 불일치** → 어댑터 필요. -- 바이너리 업로드는 클라이언트→S3 PUT(PreSign). MCP 서버는 URL만 중계. -- expiry 5분. adminId는 SecurityContext에서 강제. - -## Audit vs #341 - -### 현재 있는 것 (entity principal audit) - -- `AuditPrincipal` embeddable: `principal_id`, `principal_type`, `principal_email`. -- `AuditPrincipalType`: `USER | ADMIN | AGENT | SYSTEM | ANONYMOUS`. -- `AuditorAwareImpl`: `CustomAdminUserContext` → **항상 ADMIN**. `AGENT` 분기 없음. -- V5: alcohols/distilleries/regions 등 다수 테이블에 principal 컬럼 표준화. -- 에이전트 토큰 회원가입 통합 테스트: **감사 주체 ADMIN 유지가 계약**. - -### 없는 것 (#341 / MCP 완료 기준 대비) - -| 필요 항목 (#370 감사 기대) | 코드베이스 상태 | -|---|---| -| 툴 호출 단위 append-only 로그 | **없음** (audit_log 테이블/서비스 0) | -| toolName, args(마스킹), 결과 코드, 소요시간 | **없음** | -| 변경 전/후 스냅샷 | 엔티티 last_modify만 존재, before JSON 없음 | -| agentId / profileCode 차원 | JWT·Auditor에 없음. Facade payload에 profileCode만 있고 로그인 후 유실 | -| traceId 전파 | admin `management.tracing.enabled: false` | -| Agent Key / JWT 스크러빙 정책 코드화 | 로그 관행만, 전용 필터 미확인 | - -### 해석 - -- **#341이 열림(open)인 상태**에서 #370이 “#341 연계”를 전제하면, MCP 1차 슬라이스는 (a) 구조화 애플리케이션 로그 + 후속 #341 스키마 수용 훅, 또는 (b) #341 최소 스키마를 선행 슬라이스로 묶는 선택이 필요하다. -- 엔티티 principal을 AGENT로 바꾸면 **기존 계약(통합 테스트)이 깨진다**. MCP 감사는 별도 테이블/이벤트가 맞고, JPA Auditor는 당분간 ADMIN 유지가 안전하다. -- 다만 MCP 감사 레코드에는 **반드시** `agentId`/`profileCode`(교환 시점에 Facade에서 확보)를 남겨 “어느 에이전트가 어느 Admin으로 위장했는지” 추적 가능해야 한다. 지금은 교환 직후 profileCode를 버리는 구조다. - -## Multi-pod / 운영 규칙 - -| 규칙·사실 | 함의 | -|---|---| -| AGENTS.md: 인스턴스 다중, JVM 로컬 상태로 카운트·락·스케줄 금지 | MCP 세션/툴 상태 sticky 금지. handle·rate limit은 Redis 등 공유 저장소 | -| MCP 스펙 방향(2026-07-28 stateless) | 세션 없는 Streamable HTTP, round-robin OK (`mcp-research-spec-trends`) | -| admin-api deploy replicas: **1** (dev/prod) | 지금 Admin 자체는 single-pod. MCP 붙여도 당장 sticky 이슈는 적지만 **설계는 multi 전제** | -| product-api prod replicas: **2** | 플랫폼 운영 원칙은 multi. admin/MCP도 확장 시 동일 규칙 | -| JWT access + DB refresh | access는 무상태 검증 가능. refresh는 row last-writer-wins → MCP 파드 간 공유 refresh 금지 | -| Alcohol lookup local-cache + Redis version | 허용된 “짧은 로컬 캐시 + 공유 버전” 패턴. MCP 툴 결과 캐시도 동일 패턴만 | -| OTEL off | 분산 trace 연계는 인프라 켜기 전까지 불완전. 로그에 자체 correlation id 권장 | -| k8s health | `/admin/api/actuator/health/{liveness,readiness}` — MCP 모듈 분리 시 자체 probe 필요 | - -## capability | API | gap | risk - -| capability | API (실측) | gap | risk | -|---|---|---|---| -| Agent 자격 교환 | `POST /admin/api/v1/auth/agent` | 교환 후 profileCode/agentId 유실; last_used_at 미갱신 | 감사·키 사용 추적 공백. 시드 ROOT_ADMIN JWT 탈취 시 전 Admin 권한 | -| Admin JWT 사용 | Bearer on REQUIRED_AUTH routes | MCP 전용 audience/scope 없음 | 토큰이 범용 Admin 키. 스코프 최소화 불가(앱 레이어 가드 필수) | -| Whisky 검색 | `GET /alcohols` | size 상한 서버 강제 여부 툴 어댑터 확인 필요 | 큰 size로 컨텍스트 폭증 | -| Whisky 상세 | `GET /alcohols/{id}` | 없음(준비됨) | 민감 필드 과다 노출 가능 → 툴 응답 축소 권장 | -| Whisky lookup | `GET /alcohols/lookup` | Redis/local-cache 의존 | Redis 장애 시 DB 폴백 지연 | -| Category 참조 | `GET /alcohols/categories/reference` | 없음 | 낮음 | -| Region list/get | `GET /regions`, `/{id}` | 없음 | 낮음 | -| Distillery list/get | `GET /distilleries`, `/{id}` | 없음 | 낮음 | -| Tasting tag list | `GET /tasting-tags` | 없음 | 낮음 | -| Whisky create | `POST /alcohols` | confirm 가드·payload nested 스키마는 MCP 측 신규 | 오생성; 필수 필드 다수 → 모델 실수율 | -| Whisky update | `PUT /alcohols/{id}` | before 스냅샷 자동 감사 없음 | 조용한 덮어쓰기; #341 전 추적 공백 **고** | -| Image presign | `GET /s3/presign-url` | 툴 스키마 vs `rootPath/uploadSize/contentType` 불일치 | 잘못된 경로/MIME; 업로드 남용 | -| preview_diff | 없음(로컬 get+diff) | 전용 API 불필요 | 구현 누락 시 검증 워크플로 약화 | -| Delete / bulk reorder | DELETE·PATCH bulk 존재 | MCP 미등록만으로는 우회 호출 가능(Admin JWT 보유 시) | **고** — JWT 권한 과다 + NEVER 툴만으로는 부족, MCP 서버가 Admin 호출 화이트리스트 강제 필요 | -| 운영 감사 로그 | entity principal only | #341 미구현; AGENT 타입 미사용 | 컴플라이언스·사고 대응 공백 **고** | -| Multi-pod MCP | (미존재) | 모듈·배포 없음; OTEL off | 세션 상태 설계 시 실패; 관측 공백 | -| Spring AI / MCP SDK | 의존성 0 | 버전 핀·Boot 3.4.11 호환 검증 필요 | 착수 차단 가능(가정) | - -## 모듈 배치 권장 - -### 후보 비교 - -| 옵션 | 구조 | 장점 | 단점 | 판정 | -|---|---|---|---|---| -| **A. 신규 `bottlenote-admin-mcp` bootJar** | settings.gradle include, WebClient/Feign → admin-api 내부 URL | 프로세스 격리, Admin 표면과 MCP 표면 분리, 독립 스케일·배포, Admin API 계약을 그대로 소비 | HTTP 홉, 배포 파이프라인 추가, DTO 이중 매핑 | **1순위** | -| B. admin-api 내부 패키지 | Kotlin presentation + Spring AI in-process | 빠른 프로토타입, 서비스 직접 호출 | MCP 포트·인증 혼재, Admin 장애 도메인 공유, 레이어 표준(Controller→Service)과 MCP 툴 계층 혼선 | 스파이크만 | -| C. mono 라이브러리 only | 툴 구현을 mono에 | 공유 로직 | mono는 도메인 라이브러리 — 프로토콜/트랜스포트 넣으면 경계 붕괴 | **비권장** | -| D. product-api 합류 | — | — | Admin 데이터 경로와 무관 | 탈락 | - -### 권장 토폴로지 (A) - -``` -[MCP Client] - | Streamable HTTP + Agent Key (Bearer bn_agent_* 또는 OAuth 안 A) - v -[bottlenote-admin-mcp] --stateless-- - | 1) POST /admin/api/v1/auth/agent (서버 내부만, 키 저장소/ENV) - | 2) Admin JWT로 화이트리스트 Admin API만 호출 - v -[bottlenote-admin-api] --> [mono] --> MySQL/Redis/S3 -``` - -- mono 도메인 서비스 직접 주입 금지(옵션 A). Facade/Service를 MCP가 우회하면 트랜잭션·인가·OpenAPI 계약을 두 번 유지하게 된다. -- 의존성: Spring Boot 3.4.x, Spring AI MCP server starter, WebClient, 기존 observability 모듈 선택. -- 시크릿: Agent Key는 MCP 파드 Secret(또는 호출 클라이언트 제공 키를 요청마다 해시 검증). **Admin JWT는 프로세스 메모리·요청 스코프만**, Redis에 넣더라도 TTL·agent 바인딩. -- NEVER 목록은 **아웃바운드 HTTP 화이트리스트**로 강제(툴 미등록 + URL allowlist 이중). - -## 첫 수직 슬라이스 (권장) - -목표: “에이전트가 키로 인증하고, 위스키를 검색·조회하며, 호출이 추적 가능한가?”를 최소 코드로 증명. - -| Step | 내용 | 완료 조건 | -|---|---|---| -| S0 | 모듈 스캐폴드 `bottlenote-admin-mcp` + Streamable HTTP `/mcp` + health | bootRun, multi-pod sticky 불필요 확인 | -| S1 | Agent Key 수신 → `POST .../auth/agent` → access 캐시(요청 단위) | 통합 테스트: 유효/무효 키, 형식 오류 | -| S2 | 툴 2개: `bottlenote_whisky_search`, `bottlenote_whisky_get` | Admin 실 API 왕복, page size ≤50 클램프 | -| S3 | 구조화 감사 로그(임시): agentProfileCode, tool, alcoholId?, status, durationMs, correlationId | 키/JWT 로그 미출력 검증. #341 테이블 생기면 동일 필드 이식 | -| S4 | 아웃바운드 allowlist: agent + alcohols GET만 | DELETE/ bulk 경로 호출 코드 경로 0 | - -**슬라이스에서 의도적으로 제외**: create/update, presign, region/distillery write, #341 스키마 본구현, OAuth AS(안 A), preview_diff. - -### 후속 슬라이스 순서 (참고) - -1. 참조 조회(category/region/distillery/tasting-tag list) → 생성 워크플로 입력 완비 -2. image presign 어댑터 + whisky create/update(+confirm) + preview_diff -3. #341 감사 테이블 연계 + (선택) JWT에 agent claim 또는 교환 응답 확장 -4. 에이전트 Admin 역할을 ROOT_ADMIN에서 최소 권한 역할로 하향(별 이슈) - -## 차단·가정 - -| ID | 항목 | 조치 | -|---|---|---| -| A1 | Spring AI MCP ↔ Boot 3.4.11 호환 | 착수 전 의존성 해석 스파이크 | -| A2 | Java SDK는 2025-11-25 트래킹, 설계는 stateless | 스펙 트렌드 문서와 동일 Assumption | -| A3 | #341 open | 1차는 구조화 로그 훅; 스키마 확정 시 이식 | -| A4 | 시드 Agent = ROOT_ADMIN | 권한 축소는 보안 후속; MCP allowlist로 완화 | -| A5 | admin-api 단일 레플리카 | MCP multi 배포 시 Admin 스케일·rate limit 재검토 | -| A6 | Gitea/workspace 이슈 본문은 이 조사에서 직접 fetch 안 함 | 요구는 카탈로그·스펙 리서치·코드 실측으로 대체 | - -## 관련 파일 (앵커) - -- Auth: `bottlenote-admin-api/.../auth/presentation/AuthController.kt`, `bottlenote-mono/.../user/service/AdminAuthService.java`, `.../agent/**` -- Alcohols: `.../alcohols/presentation/AdminAlcoholsController.kt`, `AdminAlcoholCommandService.java`, `AdminAlcoholUpsertRequest.java` -- Region/Distillery/Tag: `AdminRegionController.kt`, `AdminDistilleryController.kt`, `AdminTastingTagController.kt` -- Image: `AdminImageUploadController.kt`, `ImageUploadService.java` -- Audit: `AuditPrincipal.java`, `AuditorAwareImpl.java`, `AuditPrincipalType.java`, V5 SQL -- Agent seed: `V7__add_agent_key_auth.sql`, `agent/api-keys.sops.yaml`(원문 비커밋 정책) -- Deploy: `git.environment-variables/deploy/base/admin-api.yaml`, overlays `replicas: 1` -- 선행 리서치: `plan/mcp-research-spec-trends.md`, `plan/mcp-research-tool-catalog.md`, `plan/agent-key-token-exchange.md` - -## 결론 - -1. **코드베이스 readiness: 인증·대상 CRUD/조회 API는 준비됨. 감사·권한 세분화·MCP 모듈은 공백.** -2. **#340으로 자격 교환 경로가 닫혀 있어 #370 착수 가능.** #341 없이도 읽기 슬라이스는 가능하나, 쓰기 슬라이스 전에 감사 훅(최소 로그 또는 #341)이 필요하다. -3. **첫 수직 슬라이스 = 신규 MCP 모듈 + agent 교환 + whisky search/get + allowlist + 구조화 로그.** -4. **삭제·bulk·토큰 통과·세션 상태·Admin JWT 스코프 과다는 최상위 리스크**이며 서버 allowlist와 무상태 설계로 막는다. diff --git a/plan/mcp-research-deploy-clients.md b/plan/mcp-research-deploy-clients.md deleted file mode 100644 index 1024d6044..000000000 --- a/plan/mcp-research-deploy-clients.md +++ /dev/null @@ -1,536 +0,0 @@ -# MCP 배포·클라이언트 설정 리서치 — Admin MCP #370 - -- 조사일: 2026-08-08 / 이슈: `bottle-note/workspace#370` -- 관련: #340 Agent Key 완료, #341 감사 로그 open -- 근거: `git.environment-variables/deploy/**` 실측(시크릿 값 미열람) + `plan/mcp-research-spec-trends.md` + 클라이언트 설정 공개 문서 -- 성격: 읽기 전용 설계 입력. 프로덕션 코드 변경 없음. 확정 요구사항 아님. - -## Summary - -- **제품 형태**: `https://mcp.bottlenote.com` (Streamable HTTP, 엔드포인트 `/mcp`) 원격 MCP 서버. Agent Key(`bn_agent_*`)를 클라이언트가 보내고, MCP 서버가 내부에서 Admin JWT로 교환(#340) — **토큰 통과 금지**. -- **배포 형태**: 기존 product/admin과 **동일 GitOps 패턴**을 복제한다. `deploy/base` + `overlays/{development,production}` + Envoy Gateway `HTTPRoute` + KSOPS Secret + Argo CD auto-sync. 매니페스트 원본은 서브모듈 `git.environment-variables`(원격 `bottle-note/environment-variables`). -- **TLS**: 클러스터 밖 TLS 종단은 `main-gateway`(Envoy Gateway, `envoy-gateway-system`). 앱 컨테이너는 HTTP `:8080`만 노출. 앱에서 TLS 종료하지 않음. -- **CORS**: MCP 클라이언트는 브라우저 Origin이 아니라 **네이티브 앱/CLI 헤더 호출**이 기본. 서버 CORS allowlist는 비우거나 최소 유지. 브라우저 커넥터(ChatGPT 등 OAuth)를 나중에 열 때만 별도 정책. -- **Health**: product/admin과 동일하게 Actuator liveness/readiness. 게이트웨이는 `/actuator` prefix 허용 패턴 유지. -- **Rate limit / XFF**: 게이트웨이가 XFF를 재작성하므로 앱이 받는 XFF는 신뢰 가능. **Rate limit 키 = Agent Key 주체(agentId) 우선, fallback = XFF 클라이언트 IP**. Redis 공유 카운터. **sticky session 불필요**(stateless MCP). -- **클라이언트**: Claude Code / Cursor / Codex(CLI)는 Streamable HTTP + `Authorization: Bearer` 네이티브 지원. Claude Desktop은 `mcp-remote` 브릿지 또는 stdio. 문서·온보딩에는 **원문 키 placeholder만** (`bn_agent_` / env var). -- **1차 PR vs 이후**: 1차는 앱 모듈(또는 admin-api 내장 옵션 결정) + dev HTTPRoute + 읽기 툴 + 정적 Bearer + 클라이언트 문서. 이후 OAuth/PRM, prod multi-replica, 게이트웨이 rate limit, 쓰기 툴, 감사(#341). - -## 1. 제품 형태 (`mcp.bottlenote.com`) - -| 항목 | 권장 값 | 근거 | -|---|---|---| -| Public URL | `https://mcp.bottlenote.com` | 스펙 트렌드 문서 audience 예시, #370 원격 서버 전제 | -| Dev URL | `https://mcp.development.bottle-note.com` (가칭) | 기존 호스트 규칙: `*.development.bottle-note.com` / `api.development...` | -| Transport | **Streamable HTTP only** | 레거시 HTTP+SSE Deprecated. Spring AI `protocol=STREAMABLE` | -| Path | `/mcp` | Spring AI MCP Server Boot Starter 기본 경로 | -| Protocol wire | 구현: `2025-11-25` (Java SDK v2), 설계: stateless(2026-07-28 전제) | 스펙 트렌드 문서 | -| Auth (1차) | `Authorization: Bearer bn_agent_...` | 안 B 최소 비용. 소수 내부 에이전트 | -| Auth (이후) | OAuth 2.1 RS + PRM, audience=`https://mcp.bottlenote.com` | 안 A. 범용 클라이언트 호환 | -| Upstream | Admin API (내부 ClusterIP 또는 동일 프로세스) | 툴 카탈로그 13개 → Admin 엔드포인트 | -| Session affinity | **없음** | sticky session 금지. handle 필요 시 Redis | - -### 모듈 배치 옵션 - -| 옵션 | 설명 | 1차 추천 | -|---|---|---| -| **A. 독립 Deployment `mcp-server`** | 별도 jar/이미지, admin-api를 HTTP 클라이언트로 호출 | **권장** — blast radius·스케일·게이트웨이 정책을 MCP 전용으로 분리 | -| B. admin-api 내장 | 같은 프로세스에 `/mcp` 노출 | 배포 단순. 단 admin 부하·재시작 커플링 | -| C. product-api 내장 | 비권장 | Admin 권한 툴을 product에 넣으면 경계 붕괴 | - -옵션 A를 전제로 아래 배포 패턴을 기술한다. B로 가면 HTTPRoute만 admin 호스트에 `/mcp`를 추가하는 축소판이 된다. - -## 2. 기존 deploy 패턴 실측 (시크릿 미포함) - -### 2.1 디렉터리 구조 - -``` -git.environment-variables/deploy/ - base/ # 공통 Deployment + Service - product-api.yaml - admin-api.yaml - admin-dashboard.yaml - frontend.yaml - batch-module.yaml - kustomization.yaml - overlays/ - development/ # ns: bottlenote-development - kustomization.yaml # resources: ../../base + http-route + redis - *-patch.yaml # replicas, profile, nodeSelector - http-route.yaml # Envoy Gateway HTTPRoute - secrets-generator.yaml # KSOPS - secrets/*.sops.yaml - redis-replication.yaml - production/ # ns: bottlenote-production - (동일 + product-api-pdb.yaml) - argocd/bottlenote/applications/ - bottlenote-development.yaml - bottlenote-production.yaml # source path = deploy/overlays/{env} -``` - -### 2.2 Deployment / Service 관례 (product-api 기준) - -| 항목 | 값 | -|---|---| -| labels | `app: product-api` (+ overlay `env: dev|prod`) | -| container port | `8080` name `http` | -| Service | ClusterIP `port: 80` → `targetPort: 8080` | -| envFrom | `secretRef: product-api-env` (KSOPS 생성 Secret) | -| env 고정 | `TZ=Asia/Seoul`, overlay에서 `SPRING_PROFILES_ACTIVE`, `SERVER_NAME`=pod name | -| imagePullSecrets | `private-registry-secret` | -| registry | `docker-registry.bottle-note.com/...` | -| image tag | overlay `kustomization.yaml` `images[].newTag` — GH Actions가 갱신 | -| nodeSelector | `kubernetes.io/arch: arm64` | -| replicas | product prod **2**, admin/dev **1** | -| PDB | product prod only: `minAvailable: 1` | -| resources (product base) | req 500m/2Gi, lim 1500m/6Gi | -| resources (admin base) | req 250m/1Gi, lim 750m/3Gi | - -**MCP 1차 제안 리소스** (admin과 유사, 트래픽 소수): - -- req: cpu 250m / mem 512Mi–1Gi -- lim: cpu 500m–750m / mem 1–2Gi -- replicas: dev 1 / prod 1(1차) → 2 + PDB(이후) - -### 2.3 Health probe 관례 - -| 서비스 | liveness / readiness path | -|---|---| -| product-api | `/actuator/health/liveness`, `/actuator/health/readiness` | -| admin-api | `/admin/api/actuator/health/liveness` (context-path 포함), readiness 동일 | - -공통 probe 타이밍(base): - -- startup: initialDelay 90s, period 10s, failureThreshold 25, timeout 5s -- liveness: period 10s, timeout 5s, failureThreshold 3 -- readiness: period 5s, timeout 3s, failureThreshold 3 - -**MCP 서버**: context-path 없이 `/actuator/health/{liveness,readiness}` 권장. 게이트웨이 허용 prefix에 `/actuator` 포함. - -### 2.4 HTTPRoute / 게이트웨이 관례 - -- Gateway: `main-gateway` in `envoy-gateway-system` (Gateway API) -- API 종류: `gateway.networking.k8s.io/v1` `HTTPRoute` -- 호스트별 라우트 분리 (prod 예): - - `api.product.bottle-note.com` → product-api - - `admin-api.bottle-note.com` → admin-api (`/admin/api`, `/actuator`) - - `bottle-note.com` → frontend - - `admin.bottle-note.com` → admin-dashboard -- **경로 화이트리스트 + 나머지 403**: `HTTPRouteFilter` `block-unknown-paths` (DirectResponse JSON `{"error":"Forbidden"}`) -- product 허용: `/api/v1`, `/api/v2`, `/actuator` — 그 외 `/` → 403 -- admin 허용: `/admin/api`, `/actuator` — 그 외 `/` → 403 - -**MCP용 HTTPRoute 초안 (prod, 매니페스트 예시 — 아직 미적용)** - -```yaml -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: mcp-server-route - namespace: bottlenote-production -spec: - parentRefs: - - group: gateway.networking.k8s.io - kind: Gateway - name: main-gateway - namespace: envoy-gateway-system - hostnames: - - mcp.bottlenote.com - rules: - - matches: - - path: - type: PathPrefix - value: /mcp - backendRefs: - - name: mcp-server - port: 80 - - matches: - - path: - type: PathPrefix - value: /actuator - backendRefs: - - name: mcp-server - port: 80 - - matches: - - path: - type: PathPrefix - value: / - filters: - - type: ExtensionRef - extensionRef: - group: gateway.envoyproxy.io - kind: HTTPRouteFilter - name: block-unknown-paths -``` - -dev는 hostname만 `mcp.development.bottle-note.com`(가칭)으로 교체. - -**Streamable HTTP 게이트웨이 주의** (스펙 트렌드 미확인 항목 승격): - -- POST `/mcp` 응답 스트리밍을 버퍼링하면 장기 툴 호출이 끊긴다. -- 타임아웃: 툴 호출 상한(예: 30–60s)보다 게이트웨이/Envoy idle timeout이 짧으면 실패. -- 바디 크기 제한: 목록 응답·diff 스냅샷을 고려해 기본 제한 확인. -- 확인 담당: 인프라. 앱 착수 전 체크리스트에 포함. - -### 2.5 TLS - -- 공개 호스트 TLS는 Gateway 레이어에서 종단 (이 저장소 매니페스트에 Certificate 리소스는 없음 — 클러스터/Gateway 공통 설정 추정). -- 앱: plain HTTP 8080. 컨테이너 간 mTLS 강제 흔적 없음. -- MCP 클라이언트 → `https://mcp.bottlenote.com` 만 문서화. HTTP 평문 공개 금지. - -### 2.6 Secret / 설정 (키 이름만) - -KSOPS `secrets-generator.yaml` → `product-api-secret.sops.yaml` 등이 Secret `product-api-env`로 주입. - -관측된 **키 이름**(값 미열람): `SERVER_PORT`, `DB_*`, `REDIS_*`, `JWT_SECRET_KEY`, `AWS_*`, OTel, Discord, OAuth 쿠키 관련, Root Admin 등. - -Agent Key 원문 보관: - -- 서브모듈 루트 `agent/api-keys.sops.yaml` — 프로필 `0001`–`0006`, 필드 `alias` / `api_key` (SOPS age) -- DB V7: `agents.api_key_hash` SHA-256만 저장, 원문 없음 -- **배포 Secret에 Agent Key 원문을 넣지 않는다.** 원문은 사람/에이전트 클라이언트 로컬 설정 전용. MCP 서버는 해시 검증 또는 Admin 교환 API만 사용. - -MCP 서버 전용 env 후보(신규, 값 설계만): - -| 키 | 용도 | -|---|---| -| `SPRING_PROFILES_ACTIVE` | dev/prod | -| `ADMIN_API_BASE_URL` | 클러스터 내부 `http://admin-api` (+ context `/admin/api`) | -| `MCP_SERVER_PUBLIC_URL` | `https://mcp.bottlenote.com` (OAuth audience / 문서) | -| `REDIS_*` | rate limit·(선택) handle 저장 — 기존 redis 공유 가능 | -| OTel 계열 | 기존 product/admin과 동일 패턴 | -| `JWT`/`AGENT` 교환 관련 | 서버가 Admin 교환 호출 시 필요한 내부 설정 (시크릿은 별도 SOPS) | - -### 2.7 GitOps / 배포 파이프라인 - -| 환경 | 트리거 | 동작 | -|---|---|---| -| development | main CI 성공 또는 `deploy_development_applications.yml` 수동 | 이미지 빌드 → overlay `kustomization.yaml` 이미지 태그 커밋 → Argo sync | -| production | `backend/vX.Y.Z` release published → `deploy_release_applications.yml` | 동일, production overlay | -| batch | `deploy_batch.yml` 수동 | 별도 | - -Argo Application: - -- source: `https://github.com/bottle-note/environment-variables`, path `deploy/overlays/{env}` -- automated prune + selfHeal -- Secret `/data` ignoreDifferences - -**MCP 추가 시 작업 위치** (이 API 서버 레포가 아닌 environment-variables 서브모듈/원격): - -1. `deploy/base/mcp-server.yaml` (Deployment+Service) -2. overlays patch + images 항목 -3. `http-route.yaml`에 호스트/경로 규칙 -4. (선택) `mcp-server-secret.sops.yaml` + secrets-generator -5. GH workflow에 이미지 빌드·태그 갱신 job (1차 수동 태그도 가능) -6. DNS: `mcp.bottlenote.com` / dev 호스트 → Gateway - -로컬에서 이미지 빌드·푸시·매니페스트 직접 운영 배포 금지(AGENTS.md). - -### 2.8 Redis - -- Opstree `RedisReplication` (prod clusterSize 3, AOF, maxmemory 1gb LRU) -- MCP rate limit·짧은 TTL handle에 **기존 Redis 공유** 가능. 별도 Redis는 불필요(1차). - -## 3. Streamable HTTP · CORS · Health 상세 - -### 3.1 Streamable HTTP - -| 항목 | 권장 | -|---|---| -| Method/Path | MCP JSON-RPC over HTTP, 주로 `POST /mcp` (+ 스펙이 요구하는 GET 스트림이 있으면 동일 path) | -| Content-Type | `application/json` (및 스트림 시 스펙 규정 MIME) | -| 필수 헤더(미래 스펙) | `Mcp-Method` / `Mcp-Name` — 게이트웨이 정책 승격 여지. 지금은 앱 레이어에서 툴명 기준 인가 | -| Session | `Mcp-Session-Id`에 서버 상태 묶지 않음. 2025-11-25 SDK가 세션을 쓰더라도 **비즈니스 상태를 세션에 두지 않음** | -| 압축 | product/admin은 Tomcat compression on (json 등). 스트리밍 응답은 압축 off 또는 스트리밍 MIME 제외 확인 | - -### 3.2 CORS - -현재 product/admin: - -- allowlist origin (localhost, github.io 문서 등) -- methods: GET/POST/PUT/DELETE/PATCH/OPTIONS -- headers: `Authorization`, `Content-Type` -- credentials: false -- OpenAPI 문서는 별도 빈/최소 docs CORS - -**MCP 권장**: - -| 클라이언트 유형 | CORS 필요? | -|---|---| -| Claude Code / Cursor / Codex CLI / Desktop(mcp-remote) | **아니오** (Origin 없는 서버-사이드 또는 네이티브 HTTP) | -| 브라우저 기반 커넥터 (ChatGPT custom connector 등) | **예** — OAuth + 제한적 Origin. 1차 범위 밖 | - -1차 서버 설정: - -- 일반 MCP 경로: allow-origins **비움** 또는 미사용. preflight가 오면 403이어도 CLI는 무관. -- 허용 헤더에 최소한 `Authorization`, `Content-Type` (및 스펙 추가 헤더 `Mcp-Method`, `Mcp-Name`을 쓸 계획이면 포함). -- `Access-Control-Allow-Origin: *` + credentials 조합 금지. -- Agent Key를 쿼리스트링에 싣는 방식 금지 (로그·Referer 유출). - -### 3.3 Health · 관측 - -- `/actuator/health/liveness`: 프로세스 생존만 (의존성 제외) -- `/actuator/health/readiness`: Admin API 또는 DB/Redis 중 MCP 동작에 필수인 의존성만 -- 게이트웨이 `/actuator` 공개: 현재 product/admin과 동일하게 **클러스터 밖에서도 경로 허용**. 민감 엔드포인트(`env`, `heapdump` 등)는 Spring 노출 제한으로 막혀 있어야 함 — MCP도 동일 정책 강제. -- OTel: product 시크릿에 이미 OTLP 엔드포인트 키 존재. MCP도 동일 exporter. 툴 호출 span에 `gen_ai.tool.name`, agentId(해시/ID만). - -## 4. Rate limit · XFF · stickiness - -### 4.1 XFF 신뢰 모델 - -AGENTS.md / CLAUDE.md: - -> 앞단 게이트웨이가 클라이언트를 통해 들어온 `X-Forwarded-For`를 제거하고 실제 접속 주소로 다시 채운다. 따라서 앱이 받는 XFF는 신뢰할 수 있다. - -코드 실측(product `SecurityConfig` visitor telemetry): XFF 첫 유효 IP 사용. 동일 해석기를 MCP rate limit·감사 IP 필드에 재사용 가능. - -### 4.2 Rate limit 설계 - -현재 코드베이스에 Bucket4j/전역 rate limit 구현 **없음**. MCP에서 신규 도입. - -| 차원 | 키 | 권장 한도(초안) | 비고 | -|---|---|---|---| -| 인증 주체 | `agentId` (검증 후) | 분당 60 req / 에이전트 | 툴 호출·initialize·tools/list 포함 여부 명시 | -| 미인증/실패 | XFF IP | 분당 20 req | 브루트포스·키 스캔 완화 | -| 쓰기 툴 | `agentId` + tool name | 분당 10 (create/update) | confirm 필수와 병행 | -| 응답 | HTTP 429 + `Retry-After` | 클라이언트 재시도 가이드 | | - -저장소: **Redis** (다중 파드 공유). 로컬 메모리 카운터 금지. - -구현 위치: - -1. 1차: Spring Filter / Interceptor in MCP 앱 (빠름) -2. 이후: Envoy Gateway global rate limit (인프라) — 앱 한도와 이중화 가능 - -### 4.3 Stickiness - -| 대상 | sticky 필요? | -|---|---| -| MCP 세션 → 파드 | **아니오** — stateless 설계 | -| Rate limit | Redis 공유로 sticky 불필요 | -| Admin JWT 캐시(서버 내부) | 파드 로컬 캐시 TTL 짧게 또는 Redis. sticky로 해결하지 않음 | -| Service `sessionAffinity` | **설정하지 않음** (기본 None) | - -Gateway HTTPRoute에 cookie affinity / consistent hash 파드 고정 넣지 않는다. - -## 5. 클라이언트 설정 (Agent Key placeholder) - -원칙: - -- 문서·레포·커밋에 **실키 금지**. placeholder: `bn_agent_` 또는 env `BOTTLENOTE_AGENT_KEY`. -- 원문 키는 `agent/api-keys.sops.yaml` 복호화 권한이 있는 운영자만 로컬에 설정. -- 서버는 키를 툴 인자로 받지 않음. 헤더(또는 OAuth)만. - -Public endpoint (placeholder 호스트): - -```text -https://mcp.bottlenote.com/mcp -``` - -dev: - -```text -https://mcp.development.bottle-note.com/mcp -``` - -### 5.1 Claude Code - -네이티브 Streamable HTTP. - -```bash -claude mcp add --scope user --transport http bottlenote-admin \ - https://mcp.bottlenote.com/mcp \ - --header "Authorization: Bearer ${BOTTLENOTE_AGENT_KEY}" -``` - -또는 프로젝트 `.mcp.json` (키가 파일에 남지 않게 env 치환 지원 시 문서화): - -```json -{ - "mcpServers": { - "bottlenote-admin": { - "type": "http", - "url": "https://mcp.bottlenote.com/mcp", - "headers": { - "Authorization": "Bearer bn_agent_" - } - } - } -} -``` - -검증: `/mcp` 또는 툴 목록에 `bottlenote_whisky_search` 등 노출. - -### 5.2 Cursor - -- Global: `~/.cursor/mcp.json` -- Project: `.cursor/mcp.json` - -```json -{ - "mcpServers": { - "bottlenote-admin": { - "url": "https://mcp.bottlenote.com/mcp", - "headers": { - "Authorization": "Bearer bn_agent_" - } - } - } -} -``` - -Settings → MCP에서 토글/재연결. 프로젝트 레포에 실키 커밋 금지 — 팀 공유 시 env 기반 또는 개인 global 설정. - -### 5.3 Codex CLI - -`~/.codex/config.toml` — Streamable HTTP + bearer env 권장(파일에 키 미기록). - -```toml -[mcp_servers.bottlenote_admin] -url = "https://mcp.bottlenote.com/mcp" -bearer_token_env_var = "BOTTLENOTE_AGENT_KEY" -# 대안: 정적 헤더 (비권장 — 파일에 키 잔존) -# http_headers = { "Authorization" = "Bearer bn_agent_" } -``` - -CLI: - -```bash -export BOTTLENOTE_AGENT_KEY='bn_agent_' -codex mcp add bottlenote_admin \ - --url https://mcp.bottlenote.com/mcp \ - --bearer-token-env-var BOTTLENOTE_AGENT_KEY -codex mcp get bottlenote_admin --json # transport.type == streamable_http 확인 -``` - -### 5.4 Claude Desktop (부록) - -원격 HTTP 네이티브 미흡 시 `mcp-remote` 브릿지: - -```json -{ - "mcpServers": { - "bottlenote-admin": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://mcp.bottlenote.com/mcp", - "--header", - "Authorization:Bearer bn_agent_" - ] - } - } -} -``` - -경로: macOS `~/Library/Application Support/Claude/claude_desktop_config.json`. Node.js PATH 필요. - -### 5.5 클라이언트 호환 매트릭스 - -| 클라이언트 | Transport | Auth 설정 | 1차 지원 | -|---|---|---|---| -| Claude Code | Streamable HTTP native | `--header` / `.mcp.json` | Yes | -| Cursor | Streamable HTTP native | `url` + `headers` | Yes | -| Codex CLI | Streamable HTTP native | `url` + `bearer_token_env_var` | Yes | -| Claude Desktop | stdio bridge (`mcp-remote`) | `--header` | Yes (문서화) | -| ChatGPT connector | OAuth 위주 | 정적 Bearer 비적합 | **이후** (안 A) | - -### 5.6 온보딩 체크리스트 (운영자) - -1. SOPS로 `agent/api-keys.sops.yaml`에서 본인 프로필 키 확인 (원문 재표시 정책은 운영 규약 따름) -2. `export BOTTLENOTE_AGENT_KEY=...` (shell profile 또는 secret manager) -3. 위 클라이언트 중 하나로 연결 -4. 읽기 툴 1회 호출 (`bottlenote_whisky_search`) -5. 401 → 키/헤더 공백, 403 → 에이전트 status, 429 → rate limit 대기 - -## 6. First PR vs Later - -### 6.1 1차 PR 범위 (최소 동작 수직 슬라이스) - -**목표**: 내부 에이전트 1–2명이 dev에서 읽기 툴을 호출할 수 있다. - -| 영역 | 포함 | 제외 | -|---|---|---| -| 앱 | MCP 모듈/서버 스캐폴드, Streamable HTTP `/mcp`, 정적 Bearer Agent Key 검증, Admin 교환 내부 호출 | OAuth/PRM, EMA | -| 툴 | 조회 툴 서브셋 (예: search/get/lookup 3–5개) | 쓰기·presign·preview 전체, 삭제 | -| 배포 | `deploy/base/mcp-server.yaml` + **development** overlay + HTTPRoute + 이미지 파이프라인(또는 임시 수동 태그) | production 다중 레플리카, PDB | -| DNS/TLS | dev 호스트만 | prod `mcp.bottlenote.com` (준비만) | -| Rate limit | 앱 레벨 agentId + XFF, Redis | Gateway global RL | -| CORS | 기본 거부/미사용 | 브라우저 커넥터 | -| 문서 | 이 plan 기반 클라이언트 설정 절 (placeholder) | 공개 레지스트리 등재 | -| 감사 | 기존 로그에 agentId·툴명 최소 필드 | #341 풀 모델 | -| 테스트 | 단위(Fake Admin)·계약 테스트 | 풀 e2e 부하 | - -**1차 수락 기준 초안**: - -1. `POST https://mcp.development.../mcp` + 유효 Bearer → `tools/list`에 등록 툴 -2. 무효/누락 Bearer → 401 -3. 파드 2개로 올려도 sticky 없이 list/call 성공 (dev에서 replicas=2 스모크 가능) -4. 시크릿·커밋에 `bn_agent_` 원문 0건 -5. HTTPRoute: `/mcp`, `/actuator`만 백엔드, 기타 403 - -### 6.2 이후 PR / 단계 - -| 단계 | 내용 | -|---|---| -| P2 | 쓰기 툴 + `confirm=true` 서버 가드, presign, preview_diff | -| P3 | production 호스트, replicas≥2, PDB, 릴리스 워크플로 정식 편입 | -| P4 | OAuth 2.1 + Protected Resource Metadata (안 A), audience 검증 | -| P5 | #341 감사 로그 풀 연동 (before/after, traceId) | -| P6 | Gateway 단 rate limit / `Mcp-Method` 헤더 정책 (스펙·SDK 지원 시) | -| P7 | 브라우저 커넥터·ChatGPT 등 — CORS/OAuth 별도 설계 | -| P8 | Java SDK `2026-07-28` 마이그레이션 (트랜스포트만 교체 가정) | - -### 6.3 의사결정이 필요한 항목 (구현 전 확인) - -1. **모듈 배치**: 독립 `mcp-server` vs admin-api 내장 (이 문서 권장: 독립) -2. **prod 호스트 최종 문자열**: `mcp.bottlenote.com` vs `mcp.bottle-note.com` (기존 도메인은 `bottle-note.com` 하이픈 패턴 — **DNS 팀과 확정 필요**. 스펙 트렌드 문서는 `mcp.bottlenote.com` 표기) -3. **게이트웨이 스트리밍/타임아웃** 실측 -4. **Rate limit 수치** (분당 60 등) 운영 합의 -5. 1차 인증 안 B 유지 기간 — OAuth 이전이라도 Claude/Cursor/Codex는 Bearer로 충분 - -## 7. 안티패턴 (배포·클라이언트) - -- product-api에 Admin MCP 툴을 붙여 권한 경계 붕괴 -- sticky session / `sessionAffinity: ClientIP`로 상태 숨기기 -- Agent Key 원문을 k8s Secret·환경변수 서브모듈 plain·CI 로그에 저장 -- 클라이언트 설정 예시에 실키 하드코딩 후 레포 커밋 -- 게이트웨이에서 `/` 전체 개방 (스캐닝 노출) — 반드시 path allowlist -- MCP 경로에 `Access-Control-Allow-Origin: *` -- rate limit을 파드 로컬 메모리로만 구현 -- 레거시 SSE URL을 클라이언트 문서에 병기 -- 1차 PR에 OAuth+전체 툴+prod HA를 한 번에 넣기 - -## 8. 미확인 / 후속 확인 - -- [ ] Envoy Gateway가 Streamable HTTP 응답 스트리밍을 버퍼링하는지, idle/request timeout 기본값 -- [ ] 호스트명 표기: `bottlenote.com` vs `bottle-note.com` 최종 DNS -- [ ] 신규 MCP 이미지용 GH workflow 슬롯 (development/release 워크플로 확장 vs 독립) -- [ ] admin-api 내부 ClusterIP 호출 시 인증: 교환 API 경로·네트워크 정책 -- [ ] Actuator 공개 범위 재검토 (MCP·admin·product 공통 보안 하드닝) -- [ ] Claude Desktop `mcp-remote`와 서버 401 challenge 호환성 실기기 테스트 - -## Source / 근거 - -### 레포 실측 - -- `git.environment-variables/deploy/base/{product-api,admin-api}.yaml` -- `git.environment-variables/deploy/overlays/{development,production}/{http-route,kustomization,*-patch,product-api-pdb,secrets-generator,redis-replication}.yaml` -- `git.environment-variables/deploy/argocd/bottlenote/applications/*.yaml` -- `git.environment-variables/storage/db/migration/V7__add_agent_key_auth.sql` -- `git.environment-variables/agent/api-keys.sops.yaml` (구조만, 값 미열람) -- `.github/workflows/deploy_{development,release}_applications.yml` -- `bottlenote-product-api/.../SecurityConfig.java` (XFF·CORS) -- `plan/mcp-research-spec-trends.md`, `plan/mcp-research-tool-catalog.md`, `plan/agent-key-token-exchange.md` -- AGENTS.md / CLAUDE.md 다중 인스턴스·XFF·배포 규칙 - -### 외부 (클라이언트 설정 패턴) - -- MCP Streamable HTTP 원격 + Bearer: Cursor `url`/`headers`, Claude Code `claude mcp add --transport http`, Codex `url` + `bearer_token_env_var` -- Claude Desktop: `mcp-remote` stdio 브릿지 관례 -- 스펙·Spring AI Streamable HTTP: 스펙 트렌드 문서 Source links 참조 - ---- - -**산출물 성격**: #370 define/plan 입력. 이 문서만으로 구현·배포 승인으로 간주하지 않는다. diff --git a/plan/mcp-research-security-checklist.md b/plan/mcp-research-security-checklist.md deleted file mode 100644 index d77c1a5ef..000000000 --- a/plan/mcp-research-security-checklist.md +++ /dev/null @@ -1,323 +0,0 @@ -# Remote Admin MCP 보안 체크리스트 — #370 설계 입력 - -- 조사일: 2026-08-08 / 이슈: `bottle-note/workspace#370` -- 관련: #340 Agent Key 완료, #341 감사 로그 open -- 근거: MCP Security Best Practices(Token Passthrough 금지·State Handle Hijacking), Authorization(OAuth 2.1 RS·RFC 8707 audience), `plan/mcp-research-spec-trends.md`, `plan/mcp-research-tool-catalog.md`, `plan/agent-key-token-exchange.md` -- 성격: 읽기 전용 설계 입력. 프로덕션 코드 변경 없음. 확정 요구사항 아님. -- 대상: 원격 Admin MCP (`https://mcp.bottlenote.com`, Streamable HTTP) - -## Summary - -| 영역 | 한 줄 결론 | 구현 위치(권장) | -|---|---|---| -| Agent Key 원문 | **로그·메트릭·트레이스·예외·응답에 절대 기록 금지**. DB는 SHA-256 해시만 (#340) | 필터/스크러버 + 감사 직렬화 | -| JWT 재사용·통과 | 클라이언트 Admin JWT **수신·전달 금지**. MCP 전용 자격만 수락, Admin JWT는 서버 내부 교환 | 인증 필터 + Admin API 클라이언트 | -| 툴 인가 | 인증(identity) ≠ 툴 권한. **툴별 scope** + 서버 가드(`confirm`, 건수 상한, 삭제 미등록) | 툴 디스패처 | -| 프롬프트 인젝션(쓰기) | description/annotation은 보안 경계가 **아님**. 쓰기 툴은 **서버 로직 + confirm + 감사**로 강제 | write 툴 핸들러 | -| 호출당 감사 | 툴 호출마다 고정 필드 세트 기록 (#341 연계). 민감값 마스킹 | 감사 인터셉터 | -| Rate limit | `mcp.bottlenote.com` 게이트웨이 + 앱 이중 제한. 쓰기 툴·presign·교환 API 우선 | Ingress/Gateway + Redis | - ---- - -## 1. Agent Key 원문 비노출 (MUST) - -### 1.1 정책 - -- 형식: `bn_agent_*` (에이전트당 활성 키 1개). -- 저장: **SHA-256 해시만** DB/`agents` 테이블. 원문은 `agent/api-keys.sops.yaml`(SOPS)에만 존재 (#340). -- 전송: `Authorization: Bearer bn_agent_...` 또는 OAuth client_credentials의 secret으로만. 쿼리스트링·툴 인자·본문에 키 금지. - -### 1.2 체크리스트 (구현 시 전부 통과) - -| # | 항목 | 검증 방법 | -|---|---|---| -| K1 | 액세스/앱/감사 로그에 `bn_agent_` 원문 0건 | 통합 테스트: 요청 후 로그 캡처 후 정규식 `bn_agent_[A-Za-z0-9_-]{8,}` 매칭 0 | -| K2 | 예외 메시지·`toString()`·Jackson 직렬화에 원문 없음 | 인증 실패 응답 body에 Bearer 값 미포함 | -| K3 | OTel span attribute / baggage에 원문 없음 | 스팬 덤프에서 `authorization`, `api_key`, `bn_agent_` 부재 | -| K4 | 메트릭 라벨에 키·토큰 값 금지 (agentId, outcome만) | Prometheus 라벨 화이트리스트 | -| K5 | 교환 실패 시 "invalid credentials" 단일 메시지 (키 일부 노출 금지) | 401 body 고정 문자열 | -| K6 | 디버그 로그 레벨에서도 헤더 덤프 금지 | `HttpLogging`/`CommonsRequestLoggingFilter` 비활성 또는 스크러빙 | -| K7 | 툴 응답·에러 payload에 키/JWT 미포함 | schema 단위 테스트 | -| K8 | CI 시크릿 스캔: 커밋·SQL·테스트 픽스처에 `bn_agent_` 원문 금지 | gitleaks / custom grep | - -### 1.3 허용 기록 형태 - -| 허용 | 금지 | -|---|---| -| `agentId` (예: `0001`~`0006`) | `bn_agent_xxxx...` 전체/부분 | -| 키 지문 앞 4자 + `***` (운영 디버그 한시, 기본 off) | Authorization 헤더 전체 | -| 해시 조회 성공/실패 boolean | JWT access/refresh 원문 | -| `keyVersion` / `rotatedAt` | SOPS 복호화 평문 로그 | - -### 1.4 스크러빙 규칙 (권장 구현) - -요청 로그·감사 인자 직렬화 직전 공통 스크러버: - -1. 헤더 `Authorization` → `[REDACTED]` -2. 값 정규식 `bn_agent_[A-Za-z0-9_-]+` → `[REDACTED_AGENT_KEY]` -3. JWT형 `eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+` → `[REDACTED_JWT]` -4. 필드명 화이트리스트 외 `password`, `secret`, `token`, `apiKey`, `api_key`, `refreshToken` → 마스킹 - ---- - -## 2. JWT 재사용·토큰 통과 금지 (MUST) - -스펙: MCP 서버는 **자기에게 발급된 토큰만** 수락. 다운스트림으로 클라이언트 토큰을 통과(passthrough)하면 안 된다. - -### 2.1 신뢰 경계 - -``` -[MCP Client] - | Bearer: MCP 전용 자격 (Agent Key 또는 audience=https://mcp.bottlenote.com JWT) - v -[MCP Server @ mcp.bottlenote.com] --(내부)--> Agent Key로 Admin JWT 교환 (#340) - | Bearer: Admin JWT (서버 메모리/짧은 TTL 캐시, 클라이언트 미노출) - v -[Admin API] -``` - -### 2.2 체크리스트 - -| # | 항목 | 동작 | -|---|---|---| -| J1 | 클라이언트가 보낸 `admin` audience JWT 거부 | 401. audience MUST = MCP 리소스 (`https://mcp.bottlenote.com` 또는 합의 canonical URI) | -| J2 | 클라이언트가 보낸 Product/Admin 사람 JWT 거부 | 동일 401. "토큰 통과 프록시" 툴 미등록 (카탈로그 NEVER) | -| J3 | Admin API 호출용 JWT는 **MCP 서버만** #340 교환으로 획득 | 교환 엔드포인트는 MCP 내부 전용 네트워크 또는 서비스 계정 | -| J4 | 교환으로 얻은 Admin JWT를 클라이언트 응답/툴 결과에 넣지 않음 | 응답 스키마에 token 필드 없음 | -| J5 | 교환 JWT 캐시 시 Redis 키 = `agentId` 바인딩, TTL ≤ access token 잔여, 평문 로그 금지 | 다중 파드 전제(로컬 static 금지) | -| J6 | 탈취 대비: access token 짧은 TTL + 키 로테이션 절차 | 키 유출 시 해시 교체 + 기존 캐시 무효 | -| J7 | (안 A 채택 시) RFC 8707 `resource` / aud 검증 필수 | `aud` 불일치 즉시 401 | -| J8 | 토큰을 URI 쿼리에 싣지 않음 | 로깅 프록시 유출 방지 | - -### 2.3 안 A vs 안 B (인증 표면) - -| | 안 A OAuth RS + client_credentials | 안 B 정적 Bearer Agent Key | -|---|---|---| -| 클라이언트 제시 | MCP audience JWT | `bn_agent_*` | -| 스펙 정합 | 높음 (PRM·WWW-Authenticate) | 내부 전용 실용 | -| 공통 필수 | **Admin JWT 통과 금지**, 키 원문 비로그, 툴 인가·감사 | 동일 | - ---- - -## 3. 툴 단위 인가 (Tool Auth) (MUST) - -### 3.1 원칙 - -- **연결 인증 성공 ≠ 모든 툴 허용.** -- annotation(`readOnlyHint` 등)은 UX 힌트일 뿐 **인가 결정에 사용 금지**. -- 스코프는 서버 카탈로그 기준으로 최소화. 옴니버스 `admin:*` 금지. - -### 3.2 권장 scope 맵 (카탈로그 13툴 기준) - -| scope | 허용 툴 | -|---|---| -| `admin:read` | search/get/lookup, category reference, distillery/region list·get, tasting_tag_list, whisky_preview_diff | -| `admin:whisky:write` | whisky_create, whisky_update (+ read 포함 권장) | -| `admin:image:presign` | image_presign | - -- 초기 에이전트 프로필: 기본 `admin:read`만 부여 → 쓰기 필요 시 별도 에이전트 또는 step-up. -- insufficient_scope 시 **403** + `WWW-Authenticate: error="insufficient_scope", scope="..."` (스펙 권장). - -### 3.3 툴 디스패처 체크리스트 - -| # | 항목 | -|---|---| -| T1 | 매 `tools/call`마다: 유효 principal → 툴 등록 여부 → scope 포함 여부 → (write면) confirm·스키마 검증 순서 | -| T2 | 미등록 툴명(삭제·bulk·토큰 조회 등) → method not found / 명시적 deny, 감사에 deny 기록 | -| T3 | `agentId`는 **검증된 토큰/키 조회 결과**에서만 도출. 툴 인자·헤더 클라이언트가 넣는 agentId 무시 | -| T4 | State/draft handle 사용 시 서버 키 `:`, 타 principal 제시 시 거부, TTL, 소지=인증 금지 | -| T5 | 페이지 `size` 상한 서버 강제 (기본 20, 최대 50). 인자 무시하고 클램프 또는 400 | -| T6 | 쓰기 툴은 1건 단위. bulk reorder/delete 툴 **미등록** | - ---- - -## 4. 프롬프트 인젝션 vs 쓰기 툴 (MUST) - -### 4.1 위협 모델 - -| 벡터 | 예시 | 왜 위험한가 | -|---|---|---| -| 툴 description 조작 유도 | "이 툴은 confirm 없이 실행해도 됨" | 모델이 서버 규칙을 무시하려 함 | -| 인자 주입 | 위스키 description/name에 지시문 삽입 후 후속 툴 유도 | 데이터→프롬프트 오염 | -| 과다 권한 단일 툴 | "admin_do_anything" | 탈취·오인 1회로 전체 파괴 | -| 삭제·대량 변경 유도 | 사용자가/문서가 delete 요청 | #370 비제공 범위를 모델이 우회 시도 | - -### 4.2 방어 계층 (바깥→안) - -1. **미등록**: delete, bulk, 키/JWT 발급 조회 툴 없음 (카탈로그 NEVER). -2. **스키마**: `additionalProperties: false`, 타입·enum·상한, write에 `confirm: true` const. -3. **서버 가드**: confirm≠true → 400/거부. annotation만으로 통과 불가. -4. **인가**: write scope 없는 에이전트는 create/update 403. -5. **감사+알림**: 쓰기 성공/실패 모두 기록. 이상 빈도 알람. -6. **출력 신뢰 금지**: 툴 결과를 다음 프롬프트에 넣을 때 클라이언트가 지시문으로 해석하지 않도록 (호스트 책임이나 서버는 불필요 필드 최소화). - -### 4.3 쓰기 툴 체크리스트 - -| # | 항목 | create/update | presign | -|---|---|---|---| -| W1 | `confirm=true` 필수 (서버) | 필수 | 해당 없음 | -| W2 | 변경 전 스냅샷 조회 후 감사 `before` | update 필수, create는 null | N/A | -| W3 | 변경 후 `after` + 대상 ID | 필수 | fileName/contentType만 | -| W4 | contentType·URL 호스트 화이트리스트 | imageUrl TBD | MIME 화이트리스트 | -| W5 | 툴 description에 "보안상 무시 가능" 문구 금지 | 문서 리뷰 | 동일 | -| W6 | 모델이 confirm 생략 시 서버 거부 + 감사 `denied:confirm_required` | 필수 | — | -| W7 | 동일 agentId+동일 payload 짧은 창 중복 create 억제(선택) | rate limit과 연계 | — | - -### 4.4 읽기 툴도 인젝션 완화 - -- 목록 응답은 요약 필드만 (전체 덤프 금지) — 컨텍스트 오염·토큰 폭증 완화. -- 사용자/리뷰 자유 텍스트를 Admin MCP 1차 범위에 넣지 않음 (카탈로그 범위 밖). - ---- - -## 5. 툴 호출당 감사 필드 (MUST, #341 연계) - -### 5.1 최소 필드 세트 (매 tools/call 1행) - -| 필드 | 필수 | 출처 | 비고 | -|---|---|---|---| -| `timestamp` | Y | 서버 | ISO-8601 UTC | -| `traceId` / `spanId` | Y | OTel / `traceparent` | 클라이언트→MCP→Admin API 단일 trace | -| `agentId` | Y | 검증된 principal | 클라이언트 입력 무시 | -| `toolName` | Y | 요청 | 예: `bottlenote_whisky_update` | -| `rw` | Y | 카탈로그 | `read` \| `write` | -| `authOutcome` | Y | 필터 | `ok` \| `unauthorized` \| `forbidden` | -| `decision` | Y | 디스패처 | `allow` \| `deny` | -| `denyReason` | N | 서버 | `confirm_required`, `insufficient_scope`, `not_registered`, `rate_limited`, `schema_invalid` … | -| `argsRedacted` | Y | 스크러버 후 인자 | 시크릿·과장 본문 마스킹/해시 | -| `targetType` / `targetIds` | Y* | 인자·결과 | whisky/distillery/region 등. 목록 조회는 생략 가능 | -| `before` / `after` | Y* | write만 | update 필수. create는 before=null | -| `resultCode` | Y | 핸들러 | 성공/도메인에러/5xx 구분 | -| `durationMs` | Y | 인터셉터 | | -| `clientIp` | Y | 신뢰 XFF (게이트웨이 재작성 전제) | | -| `protocolVersion` | N | 요청 `_meta`/헤더 | | -| `mcpMethod` | N | `tools/call` 등 | 게이트웨이 정책 승격용 | -| `requestId` | N | 서버 생성 UUID | 멱등·지원용 | - -\* write 또는 단건 get에서 필수에 가깝게 취급. - -### 5.2 기록 금지 - -- Agent Key 원문, Admin/Product JWT, refresh token -- S3 presigned URL 쿼리 시그니처 전체 (path·bucket·만료만) -- 불필요 PII 확대 수집 (1차 카탈로그에 user/review 없음) - -### 5.3 운영 - -- 보관: 최소 **90일** 권장 (CSA agentic MCP 가이드 수준). 조직 정책에 맞춤. -- 전송: 중앙 SIEM/로그 파이프. 앱 로컬 디스크만으로 끝내지 않음. -- #341: MCP 전용 테이블 신설보다 **기존 감사 모델에 주체 차원=에이전트** 추가 우선. -- 시맨틱: OTel GenAI/`gen_ai.tool.name`, `mcp.server.name=bottlenote-admin-mcp`. - -### 5.4 감사 체크리스트 - -| # | 항목 | -|---|---| -| A1 | 성공·실패·deny 모두 1레코드 (실패 시 무로그 금지) | -| A2 | write 누락 `before`/`after` 시 배포 게이트 실패 (테스트) | -| A3 | 스크러버 유닛 테스트: 키·JWT 샘플 입력 → 출력에 원문 0 | -| A4 | traceId 없으면 서버가 생성해 응답/로그에 상관 ID 유지 | -| A5 | 감사 기록 실패 시 write는 **실패 처리**(감사 없는 변경 금지) 또는 동기 outbox — 정책 확정 필요 | - ---- - -## 6. Rate limit — `mcp.bottlenote.com` (MUST) - -다중 인스턴스 전제 → **Redis(또는 동등 공유 저장소)** 토큰 버킷. JVM 로컬 카운터 금지. - -### 6.1 계층 - -| 계층 | 대상 | 목적 | -|---|---|---| -| L1 게이트웨이/Ingress | IP, TLS 종료 호스트 | 볼류메트릭 DDoS·비인증 폭주 | -| L2 앱 (인증 후) | `agentId` + 툴 클래스 | 에이전트 오남용·인젝션 루프 | -| L3 다운스트림 | Admin API·S3 presign 기존 한도 | 폭주 전파 차단 | - -### 6.2 권장 초기 한도 (조정 가능 수치, 설계 출발점) - -| 키 | 한도 | 비고 | -|---|---|---| -| 비인증 IP → `/mcp` | 30 req/min | 401 폭풍 완화 | -| agentId 전체 | 120 req/min | 읽기 위주 에이전트 | -| agentId + read 툴 | 100 req/min | | -| agentId + write 툴 | **10 req/min**, burst 3 | create/update | -| agentId + `image_presign` | **20 req/min** | 남용 업로드 URL 발급 | -| agentId + 토큰 교환 | **5 req/min** | 키 스터핑 완화 | -| 전역 write (클러스터) | 60 req/min | 사고 시 상한 | - -초과 시: **HTTP 429** + `Retry-After`. 감사 `denyReason=rate_limited`. 본문에 키/토큰 미포함. - -### 6.3 체크리스트 - -| # | 항목 | -|---|---| -| R1 | 한도 키에 원문 키/JWT 사용 금지 → `agentId` 또는 IP 해시 | -| R2 | sticky session 없이 동작 (stateless MCP + Redis) | -| R3 | write·presign·교환이 read보다 엄격 | -| R4 | 429도 감사·메트릭 기록 (`mcp_rate_limited_total{tool,agentId}`) | -| R5 | 운영 런북: 특정 agentId 즉시 차단(킬 스위치) Redis flag | -| R6 | Streamable HTTP 장기 스트림: 게이트웨이가 바디 버퍼로 타임아웃 내지 않는지 인프라 확인 (스펙 트렌드 미확인 항목) | - ---- - -## 7. 배포·네트워크 가드 (SHOULD) - -| # | 항목 | -|---|---| -| N1 | 외부 노출은 `https://mcp.bottlenote.com` only. Admin API는 클러스터 내부 | -| N2 | TLS 필수. HSTS 게이트웨이 | -| N3 | CORS: 브라우저 일반 사용 없다면 최소/비허용. 자격 쿠키 사용 안 함 (`Bearer` only) | -| N4 | 헬스/ready는 인증 없이 가능하되 내부 정보·키 미노출 | -| N5 | 의존 Admin API·교환 API 타임아웃·재시도 상한 (재시도 폭풍=쓰기 중복 주의, idempotency 키 검토) | - ---- - -## 8. 구현 전 Go / No-Go 게이트 - -배포 전 아래가 모두 문서·테스트로 증명되어야 한다. - -1. [ ] Agent Key 원문이 로그/트레이스/응답/커밋에 0건 -2. [ ] 클라이언트 Admin JWT 제시 → 401, Admin API로 전달 0건 -3. [ ] 삭제·bulk·키 조회 툴 미등록 + 호출 시도 deny 감사 -4. [ ] write 툴 confirm 없이 호출 → 거부 + 감사 -5. [ ] 툴 호출 1회 = 감사 1행 (5.1 필드) -6. [ ] write 시 before/after 존재 -7. [ ] agentId·write 클래스 rate limit + 429 -8. [ ] scope 없는 write → 403 insufficient_scope -9. [ ] handle 사용 시 agent 바인딩 검증 (해당 시) -10. [ ] 다중 파드에서 한도·JWT 캐시 공유 저장소 사용 - ---- - -## 9. Anti-patterns (즉시 거부) - -- 클라이언트가 준 JWT를 Admin API Authorization에 그대로 설정 -- `log.debug(request.headers)` 무스크러빙 -- 툴 description/“AI 안전 수칙”만으로 삭제 방지 -- `admin:*` 단일 스코프 -- 로컬 static Map rate limit (다중 파드에서 무력) -- 감사 실패를 삼키고 write 성공 처리 -- 테스트 fixture에 실키 `bn_agent_` 커밋 -- handle 소지자 = 인증된 사용자로 간주 - ---- - -## 10. 후속·미확정 - -| 항목 | 상태 | -|---|---| -| 안 A(OAuth RS) vs 안 B(정적 Bearer) 최종 선택 | 스펙 트렌드 문서 권장 A, 비용 시 B | -| 감사 기록 실패 시 write 트랜잭션 정책 | #341과 합의 필요 | -| imageUrl 허용 호스트 화이트리스트 | TBD | -| 게이트웨이 Streamable HTTP 스트리밍 버퍼링 | 인프라 확인 | -| 사람 관리자 SSO(EMA) | 에이전트 수 적을 때 비우선 | - ---- - -## Source links - -- [MCP Security Best Practices (Token Passthrough, State Handle)](https://modelcontextprotocol.io/specification/draft/basic/security_best_practices) -- [MCP Authorization (OAuth 2.1 RS, audience, no transit tokens)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) -- [CSA Agentic MCP Security Best Practices](https://labs.cloudsecurityalliance.org/agentic/agentic-mcp-security-best-practices-v1/) -- [OWASP MCP Top 10](https://owasp.org/www-project-mcp-top-10/) -- 내부: `plan/mcp-research-spec-trends.md`, `plan/mcp-research-tool-catalog.md`, `plan/agent-key-token-exchange.md` diff --git a/plan/mcp-research-spec-trends.md b/plan/mcp-research-spec-trends.md deleted file mode 100644 index fb388cb06..000000000 --- a/plan/mcp-research-spec-trends.md +++ /dev/null @@ -1,117 +0,0 @@ -# MCP 스펙·트렌드 리서치 (2025-2026) — Admin MCP #370 설계 입력 - -- 조사일: 2026-08-07 / 대상 이슈: `bottle-note/workspace#370` -- 성격: 읽기 전용 리서치. 코드 변경 없음. 결론은 설계 판단의 근거이며 확정된 요구사항이 아니다. - -## Summary - -- **최신 스펙은 `2026-07-28`** (2026-07-28 릴리스, 직전은 `2025-11-25`). 출범 이후 최대 개정이며 **프로토콜이 stateless로 전환**됐다. - - `initialize`/`notifications/initialized` 핸드셰이크 제거, `Mcp-Session-Id` 헤더 제거. 매 요청이 `_meta`에 프로토콜 버전과 클라이언트 capability를 싣는다 (SEP-2567, SEP-2575). - - `server/discover` RPC 신설(서버 MUST 구현), 서버 간 sticky session 불필요 → 평범한 round-robin LB 뒤에서 다중 파드 운영 가능. - - `tools/list` 등 목록 응답에 `ttlMs`/`cacheScope` 필수(SEP-2549), `Mcp-Method`/`Mcp-Name` 요청 헤더 필수(SEP-2243) → 게이트웨이가 body 파싱 없이 라우팅·인가·레이트리밋 가능. - - Roots / Sampling / Logging **deprecated**(SEP-2577, 최소 12개월 유예). HTTP+SSE 레거시 트랜스포트도 Deprecated로 재분류(SEP-2596). - - 서버 주도 요청(sampling/elicitation/roots)은 **MRTR 패턴**으로 교체: 서버가 `resultType: "input_required"` 반환 → 클라이언트가 재시도에 답을 실어 보냄(SEP-2322). -- **그런데 Java 생태계는 아직 `2025-11-25`에 머물러 있다.** MCP Java SDK 최신 GA는 **v2.0.0 (2026-06-11)**, 명시적으로 `2025-11-25` 스펙을 트래킹한다. 2026-07-28 대응 릴리스는 조사 시점(2026-08-07) 기준 없음. 공식 Tier 1 SDK는 TS/Python/Go/C#이고 Java는 여기에 없다. -- **결론(#370 관점)**: 지금 착수하면 **와이어는 `2025-11-25`(Java SDK v2.0.0 + Spring AI 2.x)로 구현하되, 설계는 stateless 전제로** 해야 한다. 세션에 의존하는 서버 상태를 만들면 2026-07-28 마이그레이션 때 통째로 다시 짜야 한다. 이건 이 저장소의 "인스턴스는 다중이다" 제약과도 정확히 일치한다. -- 인증은 스펙상 OAuth 2.1 리소스 서버가 정도(正道)지만, #370의 Agent Key(`bn_agent_*`, SHA-256 해시 저장, 에이전트당 1개)는 사실상 client_credentials용 정적 시크릿이다. 아래 "Must-adopt" 3번에서 두 안을 비교한다. - -## Must-adopt for #370 - -### 1. Stateless 서버로 설계 (최우선) - -- MCP 세션/커넥션에 어떤 서버 상태도 붙이지 않는다. 툴 호출 간 상태가 필요하면 **서버가 발급한 명시적 handle을 툴 인자로 주고받는다**(스펙이 지정한 유일한 방식). -- Java SDK의 `McpStatelessSyncServer` / Spring AI `mcp-stateless-server-boot-starter` 계열을 기본값으로 잡는다. Spring AI에서 Streamable HTTP는 `spring.ai.mcp.server.protocol=STREAMABLE`, 엔드포인트 기본 `/mcp`. -- 트랜스포트는 **Streamable HTTP만**. 레거시 HTTP+SSE는 채택하지 않는다(주요 클라이언트가 2026 중반 sunset 공지, 스펙상 Deprecated). -- k8s 다중 파드 전제이므로 sticky session 설정을 만들지 않는다. 상태가 필요하면 Redis. - -### 2. State handle 하이재킹 방어 (변경 전후 검증 워크플로에 직결) - -- #370의 "변경 전후 데이터 조회 및 검증"은 draft/change-set handle을 낳기 쉽다. 스펙 보안 문서가 이걸 **State Handle Hijacking** 공격면으로 명시한다. -- 필수: handle은 `SecureRandom` 기반 비순차 값, **서버 측에서 `:` 형태로 인증 주체에 바인딩**, 다른 principal이 제시하면 거부, TTL 만료. **handle 소지를 인증으로 취급 금지.** - -### 3. 인증: Agent Key를 어떻게 노출할 것인가 - -스펙 요구(HTTP 트랜스포트에서 authorization을 지원한다면): MCP 서버는 OAuth 2.1 리소스 서버로 동작하고 **RFC 9728 Protected Resource Metadata를 MUST 구현**, 토큰의 **audience가 자기 자신인지 MUST 검증**, 401에 `WWW-Authenticate: Bearer resource_metadata=..., scope=...`를 실어야 한다. - -- **안 A (권장) — OAuth2 리소스 서버 + client_credentials**: Agent Key를 `client_id/client_secret`으로 매핑해 자체 AS(또는 admin-api)가 audience `https://mcp.bottlenote.com`인 JWT를 발급. `spring-ai-community/mcp-security`의 `McpServerOAuth2Configurer` + `spring-boot-starter-oauth2-resource-server`로 붙는다. 범용 MCP 클라이언트 호환성과 감사 추적이 가장 좋다. -- **안 B (최소 비용) — 정적 Bearer**: `Authorization: Bearer bn_agent_...`를 그대로 받고 해시 조회로 검증. 클라이언트에 수동 헤더 설정이 필요하고, OAuth 디스커버리를 요구하는 클라이언트와는 붙지 않는다. 내부 소수 에이전트 한정이면 실용적. -- 어느 안이든 **토큰 통과(token passthrough) 금지**: 클라이언트가 보낸 Admin JWT를 그대로 받아 Admin API로 넘기면 안 된다. MCP 서버는 자기 앞으로 발급된 자격만 받고, Admin JWT는 **서버가 Agent Key로 직접 교환해 내부에서만** 쓴다. #370 설계가 이미 이 형태이므로 유지하면 된다. -- 스코프 최소화: `scopes_supported`에 전체 카탈로그를 싣지 말고 읽기 기본(`admin:read`) → 쓰기(`admin:whisky:write`)를 `insufficient_scope` 403 챌린지로 승격시킨다. -- 참고 수치: 2026-05 기준 원격 MCP 서버 중 OAuth 2.1을 실제 구현한 비율은 **8.5%**. 즉 여기서 제대로 하면 상위 10%다. - -### 4. 툴 설계 — 파괴적 동작은 서버가 막는다 - -- **명명**: `bottlenote_whisky_search`처럼 `{서비스}_{도메인}_{동작}` 스네이크. 클라이언트가 여러 MCP를 동시에 물기 때문에 접두사가 충돌 방지가 된다. -- **파라미터 8개 이하**, 멀티 목적 툴은 쪼갠다. `inputSchema`는 JSON Schema 2020-12. 날짜/enum은 포맷을 명시하지 않으면 모델이 추측한다 — ISO-8601, enum 값 목록을 스키마에 박는다. -- **annotation**: 조회 툴에 `readOnlyHint: true`, 수정 툴에 `destructiveHint`/`idempotentHint`를 정확히 단다. 단 **annotation은 클라이언트 UX 힌트일 뿐 보안 경계가 아니다**(스펙: 서버 어노테이션은 신뢰 대상 아님). -- **#370의 "비제공" 항목은 툴 설명이 아니라 서버 로직으로 강제한다**: 자동 삭제 툴은 아예 등록하지 않음, 대량 수정은 건수 상한 + 승인 handle 없으면 거부. 프롬프트로 막는 것은 방어가 아니다. -- **페이지네이션**: 조회 툴은 반드시 커서 기반 + 상한(예: 기본 20, 최대 50). 프로젝트의 `PageResponse`/`CursorPageable`을 그대로 매핑한다. 응답에 위스키 전체 필드를 덤프하지 말고 목록/상세를 분리한다. -- **툴 개수 예산**: 툴 정의 1개가 100~500 토큰. 58개 툴 세팅이 55K 토큰을 먹은 측정치가 있다. #370 범위는 조회 7~9 + 변경 4~6 정도로 **15개 내외에 묶는다**. 넘어가면 클라이언트가 tool search로 lazy-load하기 시작해 발견율이 떨어진다. -- **`tools/list`는 결정적 순서로 반환**(2026-07-28 SHOULD). LLM 프롬프트 캐시 적중률에 직접 영향. - -### 5. 감사 로그·관측 (이슈 완료 기준에 포함된 항목) - -- MCP의 `logging` 기능은 deprecated다. **OpenTelemetry로 간다.** 2026-07-28은 `_meta`의 `traceparent`/`tracestate`/`baggage` 전파 규약을 문서화했다(SEP-414) — 클라이언트 trace가 MCP 서버를 지나 Admin API까지 한 trace로 이어진다. -- 툴 호출당 감사 레코드에 최소: `agentId`(검증된 토큰에서 도출, 클라이언트 입력 금지), 툴 이름, 인자(민감값 마스킹), 대상 리소스 ID, **변경 전/후 스냅샷**, 결과 코드, traceId, 소요시간. -- 시맨틱 컨벤션은 OTel GenAI 규약(`gen_ai.tool.name`, `mcp.server.name`)에 맞춘다. -- 로그에 Agent Key 원문·JWT가 절대 남지 않도록 스크러빙. `agent/api-keys.sops.yaml` 정책과 일관되게. -- 이 저장소는 `#341` 감사 로그 모델과 연계하도록 되어 있으므로, MCP 전용 로그를 새로 만들지 말고 기존 감사 모델에 "요청 주체 = 에이전트" 차원을 추가하는 방향이 맞다. - -### 6. Java/Spring 스택 선택 - -| 옵션 | 상태 (2026-08) | 판단 | -|---|---|---| -| **MCP Java SDK v2.0.0** (`io.modelcontextprotocol.sdk:mcp`) | GA 2026-06-11, `2025-11-25` 트래킹. STDIO/SSE/Streamable HTTP 내장, 웹 프레임워크 불필요 | 저수준 제어가 필요할 때. Spring AI가 이걸 감쌈 | -| **Spring AI MCP Server Boot Starter** (`spring-ai-starter-mcp-server-webmvc`) | Spring AI 2.x. `@McpTool`/`@McpToolParam` 어노테이션 API가 코어에 편입 | **권장.** 이 저장소가 Spring Boot 3.4.11/Java 21이므로 Spring AI 버전이 요구하는 Boot 하한만 확인하면 됨 | -| **spring-ai-community/mcp-security** | `McpServerOAuth2Configurer` + 자동설정. PRM/DCR/SSRF 가드 포함 | 안 A 채택 시 필수 | -| Quarkus MCP (quarkus-mcp-server) | 성숙하지만 런타임이 다름 | **부적합** — 이 저장소는 Spring 단일 스택 | - -- **버전 리스크를 plan 문서에 Assumption으로 못 박을 것**: "Java SDK는 `2026-07-28`을 아직 지원하지 않으며, 지원 릴리스가 나오면 트랜스포트 계층만 교체한다." 이 가정이 깨지면(예: 클라이언트가 2026-07-28만 요구) 재개봉 대상이다. - -## Nice-to-have - -- **`ttlMs`/`cacheScope`**: 2026-07-28 필수 필드. 지금 SDK가 안 내보내도, 툴 카탈로그를 "정적이며 캐시 가능"하게 설계해두면 나중에 값만 채우면 된다. Admin 툴 목록은 배포 단위로만 바뀌므로 긴 TTL이 가능하다. -- **`Mcp-Method`/`Mcp-Name` 헤더 기반 게이트웨이 정책**: 앞단에서 body 파싱 없이 "쓰기 툴은 특정 에이전트만" 같은 정책을 걸 수 있다. 지금은 애플리케이션 레이어에서 같은 판정을 하되, 판정 로직을 툴 이름 기준으로 짜두면 나중에 게이트웨이로 승격 가능. -- **EMA (Enterprise-Managed Authorization) 확장**: 2026-06-18 stable, Anthropic/Microsoft/Okta 채택. 조직 IdP SSO로 MCP 접근을 통제한다. Bottle Note는 에이전트 수가 적어 지금은 과하지만, 사람 관리자가 MCP를 직접 쓰게 되면 재검토 대상. -- **MCP Registry 등재**: 공식 레지스트리에 2026-05 기준 9,652개 서버 등록. Admin MCP는 비공개라 등재 대상이 아니지만, 사내 카탈로그를 같은 스키마로 두면 나중에 확장이 쉽다. -- **Tasks 확장** (`io.modelcontextprotocol/tasks`): 이미지 업로드·대량 검증처럼 오래 걸리는 작업을 폴링형 task handle로 넘기는 공식 확장. 지금은 동기 처리로 충분하지만 이미지 업로드가 커지면 후보. - -## Avoid / anti-patterns - -- **레거시 HTTP+SSE 트랜스포트 채택** — Deprecated. 새로 만들면서 이걸 고를 이유가 없다. -- **세션 기반 상태**(`Mcp-Session-Id`, 커넥션 스코프 캐시, static 필드) — 스펙에서 제거됐고, 이 저장소의 다중 인스턴스 제약과도 정면 충돌. -- **토큰 통과** — 클라이언트가 준 토큰을 검증 없이 Admin API로 전달. 스펙이 명시적으로 금지. audience 검증 없이 토큰을 받는 것도 같은 범주. -- **파괴적 동작을 툴 description/annotation으로만 막기** — 서버 어노테이션은 신뢰 대상이 아니다. "승인 없는 대량 수정 금지"는 서버 코드의 건수 상한과 승인 handle로 강제한다. -- **옴니버스 스코프**(`admin:*`, `full-access`) — 탈취 시 폭발 반경이 전체가 되고 감사 로그에서 의도를 구분할 수 없다. -- **전체 카탈로그 툴 폭증** — Admin API 엔드포인트를 1:1로 툴에 매핑하는 것. 토큰 예산이 먼저 터지고 모델의 툴 선택 정확도가 떨어진다. 업무 단위로 묶는다. -- **인자 없는 무제한 조회 툴** — 페이징/상한 없는 `list_all_whiskies`류. 컨텍스트를 날린다. -- **Roots / Sampling / Logging 신규 채택** — 전부 deprecated. MCP 서버가 LLM 호출이 필요하면 provider API를 직접 쓴다. -- **외부 조사 기능을 슬쩍 넣기** — #370이 명시적으로 배제한 범위(웹 검색, Whiskybase 탐색, 출처 신뢰도 판단, 테이스팅 태그 자동 생성). 별도 리서치 MCP의 몫이다. - -## 미확인 / 후속 확인 필요 - -- Java SDK / Spring AI의 `2026-07-28` 지원 릴리스 일정 — 조사 시점에 공개 로드맵 없음. 착수 전 java-sdk 릴리스 노트 재확인 권장. -- Spring AI 2.x가 요구하는 Spring Boot 최소 버전과 현재 3.4.11의 호환성 — 실제 의존성 해석으로 검증 필요. -- `mcp.bottlenote.com` 앞단 게이트웨이가 Streamable HTTP의 장기 응답 스트림(POST 응답 스트리밍)을 버퍼링 없이 통과시키는지 — 인프라 확인 항목. - -## Source links - -- [MCP Specification (latest, 2026-07-28)](https://modelcontextprotocol.io/specification/latest) -- [Key Changes — 2026-07-28 changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog) -- [The 2026-07-28 Specification (blog)](https://blog.modelcontextprotocol.io/posts/2026-07-28/) -- [Authorization — 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization) -- [Security Best Practices — 2026-07-28](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices) -- [Enterprise-Managed Authorization: Zero-touch OAuth for MCP](https://blog.modelcontextprotocol.io/posts/enterprise-managed-auth/) -- [The 2026 MCP Roadmap](https://blog.modelcontextprotocol.io/posts/2026-mcp-roadmap/) -- [MCP Java SDK — releases (v2.0.0, 2026-06-11)](https://github.com/modelcontextprotocol/java-sdk/releases) -- [MCP Java SDK — Server docs](https://java.sdk.modelcontextprotocol.io/latest/server/) -- [Spring AI — MCP overview](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html) -- [Spring AI — Streamable-HTTP MCP Servers](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-streamable-http-server-boot-starter-docs.html) -- [Spring AI — MCP Security](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-security.html) -- [spring-ai-community/mcp-security](https://github.com/spring-ai-community/mcp-security) -- [AWS — MCP tool design: practical approaches and tradeoffs](https://aws.amazon.com/blogs/machine-learning/mcp-tool-design-practical-approaches-and-tradeoffs/) -- [MCP Tool Schema Bloat: The Hidden Token Tax](https://layered.dev/mcp-tool-schema-bloat-the-hidden-token-tax-and-how-to-fix-it/) -- [Progressive Tool Discovery for Token Efficiency (discussion #1923)](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/1923) -- [MCP Observability: From Tool Call to Full-Stack Trace](https://www.groundcover.com/blog/mcp-spec-update-2026-07-28) -- [MCP Observability — monitoring AI agent tool access (Obot)](https://obot.ai/blog/mcp-observability-how-to-monitor-ai-agent-activity-in-the-enterprise/) -- [MCP Ecosystem H1 2026 Retrospective: Adoption Data Points](https://www.digitalapplied.com/blog/mcp-ecosystem-h1-2026-retrospective-adoption-data-points) diff --git a/plan/mcp-research-tool-catalog.md b/plan/mcp-research-tool-catalog.md deleted file mode 100644 index a4e4c59ec..000000000 --- a/plan/mcp-research-tool-catalog.md +++ /dev/null @@ -1,139 +0,0 @@ -# MCP Tool Catalog 리서치 — Admin MCP #370 - -- 조사일: 2026-08-08 / 이슈: `bottle-note/workspace#370` -- 관련: #340 Agent Key 완료, #341 감사 로그 open -- 근거: Admin API 컨트롤러·DTO 실측 + `plan/mcp-research-spec-trends.md` -- 성격: 읽기 전용 설계 입력. 프로덕션 코드 변경 없음. 확정 요구사항 아님. - -## Summary - -- 예산: **등록 툴 13개** (조회 8 + 변경 5). 스펙 트렌드 권장 15 이내. -- 명명: `bottlenote_{domain}_{action}` 스네이크. 접두사로 다중 MCP 충돌 방지. -- 인증: Agent Key(`bn_agent_*`) → 서버 내부 Admin JWT 교환(#340). 클라이언트 토큰 통과 금지. -- 파괴 정책: **삭제 툴 미등록**. 생성/수정은 서버 가드 + 클라이언트 `destructiveHint`/확인 UX. -- 페이지: 조회 기본 20·최대 50. 무제한 list 금지. `inputSchema`는 JSON Schema 2020-12 축약. -- 감사: 툴 호출마다 agentId·툴명·대상 ID·변경 전/후·traceId (#341 연계 예정). - -## 공통 규칙 - -| 항목 | 규칙 | -|---|---| -| R/W | `read` = GET 계열, `write` = POST/PUT/PATCH | -| confirm | 파괴·비가역·다건 변경 시 `confirm=true` 필수(서버 거부). annotation만으로 막지 않음 | -| hints | read: `readOnlyHint`; write: `destructiveHint`/`idempotentHint` (UX 힌트, 보안 경계 아님) | -| 파라미터 | 툴당 8개 이하. enum·ISO 날짜 스키마 명시 | -| handle | draft/승인 handle 사용 시 `:` 바인딩·TTL. 소지=인증 금지 | - -## 등록 카탈로그 (13) - -### A. 조회 (read, confirm 불필요) - -| # | tool | inputSchema (필수·주요) | Admin API | 비고 | -|---|---|---|---|---| -| 1 | `bottlenote_whisky_search` | `keyword?`, `category?` enum, `regionId?`, `sortType?`, `sortOrder?`, `page?`≥0, `size?`1–50, `includeDeleted?` | `GET /alcohols` | 목록 요약만 | -| 2 | `bottlenote_whisky_get` | `alcoholId` integer **req** | `GET /alcohols/{alcoholId}` | 상세 전 필드 | -| 3 | `bottlenote_whisky_lookup` | `keyword?`, `category?`, `regionId?`, `distilleryId?`, `cursor?`≥0, `pageSize?`1–50 | `GET /alcohols/lookup` | 커서 검색 | -| 4 | `bottlenote_category_reference_get` | _(없음)_ | `GET /alcohols/categories/reference` | 생성 전 참조 | -| 5 | `bottlenote_distillery_list` | `keyword?`, `page?`, `size?`1–50 | `GET /distilleries` | 목록 | -| 6 | `bottlenote_distillery_get` | `distilleryId` **req** | `GET /distilleries/{id}` | 상세 | -| 7 | `bottlenote_region_list` | `keyword?`, `page?`, `size?`1–50 | `GET /regions` | 계층 포함 시 요약 | -| 8 | `bottlenote_region_get` | `regionId` **req** | `GET /regions/{id}` | 상세 | - -### B. 변경 (write) - -| # | tool | inputSchema | R/W · confirm | Admin API | 비고 | -|---|---|---|---|---|---| -| 9 | `bottlenote_whisky_create` | `korName`, `engName`, `abv`, `type`, `korCategory`, `engCategory`, `categoryGroup`, `regionId`, `distilleryId`, `age`, `cask`, `imageUrl`, `description`, `volume`, `tastingTagIds?` + `confirm` **req true** | write · **confirm** | `POST /alcohols` | Upsert DTO 1:1. 파라미터 8초과 → 구현 시 nested `payload` object 1개로 축소 권장 | -| 10 | `bottlenote_whisky_update` | `alcoholId` **req** + create와 동일 본문 + `confirm` **req true** | write · **confirm** | `PUT /alcohols/{id}` | 호출 전 서버가 현재 스냅샷 조회해 감사 로그에 before 기록 | -| 11 | `bottlenote_image_presign` | `fileName` **req**, `contentType` **req** enum 이미지 MIME | write · 불필요 | `GET /s3/presign-url` | URL만 발급. 바이너리 업로드는 MCP 밖(클라이언트→S3) | -| 12 | `bottlenote_tasting_tag_list` | `keyword?`, `page?`, `size?`1–50 | read · 불필요 | `GET /tasting-tags` | 위스키 태깅 참조 | -| 13 | `bottlenote_whisky_preview_diff` | `alcoholId` **req**, `payload` object(변경 후보 필드) | read · 불필요 | **TBD** (로컬 get+diff 또는 전용 preview API) | 변경 전후 검증 워크플로. 서버 state handle 불필요(무상태 diff) | - -> 9·10 스키마 축소안: `payload: AdminAlcoholUpsert` 단일 object + `alcoholId?` + `confirm`. 토큰·파라미터 예산 준수. - -### C. 입력 스키마 예시 (축약) - -```json -// bottlenote_whisky_search -{ "type":"object", "properties": { - "keyword":{"type":"string"}, "category":{"type":"string"}, - "regionId":{"type":"integer"}, "page":{"type":"integer","minimum":0}, - "size":{"type":"integer","minimum":1,"maximum":50} -}, "additionalProperties": false } - -// bottlenote_whisky_update -{ "type":"object", "required":["alcoholId","payload","confirm"], "properties": { - "alcoholId":{"type":"integer"}, - "payload":{"type":"object", "required":["korName","engName","abv","type","korCategory", - "engCategory","categoryGroup","regionId","distilleryId","age","cask","imageUrl", - "description","volume"], - "properties":{ "korName":{"type":"string"}, "engName":{"type":"string"}, - "abv":{"type":"string"}, "type":{"type":"string"}, "regionId":{"type":"integer"}, - "distilleryId":{"type":"integer"}, "tastingTagIds":{"type":"array","items":{"type":"integer"}} }}, - "confirm":{"type":"boolean", "const": true} -}, "additionalProperties": false } -``` - -## NEVER 목록 (서버 미등록 + 로직 거부) - -| 금지 | 이유 | 대응 Admin API (존재해도 MCP 비노출) | -|---|---|---| -| `*_delete` / 소프트삭제 일괄 | #370 자동 삭제 비제공 | `DELETE /alcohols/{id}`, distillery/region/tag delete | -| 무페이징 `list_all_*` | 컨텍스트 폭증 | 없음 — 검색·커서만 | -| bulk reorder / 대량 수정 단일 툴 | 폭발 반경 | `PATCH .../bulk/reorder` (region·distillery) | -| 웹검색·Whiskybase·출처 판정 | #370 범위 외 | 없음 (리서치 MCP) | -| 테이스팅 태그 자동 생성·추천 | #370 비제공 | tag create는 사람 Admin UI 전제 | -| Agent Key/JWT 발급·조회 툴 | 시크릿 노출 | #340 교환 API는 MCP 서버 내부 전용 | -| 토큰 통과 프록시 툴 | 스펙 금지 | — | -| curation/banner/user/review 전면 | 1차 #370 범위 밖 | 별도 카탈로그 확장 시 | - -## confirm 정책 (파괴·쓰기) - -| 동작 | confirm | 서버 가드 | -|---|---|---| -| create whisky | `confirm=true` 필수 | 필수 필드 검증, 이미지 URL 허용 호스트 검사(TBD) | -| update whisky | `confirm=true` 필수 | before 스냅샷 + after 감사; 1건만 | -| image presign | 불필요 | contentType 화이트리스트, rate limit | -| preview_diff | 불필요 | 읽기 전용, 부작용 없음 | -| 삭제·bulk | N/A | **툴 없음** — 호출 시도 시 method not found | - -## Admin API 매핑 요약 - -``` -READ GET /alcohols → bottlenote_whisky_search - GET /alcohols/{id} → bottlenote_whisky_get - GET /alcohols/lookup → bottlenote_whisky_lookup - GET /alcohols/categories/reference → bottlenote_category_reference_get - GET /distilleries[/{id}] → bottlenote_distillery_list|get - GET /regions[/{id}] → bottlenote_region_list|get - GET /tasting-tags → bottlenote_tasting_tag_list - GET /s3/presign-url → bottlenote_image_presign -WRITE POST /alcohols → bottlenote_whisky_create (+confirm) - PUT /alcohols/{id} → bottlenote_whisky_update (+confirm) -TBD (로컬 diff) → bottlenote_whisky_preview_diff -NEVER DELETE /alcohols|distilleries|regions|tasting-tags/{id} - PATCH /**/bulk/reorder -``` - -## 구현 메모 (합성 입력) - -1. create/update는 `payload` 객체로 파라미터 예산 준수. -2. preview_diff는 신규 Admin API 없이 `get` + 서버측 필드 diff로 충분 → **TBD API 최소화**. -3. distillery/region **쓰기·삭제·reorder** 1차 제외(참조 조회만). 필요 시 2차 카탈로그. -4. tasting tag: list만. alcohol↔tag 연결은 whisky create/update의 `tastingTagIds`로 흡수 (`POST/DELETE /tasting-tags/{id}/alcohols` 비노출). -5. `tools/list` 결정적 순서: 위 표 #1→#13. -6. 스코프 제안: `admin:read` 기본 / `admin:whisky:write` 로 create·update 승격. - -## 미확인 - -- Admin API 글로벌 prefix(`/api/v1` 등) — 배포 설정 확인 후 문서 경로 보정. -- presign query 파라미터 정식 이름·MIME 화이트리스트. -- #341 감사 스키마에 toolName·before/after 컬럼 존재 여부. -- nested `payload` vs flat 필드 중 클라이언트( Claude/Cursor ) 스키마 호환 실측. - -## 결론 (Key findings) - -- **13툴 / 삭제 0 / bulk 0** 으로 #370 위스키 조회·단건 생성·수정·이미지 준비·변경 diff에 충분. -- Admin 매핑은 alcohols·distillery·region·tasting-tags·s3 실경로 기준. preview_diff만 TBD. -- confirm은 스키마 `const:true` + 서버 검증 이중. NEVER는 미등록이 본방어. -- #340 자격 교환·#341 감사 차원을 전제로 하면 토큰 통과·고아 감사 로그를 피할 수 있다. From e8adc507f98ce54072a41fab482994e7aabaaddd Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 8 Aug 2026 01:39:18 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20MCP=20=EC=9C=84=EC=8A=A4=ED=82=A4=20?= =?UTF-8?q?API=20OpenAPI=20=EB=AC=B8=EC=84=9C=20=EB=B0=8F=20operation=20?= =?UTF-8?q?=EC=88=98=20=EC=A0=95=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/AdminMcpWhiskyController.kt | 16 +++--- .../docs/AdminMcpWhiskyApiDocs.kt | 51 +++++++++++++++++++ .../openapi/OpenApiDocsIntegrationTest.kt | 9 ++-- 3 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/docs/AdminMcpWhiskyApiDocs.kt diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt index 7c0949f11..5f17f2837 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt @@ -2,6 +2,7 @@ package app.bottlenote.mcp.presentation import app.bottlenote.global.data.response.GlobalResponse import app.bottlenote.mcp.dto.McpWhiskySearchRequest +import app.bottlenote.mcp.presentation.docs.AdminMcpWhiskyApiDocs import app.bottlenote.mcp.service.AdminMcpWhiskyService import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -13,20 +14,22 @@ import org.springframework.web.bind.annotation.RestController /** * MCP-optimized admin whisky APIs. * - * Consumed only by bottlenote-mcp gateway (not public admin UI contract). + * Consumed only by the MCP gateway (not public admin UI contract). * Full path: /admin/api/v1/mcp/whiskies */ @RestController @RequestMapping("/mcp/whiskies") +@AdminMcpWhiskyApiDocs.ApiTag class AdminMcpWhiskyController( - private val adminMcpWhiskyService: AdminMcpWhiskyService, + private val adminMcpWhiskyService: AdminMcpWhiskyService ) { + @AdminMcpWhiskyApiDocs.SearchWhiskies @GetMapping fun search( @RequestParam(required = false) keyword: String?, @RequestParam(required = false) regionId: Long?, @RequestParam(required = false) page: Int?, - @RequestParam(required = false) size: Int?, + @RequestParam(required = false) size: Int? ): ResponseEntity { val result = adminMcpWhiskyService.search( @@ -34,14 +37,15 @@ class AdminMcpWhiskyController( keyword = keyword, regionId = regionId, page = page, - size = size, - ), + size = size + ) ) return GlobalResponse.ok(result) } + @AdminMcpWhiskyApiDocs.GetWhiskyDetail @GetMapping("/{alcoholId}") fun getDetail( - @PathVariable alcoholId: Long, + @PathVariable alcoholId: Long ): ResponseEntity = GlobalResponse.ok(adminMcpWhiskyService.getDetail(alcoholId)) } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/docs/AdminMcpWhiskyApiDocs.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/docs/AdminMcpWhiskyApiDocs.kt new file mode 100644 index 000000000..0355d7eba --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/docs/AdminMcpWhiskyApiDocs.kt @@ -0,0 +1,51 @@ +package app.bottlenote.mcp.presentation.docs + +import app.bottlenote.mcp.dto.McpWhiskyDetail +import app.bottlenote.mcp.dto.McpWhiskySearchResult +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag + +/** MCP 게이트웨이 전용 위스키 조회 문서. Admin UI 계약과 분리한다. */ +object AdminMcpWhiskyApiDocs { + + @Target(AnnotationTarget.CLASS) + @Retention(AnnotationRetention.RUNTIME) + @Tag(name = "MCP", description = "MCP 게이트웨이가 호출하는 위스키 조회 API. 필드·페이징은 에이전트 컨텍스트에 맞게 축소한다") + annotation class ApiTag + + @Target(AnnotationTarget.FUNCTION) + @Retention(AnnotationRetention.RUNTIME) + @Operation( + summary = "MCP용 위스키 목록을 검색한다", + description = """ +keyword, regionId, page, size(최대 50)로 위스키 요약 목록을 조회합니다. +Admin UI /alcohols 와 별도 계약이며, MCP 게이트웨이만 사용합니다. +""", + responses = [ + ApiResponse( + responseCode = "200", + description = "MCP 위스키 검색 결과", + content = [Content(schema = Schema(implementation = McpWhiskySearchResult::class))] + ) + ] + ) + annotation class SearchWhiskies + + @Target(AnnotationTarget.FUNCTION) + @Retention(AnnotationRetention.RUNTIME) + @Operation( + summary = "MCP용 위스키 상세를 조회한다", + description = "alcoholId로 MCP 최적화된 위스키 상세를 조회합니다. 지역·증류소·테이스팅 태그 요약을 포함합니다.", + responses = [ + ApiResponse( + responseCode = "200", + description = "MCP 위스키 상세", + content = [Content(schema = Schema(implementation = McpWhiskyDetail::class))] + ) + ] + ) + annotation class GetWhiskyDetail +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt index 19ba9e14d..e1fb4ffd2 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt @@ -11,9 +11,10 @@ class OpenApiDocsIntegrationTest : OpenApiSpecTestSupport() { private val envelopeFields = listOf("success", "code", "data", "errors", "meta") - // Admin의 65 operation은 모두 GlobalResponse 공통 형식을 쓴다 (plan Assumption 6). + // Admin operation은 모두 GlobalResponse 공통 형식을 쓴다 (plan Assumption 6). // product와 달리 공통 형식을 벗어나는 예외 엔드포인트가 없다. - private val expectedOperationCount = 65 + // MCP 위스키 검색·상세 2건 추가 → 65 + 2 = 67 + private val expectedOperationCount = 67 @Test @DisplayName("인증 없이 스펙 문서를 조회할 수 있다") @@ -45,8 +46,8 @@ class OpenApiDocsIntegrationTest : OpenApiSpecTestSupport() { } @Test - @DisplayName("문서에는 65개 operation이 누락 없이 포함된다") - fun openApiSpecContains65Operations() { + @DisplayName("문서에는 67개 operation이 누락 없이 포함된다") + fun openApiSpecContains67Operations() { val operations = operationsOf(fetchSpec()) assertThat(operations) From 1247ba0c0fa9dc7931b368a1389e6d1ce4119d43 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 8 Aug 2026 01:39:38 +0900 Subject: [PATCH 5/6] =?UTF-8?q?style:=20MCP=20=EC=9C=84=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=20API=20=EC=BD=94=EB=93=9C=20Spotless=20=ED=8F=AC=EB=A7=B7=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/bottlenote/mcp/dto/McpWhiskyDtos.kt | 10 +-- .../mcp/service/AdminMcpWhiskyService.kt | 70 +++++++++---------- 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt index 0f6549a57..47318ad77 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt @@ -10,13 +10,13 @@ data class McpWhiskySummary( val engName: String?, val korCategory: String?, val engCategory: String?, - val imageUrl: String?, + val imageUrl: String? ) data class McpTastingTag( val id: Long, val korName: String?, - val engName: String?, + val engName: String? ) data class McpWhiskyDetail( @@ -37,7 +37,7 @@ data class McpWhiskyDetail( val distilleryId: Long?, val korDistillery: String?, val engDistillery: String?, - val tastingTags: List, + val tastingTags: List ) data class McpWhiskySearchResult( @@ -45,12 +45,12 @@ data class McpWhiskySearchResult( val page: Int, val size: Int, val totalElements: Long?, - val hasNext: Boolean?, + val hasNext: Boolean? ) data class McpWhiskySearchRequest( val keyword: String? = null, val regionId: Long? = null, val page: Int? = null, - val size: Int? = null, + val size: Int? = null ) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt index c5610c8bd..d6ff89ae4 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt @@ -19,7 +19,7 @@ import org.springframework.stereotype.Service @Service class AdminMcpWhiskyService( private val alcoholQueryService: AlcoholQueryService, - private val objectMapper: ObjectMapper, + private val objectMapper: ObjectMapper ) { fun search(request: McpWhiskySearchRequest): McpWhiskySearchResult { val page = (request.page ?: 0).coerceAtLeast(0) @@ -34,7 +34,7 @@ class AdminMcpWhiskyService( null, page, size, - false, + false ) // AlcoholQueryService.searchAdminAlcohols uses GlobalResponse.fromPage: // data = List content, meta.totalElements / meta.hasNext @@ -47,7 +47,7 @@ class AdminMcpWhiskyService( page = page, size = size, totalElements = total, - hasNext = hasNext, + hasNext = hasNext ) } @@ -93,38 +93,36 @@ class AdminMcpWhiskyService( } } - private fun AdminAlcoholItem.toSummary(): McpWhiskySummary = - McpWhiskySummary( - alcoholId = alcoholId, - korName = korName, - engName = engName, - korCategory = korCategoryName, - engCategory = engCategoryName, - imageUrl = imageUrl, - ) + private fun AdminAlcoholItem.toSummary(): McpWhiskySummary = McpWhiskySummary( + alcoholId = alcoholId, + korName = korName, + engName = engName, + korCategory = korCategoryName, + engCategory = engCategoryName, + imageUrl = imageUrl + ) - private fun AdminAlcoholDetailResponse.toMcpDetail(): McpWhiskyDetail = - McpWhiskyDetail( - alcoholId = alcoholId, - korName = korName, - engName = engName, - korCategory = korCategory, - engCategory = engCategory, - imageUrl = imageUrl, - abv = abv, - age = age, - cask = cask, - volume = volume, - description = description, - regionId = regionId, - korRegion = korRegion, - engRegion = engRegion, - distilleryId = distilleryId, - korDistillery = korDistillery, - engDistillery = engDistillery, - tastingTags = - tastingTags.map { tag -> - McpTastingTag(id = tag.id, korName = tag.korName, engName = tag.engName) - }, - ) + private fun AdminAlcoholDetailResponse.toMcpDetail(): McpWhiskyDetail = McpWhiskyDetail( + alcoholId = alcoholId, + korName = korName, + engName = engName, + korCategory = korCategory, + engCategory = engCategory, + imageUrl = imageUrl, + abv = abv, + age = age, + cask = cask, + volume = volume, + description = description, + regionId = regionId, + korRegion = korRegion, + engRegion = engRegion, + distilleryId = distilleryId, + korDistillery = korDistillery, + engDistillery = engDistillery, + tastingTags = + tastingTags.map { tag -> + McpTastingTag(id = tag.id, korName = tag.korName, engName = tag.engName) + } + ) } From 29759770d5a343034079f01df056fec300afe06c Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Sat, 8 Aug 2026 01:53:14 +0900 Subject: [PATCH 6/6] =?UTF-8?q?revert:=20Admin=20MCP=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20API=20=EB=B0=8F=20plan=20=EB=AC=B8=EC=84=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/bottlenote/mcp/dto/McpWhiskyDtos.kt | 56 -------- .../presentation/AdminMcpWhiskyController.kt | 51 ------- .../docs/AdminMcpWhiskyApiDocs.kt | 51 ------- .../mcp/service/AdminMcpWhiskyService.kt | 128 ------------------ .../openapi/OpenApiDocsIntegrationTest.kt | 9 +- plan/mcp-architecture-isolation.md | 76 ----------- 6 files changed, 4 insertions(+), 367 deletions(-) delete mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt delete mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt delete mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/docs/AdminMcpWhiskyApiDocs.kt delete mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt delete mode 100644 plan/mcp-architecture-isolation.md diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt deleted file mode 100644 index 47318ad77..000000000 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/dto/McpWhiskyDtos.kt +++ /dev/null @@ -1,56 +0,0 @@ -package app.bottlenote.mcp.dto - -/** - * MCP-optimized whisky payloads: compact fields for agent context. - * Not a 1:1 dump of AdminAlcoholDetailResponse. - */ -data class McpWhiskySummary( - val alcoholId: Long, - val korName: String?, - val engName: String?, - val korCategory: String?, - val engCategory: String?, - val imageUrl: String? -) - -data class McpTastingTag( - val id: Long, - val korName: String?, - val engName: String? -) - -data class McpWhiskyDetail( - val alcoholId: Long, - val korName: String?, - val engName: String?, - val korCategory: String?, - val engCategory: String?, - val imageUrl: String?, - val abv: String?, - val age: String?, - val cask: String?, - val volume: String?, - val description: String?, - val regionId: Long?, - val korRegion: String?, - val engRegion: String?, - val distilleryId: Long?, - val korDistillery: String?, - val engDistillery: String?, - val tastingTags: List -) - -data class McpWhiskySearchResult( - val items: List, - val page: Int, - val size: Int, - val totalElements: Long?, - val hasNext: Boolean? -) - -data class McpWhiskySearchRequest( - val keyword: String? = null, - val regionId: Long? = null, - val page: Int? = null, - val size: Int? = null -) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt deleted file mode 100644 index 5f17f2837..000000000 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/AdminMcpWhiskyController.kt +++ /dev/null @@ -1,51 +0,0 @@ -package app.bottlenote.mcp.presentation - -import app.bottlenote.global.data.response.GlobalResponse -import app.bottlenote.mcp.dto.McpWhiskySearchRequest -import app.bottlenote.mcp.presentation.docs.AdminMcpWhiskyApiDocs -import app.bottlenote.mcp.service.AdminMcpWhiskyService -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController - -/** - * MCP-optimized admin whisky APIs. - * - * Consumed only by the MCP gateway (not public admin UI contract). - * Full path: /admin/api/v1/mcp/whiskies - */ -@RestController -@RequestMapping("/mcp/whiskies") -@AdminMcpWhiskyApiDocs.ApiTag -class AdminMcpWhiskyController( - private val adminMcpWhiskyService: AdminMcpWhiskyService -) { - @AdminMcpWhiskyApiDocs.SearchWhiskies - @GetMapping - fun search( - @RequestParam(required = false) keyword: String?, - @RequestParam(required = false) regionId: Long?, - @RequestParam(required = false) page: Int?, - @RequestParam(required = false) size: Int? - ): ResponseEntity { - val result = - adminMcpWhiskyService.search( - McpWhiskySearchRequest( - keyword = keyword, - regionId = regionId, - page = page, - size = size - ) - ) - return GlobalResponse.ok(result) - } - - @AdminMcpWhiskyApiDocs.GetWhiskyDetail - @GetMapping("/{alcoholId}") - fun getDetail( - @PathVariable alcoholId: Long - ): ResponseEntity = GlobalResponse.ok(adminMcpWhiskyService.getDetail(alcoholId)) -} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/docs/AdminMcpWhiskyApiDocs.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/docs/AdminMcpWhiskyApiDocs.kt deleted file mode 100644 index 0355d7eba..000000000 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/presentation/docs/AdminMcpWhiskyApiDocs.kt +++ /dev/null @@ -1,51 +0,0 @@ -package app.bottlenote.mcp.presentation.docs - -import app.bottlenote.mcp.dto.McpWhiskyDetail -import app.bottlenote.mcp.dto.McpWhiskySearchResult -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.media.Content -import io.swagger.v3.oas.annotations.media.Schema -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.tags.Tag - -/** MCP 게이트웨이 전용 위스키 조회 문서. Admin UI 계약과 분리한다. */ -object AdminMcpWhiskyApiDocs { - - @Target(AnnotationTarget.CLASS) - @Retention(AnnotationRetention.RUNTIME) - @Tag(name = "MCP", description = "MCP 게이트웨이가 호출하는 위스키 조회 API. 필드·페이징은 에이전트 컨텍스트에 맞게 축소한다") - annotation class ApiTag - - @Target(AnnotationTarget.FUNCTION) - @Retention(AnnotationRetention.RUNTIME) - @Operation( - summary = "MCP용 위스키 목록을 검색한다", - description = """ -keyword, regionId, page, size(최대 50)로 위스키 요약 목록을 조회합니다. -Admin UI /alcohols 와 별도 계약이며, MCP 게이트웨이만 사용합니다. -""", - responses = [ - ApiResponse( - responseCode = "200", - description = "MCP 위스키 검색 결과", - content = [Content(schema = Schema(implementation = McpWhiskySearchResult::class))] - ) - ] - ) - annotation class SearchWhiskies - - @Target(AnnotationTarget.FUNCTION) - @Retention(AnnotationRetention.RUNTIME) - @Operation( - summary = "MCP용 위스키 상세를 조회한다", - description = "alcoholId로 MCP 최적화된 위스키 상세를 조회합니다. 지역·증류소·테이스팅 태그 요약을 포함합니다.", - responses = [ - ApiResponse( - responseCode = "200", - description = "MCP 위스키 상세", - content = [Content(schema = Schema(implementation = McpWhiskyDetail::class))] - ) - ] - ) - annotation class GetWhiskyDetail -} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt deleted file mode 100644 index d6ff89ae4..000000000 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/mcp/service/AdminMcpWhiskyService.kt +++ /dev/null @@ -1,128 +0,0 @@ -package app.bottlenote.mcp.service - -import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest -import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse -import app.bottlenote.alcohols.dto.response.AdminAlcoholItem -import app.bottlenote.alcohols.service.AlcoholQueryService -import app.bottlenote.global.data.response.GlobalResponse -import app.bottlenote.mcp.dto.McpTastingTag -import app.bottlenote.mcp.dto.McpWhiskyDetail -import app.bottlenote.mcp.dto.McpWhiskySearchRequest -import app.bottlenote.mcp.dto.McpWhiskySearchResult -import app.bottlenote.mcp.dto.McpWhiskySummary -import com.fasterxml.jackson.databind.ObjectMapper -import org.springframework.stereotype.Service - -/** - * MCP-facing whisky read model. Reuses domain query services; shapes payloads for agents. - */ -@Service -class AdminMcpWhiskyService( - private val alcoholQueryService: AlcoholQueryService, - private val objectMapper: ObjectMapper -) { - fun search(request: McpWhiskySearchRequest): McpWhiskySearchResult { - val page = (request.page ?: 0).coerceAtLeast(0) - val size = (request.size ?: 20).coerceIn(1, 50) - // Java record: positional args only from Kotlin - val adminRequest = - AdminAlcoholSearchRequest( - request.keyword, - null, - request.regionId, - null, - null, - page, - size, - false - ) - // AlcoholQueryService.searchAdminAlcohols uses GlobalResponse.fromPage: - // data = List content, meta.totalElements / meta.hasNext - val global: GlobalResponse = alcoholQueryService.searchAdminAlcohols(adminRequest) - val items = extractPageItems(global).map { it.toSummary() } - val total = metaLong(global, "totalElements") - val hasNext = metaBoolean(global, "hasNext") ?: (items.size >= size) - return McpWhiskySearchResult( - items = items, - page = page, - size = size, - totalElements = total, - hasNext = hasNext - ) - } - - fun getDetail(alcoholId: Long): McpWhiskyDetail { - val detail = alcoholQueryService.findAdminAlcoholDetailById(alcoholId) - return detail.toMcpDetail() - } - - private fun extractPageItems(global: GlobalResponse): List { - val data = global.data ?: return emptyList() - if (data is List<*>) { - return data.mapNotNull { convertItem(it) } - } - return emptyList() - } - - private fun metaLong(global: GlobalResponse, key: String): Long? { - val value = global.meta?.get(key) ?: return null - return when (value) { - is Number -> value.toLong() - is String -> value.toLongOrNull() - else -> null - } - } - - private fun metaBoolean(global: GlobalResponse, key: String): Boolean? { - val value = global.meta?.get(key) ?: return null - return when (value) { - is Boolean -> value - is String -> value.toBooleanStrictOrNull() - else -> null - } - } - - private fun convertItem(raw: Any?): AdminAlcoholItem? { - if (raw == null) return null - return when (raw) { - is AdminAlcoholItem -> raw - else -> - runCatching { - objectMapper.convertValue(raw, AdminAlcoholItem::class.java) - }.getOrNull() - } - } - - private fun AdminAlcoholItem.toSummary(): McpWhiskySummary = McpWhiskySummary( - alcoholId = alcoholId, - korName = korName, - engName = engName, - korCategory = korCategoryName, - engCategory = engCategoryName, - imageUrl = imageUrl - ) - - private fun AdminAlcoholDetailResponse.toMcpDetail(): McpWhiskyDetail = McpWhiskyDetail( - alcoholId = alcoholId, - korName = korName, - engName = engName, - korCategory = korCategory, - engCategory = engCategory, - imageUrl = imageUrl, - abv = abv, - age = age, - cask = cask, - volume = volume, - description = description, - regionId = regionId, - korRegion = korRegion, - engRegion = engRegion, - distilleryId = distilleryId, - korDistillery = korDistillery, - engDistillery = engDistillery, - tastingTags = - tastingTags.map { tag -> - McpTastingTag(id = tag.id, korName = tag.korName, engName = tag.engName) - } - ) -} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt index e1fb4ffd2..19ba9e14d 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/openapi/OpenApiDocsIntegrationTest.kt @@ -11,10 +11,9 @@ class OpenApiDocsIntegrationTest : OpenApiSpecTestSupport() { private val envelopeFields = listOf("success", "code", "data", "errors", "meta") - // Admin operation은 모두 GlobalResponse 공통 형식을 쓴다 (plan Assumption 6). + // Admin의 65 operation은 모두 GlobalResponse 공통 형식을 쓴다 (plan Assumption 6). // product와 달리 공통 형식을 벗어나는 예외 엔드포인트가 없다. - // MCP 위스키 검색·상세 2건 추가 → 65 + 2 = 67 - private val expectedOperationCount = 67 + private val expectedOperationCount = 65 @Test @DisplayName("인증 없이 스펙 문서를 조회할 수 있다") @@ -46,8 +45,8 @@ class OpenApiDocsIntegrationTest : OpenApiSpecTestSupport() { } @Test - @DisplayName("문서에는 67개 operation이 누락 없이 포함된다") - fun openApiSpecContains67Operations() { + @DisplayName("문서에는 65개 operation이 누락 없이 포함된다") + fun openApiSpecContains65Operations() { val operations = operationsOf(fetchSpec()) assertThat(operations) diff --git a/plan/mcp-architecture-isolation.md b/plan/mcp-architecture-isolation.md deleted file mode 100644 index 22abbcf09..000000000 --- a/plan/mcp-architecture-isolation.md +++ /dev/null @@ -1,76 +0,0 @@ -# Admin MCP — 아키텍처 SSOT - -- 이슈: bottle-note/workspace#370 · 관련 #340(Agent Key) · #341(감사, open) -- 작성/정리: 2026-08-08 -- 이 문서가 plan 내 **유일한** MCP 설계 문서다. 조사 원본(research/brief)은 폐기했다. - -## Topology - -``` -관리자 / Agent 클라이언트 (Claude, Codex, Cursor) - | Streamable HTTP https://mcp.bottlenote.com/mcp - | Authorization: Bearer bn_agent_* - v - bottlenote-mcp (/Users/hgkim/workspace/bottlenote/mcp, TS) - | 내부 HTTP allowlist only - | 1) POST /admin/api/v1/auth/agent - | 2) GET /admin/api/v1/mcp/... - | Admin JWT 클라이언트 미노출 - v - bottlenote-admin-api -``` - -## 결정 - -| 항목 | 값 | -|------|-----| -| 게이트웨이 | 별도 TS 앱 `bottlenote-mcp` (admin-api 내장·Java monorepo MCP 모듈 비채택) | -| Transport | Streamable HTTP `/mcp`, **stateless** (sticky 없음, multi-pod OK) | -| Wire | 구현은 SDK 현실에 맞춤; 비즈니스 상태 세션 금지 | -| 인증 | Agent Key만 수신 → 서버 내부 #340 교환 → Admin JWT(요청 스코프) | -| 배포 | multi-arch Node 22 (`linux/amd64`, `linux/arm64`), k9s/GitOps | -| 서브모듈 | `git.environment-variables` (api-server와 동일). 키 커밋 금지 | - -## 백엔드 MCP API (admin-api) - -일반 `/alcohols` UI 계약과 분리. 필드 축소·size≤50. - -| Method | Path | 용도 | -|--------|------|------| -| GET | `/admin/api/v1/mcp/whiskies` | 요약 검색 | -| GET | `/admin/api/v1/mcp/whiskies/{id}` | MCP용 상세 | - -## Tools - -### v0.1 (구현 중/게이트웨이 스캐폴드) - -| tool | backend | -|------|---------| -| `bottlenote_whisky_search` | `GET /mcp/whiskies` | -| `bottlenote_whisky_get` | `GET /mcp/whiskies/{id}` | - -### 이후 (미구현) - -조회: lookup, category reference, distillery/region list·get, tasting_tag list -쓰기: whisky create/update(`confirm=true`), image presign, preview_diff -감사: #341 연계 - -### NEVER (미등록 + 아웃바운드 거부) - -- delete / bulk / 무페이징 list_all -- 토큰 발급·통과 프록시 -- 웹검색·외부 출처 판단·태그 자동생성 -- curation / banner / user / review 전면 - -## 보안 (필수) - -1. 클라이언트 → MCP: Agent Key만 (Admin JWT 수신·통과 금지) -2. MCP → Admin: allowlist `/auth/agent`, `/mcp/*` 만 -3. 로그·메트릭·예외에 `bn_agent_*` / JWT 원문 금지 -4. 쓰기 툴은 서버 `confirm=true` 강제 (annotation은 UX 힌트일 뿐) -5. Rate limit 키: agentId 우선, fallback XFF (게이트웨이 XFF 신뢰) - -## 로컬 - -- MCP app: `/Users/hgkim/workspace/bottlenote/mcp` (푸시·본구현은 별도) -- Backend: 이 저장소 `feat-issues-mcp` 등