# TAAMs MCP 서버

> 상태: **운영 중** — `https://mcp.taamsglobal.com/mcp`
> 조회 전용입니다. claude.ai·ChatGPT 커넥터(OAuth)와 API 키 연결을 모두 지원합니다.

TAAMs 의 수입식품 통관 데이터를 **Claude·ChatGPT 등 LLM 이 직접 조회**할 수 있게 하는
MCP(Model Context Protocol) 서버다. 사용자가 자기 AI 어시스턴트에 TAAMs 를 연결하면
"망고 수입단가 알려줘" 같은 질문에 실데이터로 답할 수 있다.

---

## 1. 무엇이 되는가

```
사용자: "2025년 망고 수입단가랑 주요 수출국 알려줘"
  → LLM 이 search_products("망고") 로 정확한 품목명 확인
  → get_product_price("망고") 로 단가·수출국 조회
  → "2025년 6월 기준 4.23 USD/kg 이고, 수출국은 …"
```

**조회 전용**이다. 견적 요청·광고 등록·결제 같은 쓰기 기능은 MCP 로 노출하지 않는다.

---

## 2. 연결 방법

### 먼저 — TAAMs 계정

연결하려면 TAAMs 계정이 필요합니다. 없으시면 **[회원가입(무료)](https://taamsglobal.com/signup)**
에서 먼저 만드십시오. 가입은 무료이고, 품목·수입사·수출사 검색과 수입 추이는 무료
등급에서 바로 쓰실 수 있습니다. 수입단가·경매 낙찰단가·재무·산지 수온은 Pro 등급부터입니다(§3).

### Claude Code / Cursor 등 (헤더 직접 지정)

키 발급: **TAAMs 마이페이지 → AI 연결(MCP) → 발급** (Pro 등급 이상).
평문은 발급 직후 한 번만 보이니 그때 복사해 둘 것.

```bash
claude mcp add taams --transport http https://mcp.taamsglobal.com/mcp \
  --header "Authorization: Bearer <마이페이지에서 발급한 키>"
```

연결 확인:
```bash
claude mcp list
# taams: https://mcp.taamsglobal.com/mcp (HTTP) - ✔ Connected
```

### claude.ai / ChatGPT 커넥터

**키가 필요 없습니다.** 커넥터 추가 화면에 아래 주소만 넣으시면 됩니다.

```
https://mcp.taamsglobal.com/mcp
```

주소를 넣으면 TAAMs 연결 승인 화면이 뜹니다. 거기서 TAAMs 이메일·비밀번호로
로그인하고 **허용**을 누르면 연결이 끝납니다.

- 승인 화면에 **회원가입 링크**가 있습니다. 계정이 없으시면 거기서 새 창으로 가입하신 뒤,
  원래 창으로 돌아와 로그인하시면 연결이 이어집니다.
- 승인 요청은 **10분 동안만** 유효합니다. 시간이 지나면 사용하시던 AI 앱에서
  연결을 다시 누르시면 됩니다.
- 승인하셔도 이 앱이 할 수 있는 일은 **조회뿐**입니다. 견적 요청·결제처럼 무언가를
  바꾸는 일은 할 수 없습니다.

API 키 방식(위)은 헤더를 직접 지정할 수 있는 도구에서만 동작합니다.

### 로컬 개발

```bash
# 업스트림(메인 서비스)
cd src && python -m uvicorn import_gemini:app --port 8000

# MCP 서버
cd src && python -m uvicorn MCP.mcp_app:app --port 8003
```

`MCP/.env.template` 를 `MCP/.env` 로 복사해 설정을 조정한다.

---

## 3. 제공 도구 (전부 조회 전용)

> **`get_tariff`(HS코드·관세율)는 제공하지 않습니다** (2026-08-16 중단, 목록에서도 제거).
> 관세율은 관세청 관세법령정보포털(unipass.customs.go.kr)을 이용하십시오.

<!-- 유지보수 주의(고객용 아님):
     이 표에 **도구 개수를 숫자로 적지 말 것.** 2026-08-23 이전 이 문서는 같은
     시점에 "5개"·"9개"·"15개"를 동시에 주장했고 실물은 19개였다. 개수의 정본은
     test/test_mcp_tools.py 의 EXPECTED_TOOLS 한 곳이며, 이 표가 그것과 일치하는지는
     test/test_mcp_doc_sync.py 가 검사한다(개수를 되살리면 그 게이트가 깨진다). -->


**검색 도구를 먼저 부른다.** 다른 도구는 정확한 이름을 요구한다.

| 도구 | 용도 | 주요 인자 |
|------|------|-----------|
| `search_products` | 품목명 검색 — **품목 도구보다 먼저** | `query` |
| `get_product_price` | 월별 수입단가(USD/kg) + 수출국별 단가·점유율 — **Pro 이상** | `product_name`, `year`, `month` |
| `get_product_trend` | 월별 수입 건수·중량·금액 추이 | `product_name`, `months` |
| `get_product_top_exporters` | 품목별 상위 해외 수출사 + 국가별 합계 | `product_name`, `country` |
| `get_product_importers` | 품목별 국내 수입사 목록 | `product_name` |
| `get_daily_price` | **일 단위** 단가 (주 단위 페이징) — **Pro 이상·승인셀러** | `product_name`, `year`, `month` |
| `search_importers` | 수입사 검색 — **수입사 도구보다 먼저** | `query` |
| `get_importer_profile` | 수입사 정보 + 취급 품목·수입국 통계 | `importer`, `year`, `month` |
| `get_importer_financials` | 수입사 재무실적·기업개황(DART 전자공시) — **Pro 이상** | `importer`, `years` |
| `search_exporters` | 해외 수출사 검색(대부분 영문명) | `query` |
| `get_exporter_profile` | 수출사 실적(품목·거래처·추이) + 등록정보 | `exporter_name` |
| `get_country_overview` | 국가별 수입 현황 | `country`, `year`, `section` |
| `get_exchange_rate` | 관세청 고시환율 (시중 환율 아님) | `currency` |
| `get_import_report` | 일자별 수입현황 리포트(Markdown) | `report_date` |
| `get_origin_weather` | 양식 수산물 **산지 해역 수온** + 문헌 임계값 대비 현재 상태 — **Pro 이상** | `product_name`(품목유형), `exporter_country` |
| `search_auction_items` | 국내 도매경매 품목 코드 검색 — **경매 도구보다 먼저** | 검색어(부류·품목·품종) |
| `search_auction_origins` | 경매 산지 코드 검색(시군 단위) | 산지명 |
| `search_auction_markets` | 경매 도매시장 코드 검색 | 시장명 |
| `get_auction_price` | 국내 도매경매 **낙찰단가(원/kg)** 추이 — **Pro 이상** | 품목 코드, 산지·경매시장·등급(선택), 기간 |

**파라미터 설계 주의**
- 한글 파라미터 `구분` 은 스키마에서 **`gubun`** 으로 노출된다(LLM 이 UTF-8 키를 안정적으로
  못 채운다). 보통 지정할 필요가 없다.
- `get_country_overview` 의 `section` 은 `overview`(월별추이·상위품목유형) /
  `products` / `exporters` / `importers`. 업스트림의 숫자 `tab` 을 그대로 노출하면
  LLM 이 틀린다.
**Pro 등급 이상 전용 도구**
- 단가(2026-08-15): `get_product_price`(월별·수출국별) · `get_daily_price`(일별).
- 수입사 재무(2026-08-17): `get_importer_financials`.
  기업개황(대표자·주소·사업자등록번호 포함)과 재무실적이 함께 제공된다.
  재무는 **회계 검증을 통과한 것만** 나간다 — 감사보고서 파싱 결과 중
  자산총계=부채총계+자본총계 항등식과 손익 상식 검사를 통과한 A등급뿐이다.
- 산지 기상·작황(2026-08-17): `get_origin_weather`.
- 국내 도매경매 낙찰단가(2026-08-21): `get_auction_price`.
  경매 코드 검색 3종(`search_auction_items`·`search_auction_origins`·
  `search_auction_markets`)은 free 도 쓴다 — 코드를 찾는 일까지 막을 이유가 없다.

> 이 목록의 정본은 `MCP/limits.py` 의 `PRO_ONLY_TOOLS` 다.

**`get_origin_weather` 를 읽는 법 — 경고가 없는 것이 정상이다**

응답은 세 갈래로 갈라져 온다. 이 구분이 이 도구의 전부다.

| 갈래 | 뜻 |
|------|-----|
| `alerts` | 임계에 **실제로 도달했고**, 그 산지에서 드문 일이다 — 유일한 주의 신호 |
| `routine_conditions` | 도달했지만 그 해역에선 늘 있는 일 (라이저우만 바지락 26℃) |
| `not_reached` | 감시 중이며 아직 도달하지 않음 |

같은 임계가 산지마다 정반대 의미를 갖는다. 바지락 26℃ 는 산둥 라이저우만에서 여름
평균이 이미 넘는 **상시 조건**이고, 랴오둥만에서는 드물다. 그래서 "26℃ 초과"라는
사실만으로는 아무 말도 할 수 없다 — `alerts` 에 들어왔는지로만 판단한다.

**이 도구는 예측하지 않는다.** 관측된 수온과 문헌이 정한 임계를 나란히 놓을 뿐,
단가·수급과의 인과는 주장하지 않는다. 환율·운임·검역·현지 내수가가 동시에 걸린다.

**이 도구가 보는 범위**: 수온뿐이다. 용존산소·염분·적조는 위성으로 관측되지 않는다.
그래서 경고가 비어 있다는 것은 **"수온 기준으로는 임계에 이르지 않았다"**는 뜻이지
"산지에 이상이 없다"는 뜻이 아니다. 특히 패류는 적조로 출하가 금지될 수 있는데
그 정보는 여기 없다.

> 이 범위 설명은 **여기서 한 번 읽는 것으로 충분하다.** 조회할 때마다 답변에
> 따라붙지 않는다 — 무엇을 관측하지 못하는지는 데이터 제공자의 사정이지 매번
> 확인할 내용이 아니기 때문이다. 대신 답변은 "수온 기준으로는…"처럼 근거의
> 범위를 밝혀 말한다.

출처는 NOAA OISST v2.1(0.25° 일별). MFDS 통관데이터와 마찬가지로 **며칠 지연** 공개된다.
  공시 의무가 없는 소규모 법인은 자료가 없으며, 그 경우 오류가 아니라
  `matched:false` 와 사유가 돌아온다.
- free 등급이 부르면 **오류가 아니라 정상 응답**으로 사유가 돌아온다:
  ```json
  {"error":"grade_required","required_grade":"pro","current_grade":"free",
   "message":"수입단가 조회는 TAAMs Pro 등급 이상에서…",
   "available_instead":["search_products","get_product_trend",
                        "get_product_top_exporters","get_product_importers"],
   "upgrade_url":"https://taamsglobal.com"}
  ```
  오류(`isError`)로 두지 않는 이유: SDK 가 `Error executing tool <name>:` 접두를
  자동으로 붙여, LLM 이 **등급 제한을 '기술적 오류'로 읽고** "일시적 문제 같으니
  다시 시도할까요?" 라고 안내하게 된다. 차단은 실패가 아니라 정책 응답이다.
- **한도는 깎이지 않는다** — 등급 게이트가 쿼터 계상보다 먼저 돈다.
  대신 `usage_log.extra.blocked='grade_required'` 로 기록되고,
  한도 소진율 집계에서는 제외된다(쓰지 않은 쿼터가 100% 로 보이지 않게).
- **위 목록에 없는 도구는 free 도 그대로 쓴다** — 품목·수입사·수출사 검색,
  수입 추이, 상위 수출사/수입사, 국가별 현황, 환율, 일자별 리포트, 경매 코드 검색.
- 웹(taamsglobal.com)에서는 free 도 단가를 본다. MCP 만 다른 이유는 채널 성격이
  다르기 때문이다 — LLM 이 자동으로 반복 호출하는 표면이라 같은 권한이라도
  추출 규모가 다르다.
- 유료 판정은 `users.user_grade` 가 아니라 **활성 구독**(`user_subscriptions`)
  기준이다. 구독이 끝나면 등급 컬럼이 'pro' 로 남아 있어도 단가는 막힌다.

---

## 4. 데이터 특성 — 답변 해석 시 반드시 고려

이 내용은 서버가 `instructions` 로 LLM 에 전달하지만, 사람도 알아야 한다.

- **MFDS 통관 데이터는 약 3일 지연** 공개된다. 최근 며칠이 비어 보이는 것은 정상이며
  배치 오류가 아니다.
- **수입단가는 유도값**이다 — 통관 금액 ÷ 중량(USD/kg). 실제 거래가와 다를 수 있다.
- 응답에 **`truncated: true`** 가 있으면 전체가 아니라 상위 일부다. 총계는 `total` 을 본다.
- 일부 상세 정보(수입사 주소·연락처·대표자, 수출사 주소)는 **승인된 셀러 계정에만** 제공된다.
  값이 없는 것과 권한이 없는 것은 다르며, 후자면 응답에 안내가 포함된다.

---

## 5. 등급별 호출 한도

> **수정 위치: `admin.taamsglobal.com/mcp-limits` (ADMIN → MCP한도)**
> 한도는 코드 상수가 아니라 운영 데이터다. 배포·재시작 없이 화면에서 고친다.
> 변경은 MCP 서버에 **최대 1분 내** 반영된다(TTL 캐시).
>
> ⚠ **집행 여부는 코드가 정한다.** ADMIN 에서 바꿀 수 있는 건 '얼마나'이지
> '거느냐 마느냐'가 아니다. 집행 OFF 는 외부 LLM 에 무제한 조회를 여는 것이라
> 운영 토글 대상이 아니다. 설정 조회가 실패해도 기본값으로 **닫힌다**.

근거·상세: `plans/mcp-rate-limits.md`

| 등급 | 분당 | 일당 |
|------|-----:|-----:|
| free | 6 | 60 |
| pro | 15 | 300 |
| vip | 30 | 1,000 |
| 승인 셀러 | 15 | 300 |
| admin | 60 | 5,000 |

- 승인 셀러의 **상세정보(PII) 반환 도구**는 전체 쿼터와 별도로 **일 30건** 하위한도가 있다.
- LLM 은 질문 1건에 도구를 보통 2~5회 호출한다. free 60/일이면 하루 4~6개 품목 리서치 수준.
- 한도 초과 시 안내 문구와 함께 재시도 가능 시각을 알려준다.
- ⚠ 이 수치는 **런칭 전 표본(3명)** 기반이라 잠정값이다.
  ADMIN 화면 하단의 **최근 7일 사용량(소진율)** 을 보고 조정한다.
  조정 트리거 권고: *정상 사용자가 소진율 80% 를 넘는 날이 3일 이상이면 상향 검토*.

---

## 6. 보안 모델

토큰이 **3계층**으로 분리돼 있다. 이 구조가 "조회 전용"을 코드 밖에서도 강제한다.
(이 표는 `src/CLAUDE.md` 의 3계층 표와 같은 것을 말한다 — 갈라지면 그쪽이 정본이다.)

| 계층 | 실체 | 외부 노출 |
|------|------|-----------|
| 1. 클라이언트 토큰 | `mcp_api_keys`(API 키) / `mcp_access_tokens`(OAuth) | 나감 — **이 토큰으로 `/api/v1/*` 직접 호출 불가** |
| 2. 루프백 세션 | `user_sessions` (`source='mcp'`, `mcpses_` 접두) | 안 나감 — MCP 프로세스 내부 전용 |
| 3. 심층 방어 | 메인 앱 미들웨어 | `mcpses_` 를 포함한 자격증명은 조회 허용목록 외 전 경로 **403** |

계층 3 은 **파싱이 아니라 포함 검사**다. 라우터 25곳이 `authorization.replace("Bearer ","")`
로 위치 무관 전역 치환해 토큰을 뽑기 때문에, 가드가 정식 Bearer 문법만 인식하면
`"Bearer Bearer mcpses_x"` 같은 합법 헤더로 우회된다(2026-08-15 실제 발견·차단).
보안 가드는 보호 대상보다 **넓게** 탐지해야 한다.

기타:
- API 키는 `secrets.token_urlsafe(32)`, **sha256 해시만 저장**(평문 미저장). 발급 시 1회만 노출.
- 모든 도구 호출은 `usage_log`(`event_type='mcp_tool'`)에 **서버측 기록**된다. 우회 불가.
- MCP 서비스는 CORS 를 열지 않는다(서버-투-서버 트래픽).

---

## 7. 운영

### 배포 검증 — 출력이 아니라 코드버전으로 판정

상시 데몬은 기동 시 모듈을 메모리에 로드한다. **파일만 교체하고 재시작을 누락하면
옛 코드가 계속 돌면서 그럴듯한 결과를 낸다.** 출력으로는 구분할 수 없다.

```bash
# MCP 서버
python MCP/version.py --rev                      # 서버 파일 기준
curl -s https://mcp.taamsglobal.com/healthz | jq -r .rev

# 메인 앱 (MCP 가드가 여기 산다)
python -c "import import_gemini as m; print(m.CODE_REV)"
curl -s https://taamsglobal.com/healthz | jq -r .rev
```

세 값이 일치해야 반영 완료다. 메인 앱 `/healthz` 는 `mcp_guard.enabled` 와
허용목록 크기도 함께 보고한다.

### 최초 서버 설치 (1회) — 순서를 지킬 것

각 단계는 앞 단계에 의존한다. 특히 **DDL 이 코드보다 먼저**다 — 순서가 바뀌면
서비스가 뜬 채로 없는 테이블을 조회해 500 을 뿌린다.

```bash
# ① DB 마이그레이션 (코드 배포 **전**)
psql -U taasms -d taasms -f API/alter_user_sessions_source.sql
psql -U taasms -d taasms -f MCP/schema.sql
#    ⚠ 소유자 확인 필수. postgres 소유로 만들면 앱이 permission denied 500 을 낸다.
#      로컬에는 taasms 롤 자체가 없어 이 문제가 **로컬에서는 절대 안 보인다.**
#      서버 실측(2026-08-15): 롤 taasms 존재, users·user_sessions·usage_log·
#      import_goods 전부 taasms 소유 → OWNER TO 는 서버에서 반드시 적용해야 한다.
psql -U postgres -d taasms -c "\dt mcp_*"    # 8테이블 + 소유자 확인

# ② 패키지 — 공용 venv 를 쓴다(4개 서비스가 공유)
venv/bin/pip install --dry-run "mcp>=2.0.0"   # 먼저 이걸로 확인
venv/bin/pip install "mcp>=2.0.0"
#    1.x 의 mcp.server.fastmcp 는 2.0 에서 제거됐다 — 버전이 낮으면 import 부터 실패.
#    ⚠ 공용 venv 라 "기존 서비스를 깨뜨리지 않는가"가 진짜 질문이다. 그래서 --dry-run 이
#      먼저다. 서버 실측(2026-08-15): mcp 의존성은 **전부 하한(>=)** 이라
#      12개가 신규 설치되고 fastapi·starlette·pydantic·httpx·uvicorn 은 손대지 않는다
#      ("Would install" 만 나오고 "Would uninstall" 은 없어야 한다).
#      httpx2 는 httpx 와 **별개 패키지**라 공존한다(교체 아님).
#      dry-run 출력에 다운그레이드가 보이면 거기서 멈추고 전용 venv 를 검토할 것.

# ③ MCP/.env  — 전역 secret 제외라 배포에 포함되지 않는다. 서버에서 직접 작성.
#    MCP_UPSTREAM_BASE=http://127.0.0.1:8000
#    MCP_PUBLIC_URL=https://mcp.taamsglobal.com    ← 비우면 전 요청 421 (아래 참조)
#    MCP_OAUTH_ENABLED=1
#    MCP_JSON_RESPONSE=1

# ④ systemd
sudo cp setup/taams-mcp.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now taams-mcp

# ⑤ nginx  — DNS 보다 **먼저** 한다(아래 ⑥ 참조)
sudo cp setup/mcp.taamsglobal.conf /etc/nginx/sites-available/
sudo ln -s /etc/nginx/sites-available/mcp.taamsglobal.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

# ⑥ DNS (Cloudflare 대시보드 — 아래 "DNS / Cloudflare" 절)

# ⑥ 크론 (아래 "정리 크론")
```

### DNS / Cloudflare

Cloudflare → taamsglobal.com → **DNS → Records → Add record**. A 레코드 하나가 전부다.

| Type | Name | IPv4 | Proxy status | TTL |
|---|---|---|---|---|
| A | `mcp` | `158.247.194.205` | **Proxied**(주황) | Auto |

`admin`·`seller` 와 동일한 형태다. 인증서는 손댈 게 없다 — Origin 인증서 SAN 이
`*.taamsglobal.com` + `taamsglobal.com` 이고 **2041-06 만료**라 `mcp` 가 이미 커버된다.
SSL/TLS 모드(`Full (strict)`)도 존 단위라 자동 상속.

⚠ **회색 구름(DNS-only)로 두지 말 것.** "MCP 는 브라우저 트래픽이 아니니 프록시를
빼는 게 단순하다"는 판단이 자연스럽지만, 그러면 이 서브도메인이 **origin IP 를 그대로
노출**한다. 그 순간 메인 도메인의 Cloudflare 보호까지 함께 무력화된다(공격자가 origin
IP 를 알면 taamsglobal.com 을 우회해 직접 때린다). 서브도메인 하나로 전체가 뚫리는 경로다.

**봇 차단 — 실측으로 해소(2026-08-15).** MCP 클라이언트는 브라우저가 아니라 Cloudflare
봇 방어에 걸릴 수 있는 것이 진짜 위험이었다. 무료 플랜의 Bot Fight Mode 는 **호스트명별
예외가 불가능**해서, 켜져 있었다면 존 전체를 끄거나 DNS-only 로 가는 선택을 강요당했을 것이다.
직접 던져 확인한 결과:

```
POST /api/v1/ulog  UA=python-httpx        → 200   (비브라우저 UA + POST 통과)
GET  admin.taamsglobal.com/healthz        → 200   (Server: cloudflare, CF-RAY 존재)
```

프록시 뒤의 JSON API 서브도메인이 이미 정상 동작한다 → **별도 WAF 예외 규칙 불필요.**
(GEO-STRATEGY §G 의 "AI 크롤러 정상 접근" 관측과도 일치한다)

**순서: nginx 먼저, DNS 나중.** conf 없이 DNS 만 생기면 요청이 nginx default server
(메인 포털)로 떨어져 `mcp.taamsglobal.com` 에서 **메인 사이트가 그대로 서빙**된다 —
검색엔진에 중복 콘텐츠로 잡힌다.

검증:
```bash
nslookup mcp.taamsglobal.com 1.1.1.1     # Cloudflare IP(104.x/172.67.x) 여야 정상
                                          # 서버 IP 가 그대로 보이면 회색 구름이다
curl -s https://mcp.taamsglobal.com/healthz | jq .
curl -s -o /dev/null -w "%{http_code}\n" https://mcp.taamsglobal.com/mcp   # 401 이어야 함
```
⚠ **로컬 PC 의 `nslookup` 을 그냥 쓰지 말 것.** 국내 ISP 가 NXDOMAIN 을 가로채
엉뚱한 IP(`210.220.163.82`)를 돌려준다 — 존재하지도 않는 `mcp.taamsglobal.com` 이
"정상 응답"처럼 보였다(2026-08-15 실제 오독). 반드시 `1.1.1.1` 을 명시할 것.

### nginx 설정

**정본은 `setup/mcp.taamsglobal.conf`** 다. 여기에 스니펫을 다시 적지 않는다 —
이 프로젝트가 반복해 겪은 이중 관리 드리프트를 피한다. 그 파일 상단 주석에
"다른 3개 conf 와 다른 두 가지"와 그 이유가 적혀 있다:

1. **`proxy_buffering off`** — 없으면 Streamable HTTP 응답이 버퍼에 갇힌다.
2. **`proxy_set_header Host $host`** — 원래 Host 를 그대로 넘긴다.

**응답은 SSE 가 아니라 JSON** (`MCP_JSON_RESPONSE=1`, 기본값). 우리 도구는 전부 단순
요청·응답이라 SSE 로 얻는 게 없고, SSE 모드에서는 **서버 예외 없이 응답이 간헐적으로
유실**되는 현상이 실측됐다(2026-08-15). 스트리밍이 필요한 도구가 생기기 전까지는
이 값을 바꾸지 말 것. 다만 세션 알림용 GET 스트림은 남으므로 `proxy_buffering off` 는 유지한다.

⚠ **`proxy_set_header Host 127.0.0.1` 로 우회하지 말 것.** SDK 의 DNS-rebinding 방어가
통째로 무력화돼 임의 도메인이 전부 통과한다. 올바른 방법은 `MCP_PUBLIC_URL` 에
실제 도메인을 넣어 코드가 허용목록에 등록하게 하는 것이다
(`MCP/mcp_app.py::_transport_security`, 회귀 잠금 `test_production_host_is_allowed`).

### 정리 크론 (필수)

만료 세션·카운터를 정리하지 않으면 계속 쌓인다. MCP 루프백 세션은 TTL 이 짧아
웹 세션보다 훨씬 빨리 늘고, `mcp_rate_limit` 의 minute 창은 사용자당 최대 1,440행/일이다.

```bash
python tools/cleanup_sessions.py            # 드라이런
python tools/cleanup_sessions.py --execute  # 실제 삭제

# 서버 크론 (매일 03:00 KST)
0 18 * * * cd /home/ubuntu/foods-portal && venv/bin/python tools/cleanup_sessions.py --execute --quiet
```

### API 키 발급·폐기 — **고객이 직접 한다**

TAAMs **마이페이지 → AI 연결(MCP)** 에서 발급·목록·삭제한다. Pro 등급 이상만
카드가 보인다(free 는 카드 자체가 노출되지 않는다).

- 평문 키는 **발급 직후 한 번만** 표시된다. 서버가 저장하지 않아 이후 복구 불가.
- 계정당 유효 키 5개까지. 삭제하면 그 키로 연결된 도구가 즉시 끊긴다.
- **claude.ai 커넥터는 키가 필요 없다** — 엔드포인트 주소만 넣으면 OAuth 로 붙는다.
  키는 헤더를 직접 지정하는 도구(Claude Code 등)용이다.

⚠ 유료 판정은 `users.user_grade` 가 아니라 **`user_subscriptions`(ACTIVE +
  expires_at > NOW())** 다. `user_grade` 는 main·seller·admin 공용 컬럼이라
  구독이 끝나도 'pro' 로 남는다 — 그것만 보면 **만료 고객이 계속 키를 발급받는다.**
  `routers/mcp_keys.py` 는 `routers/auth.py::_downgrade_expired_subscription` 을
  재사용해 이를 막는다(2026-08-15 실측으로 발견·수정).

운영자 경로(고객 대신 발급해야 할 때만):
```python
from MCP.auth import issue_api_key, revoke_api_key
key = issue_api_key(user_id, label="사용자 노트북")   # 평문은 이때 1회만 반환
revoke_api_key(key[:12])                              # key_prefix 로 폐기
```
⚠ 이 경로에는 **등급 검사가 없다.** 아무 user_id 로도 발급된다 — 운영자가
   판단해서 쓰라는 뜻이고, 고객 자가발급 경로(위)에만 게이트가 있다.

### 사고 대응 — 루프백 세션 일괄 폐기

```python
from MCP.auth import _revoke_loopback_sessions_sync
_revoke_loopback_sessions_sync()          # source='mcp' 전체
_revoke_loopback_sessions_sync(user_id)   # 특정 사용자
```
`user_sessions.source` 컬럼을 둔 실질적 이유가 이것이다.

---

## 8. 관련 문서

| 문서 | 내용 |
|------|------|
| `plans/mcp-rate-limits.md` | 등급별 한도 산정 근거 |
| `setup/GEO-STRATEGY.md` | 에이전트 발견성(Link 헤더·api-catalog·llms.txt) |
| `setup/DEPLOY.md` | 상시 데몬 배포·재시작 규칙 |
| `MCP/schema.sql` | 토큰 2계층 구조와 각 테이블의 설계 근거 |
| `setup/taams-mcp.service` | systemd 유닛 — `--workers 1` 고정 이유 포함 |
| `setup/mcp.taamsglobal.conf` | nginx 정본 — 다른 conf 와 다른 두 지점의 근거 |
| `test/test_mcp_tools.py` | 회귀 잠금 — 가드 우회 변종 9종 포함 |
