<!-- canonical: https://docs.axelabs.ai/architecture/mcp-server-checklist -->
<!-- source: content/architecture/mcp-server-checklist.mdx -->

---
title: MCP 서버 개발 체크리스트
description: frame / hive / blueprint 누적 lesson — 양파껍질 5층 + Azure manifest 9 항목 + 운영 21 항목 (skill-integration 5 포함).
---

# MCP 서버 개발 체크리스트

claude.ai Custom Connector + Claude Code 가 받는 표준 MCP 서버를 추가할 때, **frame · hive 가 검증한 byte-by-byte 패턴을 1:1 으로 복사**해야 한다. 비슷하게 만들면 5층 양파껍질이 차례로 폭로된다 (Blueprint 2026-05-21 실제 사고).

> **핵심 원칙**: 새 MCP 서버는 `cp -R /Users/axe/frame/src/frame /tmp/...` 부터 시작하라. "유사하게" 가 아니라 "diff 가 없을 때까지". 단 4 단어 (`audience=[client_id, app_id_uri]`) 빠뜨려도 OAuth 통과 후 401 무한루프.

---

## 0. 가기 전 — 결정 (D-bp-mcp-1, 2026-05-21)

| 결정 | 이유 |
|---|---|
| **Python + FastMCP + Starlette + uvicorn** 으로 통일 | claude.ai 의 Streamable HTTP 가 frame 의 wire-level response 만 수용. Node port 시도 (visible response 동일) 도 silent reject. |
| DB 는 **기존 Prisma/Drizzle/SQLAlchemy schema 를 raw SQL 로 read-only access** | 별도 schema 필요 없음. SQLAlchemy `text("SELECT ... FROM \"User\" WHERE ...")` 패턴. |
| **OAuth Resource Server (RP)** 직접 — 자체 AS 구축 금지 | D-ops-15 dead-end: claude.ai 는 외부 AS 의 PKCE 우회 못함. 무조건 Microsoft Entra ID 직결. |
| **standalone 컨테이너** — main service 컨테이너에 mount X | claude.ai connector probe 가 Next.js 의 `vary: rsc, ...` auto-header 거부. 최소 header 의 standalone Python 필요. |

---

## 1. Azure Entra ID app registration — 9 항목

`az ad app create` 한 줄로 끝나지 않는다. 각 항목 확인.

```bash
# 1. app + sp 동시 생성
az ad app create --display-name "Blueprint MCP" --sign-in-audience AzureADMyOrg
az ad sp create --id APP_ID   # ← 명시적 SP 생성, 자동 생성 안 됨

# 2. identifierUris (= App ID URI)
az ad app update --id APP_ID --identifier-uris "https://axe.axelabs.ai/blueprint/mcp"

# 3. accessTokenAcceptedVersion = 2 (v2 endpoint 가 표준)
az rest --method PATCH --uri "https://graph.microsoft.com/v1.0/applications/OBJECT_ID" \
  --body '{"api":{"requestedAccessTokenVersion":2}}'

# 4. oauth2PermissionScopes (delegated permission)
az rest --method PATCH ... --body '{
  "api": {"oauth2PermissionScopes":[{
    "id":"RANDOM_UUID",
    "value":"mcp.access",
    "type":"User",
    "isEnabled":true,
    "adminConsentDisplayName":"Access Blueprint MCP",
    "userConsentDisplayName":"Access Blueprint MCP"
  }]}
}'

# 5. redirectUris (claude.ai 의 양쪽 도메인)
az rest --method PATCH ... --body '{"web":{"redirectUris":[
  "https://claude.ai/api/mcp/auth_callback",
  "https://claude.com/api/mcp/auth_callback"
]}}'

# 6. requiredResourceAccess (Microsoft Graph User.Read — id_token 의 email claim 발급)
az rest --method PATCH ... --body '{"requiredResourceAccess":[{
  "resourceAppId":"00000003-0000-0000-c000-000000000000",
  "resourceAccess":[{"id":"e1fe6dd8-ba31-4d61-89e7-88639da4683d","type":"Scope"}]
}]}'

# 7. implicitGrantSettings.enableIdTokenIssuance = true
az rest --method PATCH ... --body '{"web":{"implicitGrantSettings":{"enableIdTokenIssuance":true}}}'

# 8. client_secret 생성 + vault push (stderr 분리 필수)
az ad app credential reset --id APP_ID --years 2 --query password -o tsv 2>/dev/null \
  | axe secret push CUSTOMER/SERVICE/mcp-client-secret -

# 9. tenant admin consent (필요 시 Azure Portal 에서 1 클릭)
#    User.Read + mcp.access 둘 다 grant
```

**함정 — `az` stderr 가 password 와 합쳐져 vault corrupt (Blueprint 270자 사건 2026-05-21)**: 반드시 `2>/dev/null` 로 stderr 분리.

**검증**:

```bash
az ad app show --id APP_ID --query "{accessTokenAcceptedVersion:api.requestedAccessTokenVersion, identifierUris:identifierUris, signInAudience:signInAudience}" -o json
```

frame · hive · blueprint 비교 시 차이 0 이어야 한다. 차이 있으면 fix.

**별개 — app-only write 기능의 Graph Application permission**: 위 9 항목은 **MCP connector app** (token 검증 + id_token email claim 용 `User.Read`) 한정이다. Blueprint 의 admin send-as write 기능은 app-only token (`getAppOnlyClient()`, client-credentials)을 쓰므로 **Blueprint Next.js app (`2b222356-1c36-48e0-96a3-2c5e0ecbf937`)** 에 Application permission 을 부여해야 한다 — connector app (`482598f7-...`) 이 아니다 (함정 [/ops/known-gaps#blueprint-azure-app-id-혼동](/ops/known-gaps#blueprint-azure-app-id-혼동)):

| 기능 | Application permission | 결정 |
|---|---|---|
| calendar send-as (`as_user_email`) | `Calendars.ReadWrite` | D-bp-mcp-calendar-2 |
| `send_mail` send-as (`as_user_email`) | `Mail.Send` | D-bp-mcp-mail-1 |

부여 후 `az ad app permission admin-consent --id 2b222356-1c36-48e0-96a3-2c5e0ecbf937` (Global Admin) → `docker restart blueprint-app-blue` (또는 -green; MSAL client-credential in-memory 캐시 폐기). 상세 = [/architecture/auth § app-only Application permission (send-as)](../architecture/auth). 비-admin caller 의 send-as 시도는 코드가 403 `send_as_forbidden` 로 차단하므로 self (delegated) 경로는 이 permission 없이도 동작.

---

## 2. cloudflared ingress — 1 줄 (path-based)

`/Users/axe/.axe/tunnels/axelabs/config.yml` 의 `ingress:` 에 prepend:

```yaml
- hostname: axe.axelabs.ai
  path: ^/SERVICE/mcp(/.*)?$
  service: http://host.docker.internal:PROXY_PORT

- hostname: axe.axelabs.ai
  path: ^/SERVICE/\.well-known/oauth-protected-resource(/.*)?$
  service: http://host.docker.internal:PROXY_PORT
```

**함정**: cloudflared 는 **path prefix 를 strip 하지 않는다**. 컨테이너가 `/SERVICE/mcp` 를 그대로 받음. 따라서 Starlette 라우트도 `/SERVICE/mcp` 에 mount.

---

## 3. Caddy reverse proxy (axe-SERVICE-mcp-proxy)

frame-proxy / hive-proxy 미러. cloudflared 가 graceful reload 미지원이라 사이에 끼움 (blue/green swap 시 sub-second).

`/Users/axe/.axe/SERVICE-mcp-proxy/Caddyfile`:

```
:80 {
    reverse_proxy SERVICE-mcp:3000 {
        lb_try_duration 5s
        lb_try_interval 250ms
        header_up Host {http.request.host}
    }
    encode gzip
    log { output stdout; format console; level INFO }
}
```

**함정**: nested `handle_path` syntax 쓰지 마. 단순 `reverse_proxy` 만 (Blueprint Caddyfile syntax error 2026-05-21).

---

## 4. Python 코드 — frame `auth_oidc.py` + `mcp/http_server.py` 1:1 미러

### 4.1 `auth_oidc.py` — JWKS + 토큰 검증 (∼180 줄)

**필수 4 가지 (Blueprint 양파껍질 1·3 사건)**:

```python
# ① httpx.Timeout — 4-arg 또는 default 명시. partial kwargs 는 0.28 부터 ValueError.
_JWKS_HTTP_TIMEOUT = httpx.Timeout(connect=2.0, read=3.0, write=2.0, pool=5.0)
#                                                          ^^^^^^^^^^^^^^^^^^^^ 빠뜨리면 500

# ② audience = [client_id GUID, application_id_uri]
#    v2 endpoint 의 access_token aud = client_id GUID (NOT App ID URI).
#    PyJWT 가 list 의 any 매칭. 둘 다 줘야 v1/v2 양립.
expected_audiences: list[str] = [get_client_id(), get_app_id_uri()]
payload = pyjwt.decode(
    token, public_key,
    algorithms=["RS256"],
    audience=expected_audiences,       # ← list, 단일 string 금지
    issuer=[get_microsoft_issuer(), get_microsoft_issuer_v1()],
    options={"require": ["exp","iat","iss","aud","sub"]},
    leeway=60,
)

# ③ JWKS cache + fail cooldown + kid-miss force refetch
#    Microsoft 의 key rotation 대응. frame/auth_oidc.py:47-78 그대로.

# ④ email claim fallback 순서
email = (payload.get("email")
         or payload.get("preferred_username")
         or payload.get("upn") or "").strip().lower()
```

### 4.2 `mcp/http_server.py` — Starlette + FastMCP wiring (∼270 줄)

**필수 5 가지**:

```python
# ① lifespan forward — Starlette Mount 는 child lifespan 미전파
def build_app() -> Starlette:
    mcp_app = mcp.streamable_http_app()
    @asynccontextmanager
    async def _lifespan(app: Starlette):
        async with mcp_app.router.lifespan_context(app):  # ← 안 부르면
            try:
                yield                                       #   "Task group not initialized"
            finally:
                await db_shutdown()

# ② 401 reason 노출 (body + log)
def _unauthorized(request, request_id, reason):
    log.warning("401 %s reason=%s", request.url.path, reason)
    body = {"error": {
        "code": _map_code(reason),    # MISSING_TOKEN / INVALID_TOKEN / USER_NOT_PROVISIONED / UNAUTHORIZED
        "message": reason,             # ← 고정 string 금지. reason 그대로.
        "context": {"request_id": request_id, "reason": reason},
        ...
    }}
    # WWW-Authenticate resource_metadata = APP_ID_URI 에서 /mcp suffix strip + /.well-known/...

# ③ FastMCP DNS-rebinding guard 우회 — main() 안에서 build_app() 전에:
allowed_hosts = os.environ.get("SERVICE_MCP_ALLOWED_HOSTS")
if allowed_hosts:
    mcp.settings.transport_security.allowed_hosts = [h.strip() for h in allowed_hosts.split(",") if h.strip()]
# 안 하면 axe.axelabs.ai 가 421 Misdirected Request

# ④ Route mount: cloudflared 가 path 그대로 보내므로 `/SERVICE/` mount + 화이트리스트
routes = [
    Route("/health", health),
    Route("/SERVICE/health", health),
    Route("/SERVICE/.well-known/oauth-protected-resource", oauth_protected_resource),
    Mount("/SERVICE", app=mcp_app),
]

# ⑤ JWTAuthMiddleware 의 public-path 화이트리스트도 `/SERVICE/` prefix 포함
if path in ("/health", "/SERVICE/health", "/SERVICE/.well-known/oauth-protected-resource", ...):
    return await call_next(request)
```

---

## 5. docker-compose.yml — 5 항목

```yaml
mcp:
  build: { context: .., dockerfile: mcp/Dockerfile }
  container_name: SERVICE-mcp
  env_file: ../.env
  environment:
    - SERVICE_MCP_PORT=3000
    - SERVICE_MCP_APP_ID_URI=${SERVICE_MCP_APP_ID_URI:-https://axe.axelabs.ai/SERVICE/mcp}
    - AZURE_AD_TENANT_ID=${AZURE_AD_TENANT_ID:-122fb574-...}
    # ① allowed_hosts (axe.axelabs.ai + 컨테이너 DNS + localhost)
    - SERVICE_MCP_ALLOWED_HOSTS=SERVICE-mcp:*,localhost:*,127.0.0.1:*,host.docker.internal:*,axe.axelabs.ai
    - SERVICE_MCP_ALLOWED_ORIGINS=https://axe.axelabs.ai,https://claude.ai,https://claude.com
  networks:
    default:
      aliases:
        - SERVICE-mcp
    # ② artemis network 도 참여 (axelabs-tunnel 같은 네트워크여야 cloudflared 가 닿음)
    artemis:
  # ③ Python 이미지면 curl healthcheck (Node 잔재 `node -e fetch(...)` 금지)
  healthcheck:
    test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/health"]
    interval: 30s
    timeout: 5s
    retries: 3
    start_period: 10s
  depends_on:
    postgres:
      condition: service_healthy
```

**함정 (Blueprint 양파껍질 5 사건)**: healthcheck 가 Node 잔재면 컨테이너 영원히 `unhealthy`. Python 이미지면 curl 만.

---

## 6. customers.yaml entry — D-ops-17 매니페스트

```yaml
azure_apps:
  CUSTOMER:
    SERVICE_mcp:                # D-SERVICE-mcp-1
      client_id: "GUID"
      application_id_uri: "https://axe.axelabs.ai/SERVICE/mcp"
      scopes:
        - "https://axe.axelabs.ai/SERVICE/mcp/mcp.access"
      # client_secret 는 vault 에서 pull (env 명시)
      client_secret_env: "AZURE_SERVICE_MCP_CLIENT_SECRET"

services:
  SERVICE:
    env_file: "/Users/axe/SERVICE/.env"
    secrets:
      - { env: AZURE_SERVICE_MCP_CLIENT_SECRET, vault: "SERVICE/CUSTOMER/mcp-client-secret", rotation_external: azure }
      # 기타 비밀들 ...
```

`axe secret push PATH -` 로 push, `axe secret pull SERVICE` 로 deploy-time pull.

---

## 7. 등록 절차 — 사용자 화면 (claude.ai)

1. Settings → Connectors → **+ Add custom connector**
2. URL: `https://axe.axelabs.ai/SERVICE/mcp`
3. Client ID + Client Secret 입력 (vault 에서 `axe secret get SERVICE/CUSTOMER/mcp-client-secret -n`)
4. Microsoft 로그인 (axellc.com 계정) → 동의 → claude.ai 복귀
5. **성공 신호**: connector 상세에 tool 목록이 노출됨 (whoami, list_..., 등)

**실패 시 사용자 화면 메시지 → 진짜 원인 매핑** (Blueprint 5 fix 회고):

| claude.ai 메시지 | 실제 원인 | 진단 path |
|---|---|---|
| "the integration may not be available right now due to a temporary error" | server 가 500 응답 (verifier 자체 ValueError 등) | `docker logs SERVICE-mcp` traceback |
| "the integration rejected the credentials, so the connection was reverted" | server 가 401 응답 | log.warning `reason=...` 확인 (`§ 4.2 ②` fix 필수) |
| "Audience doesn't match" (응답 body) | `audience=` 가 list 아님 (`§ 4.1 ②`) | aud claim 확인 — v2 면 client_id GUID |
| "Task group is not initialized" (응답 body 또는 컨테이너 로그) | lifespan forward 누락 (`§ 4.2 ①`) | boot log 에 "StreamableHTTP session manager started" 있어야 함 |
| "Invalid Host header" + 421 | allowed_hosts 미설정 (`§ 4.2 ③`) | `BLUEPRINT_MCP_ALLOWED_HOSTS` env 확인 |

---

## 8. 운영 — 21 가지 체크포인트

배포 후 다음을 모두 확인. 어느 하나 실패 시 production 으로 안 보냄.

- **#1–#16 = 전송·인증·부팅 레이어** (frame · hive · blueprint 가 검증한 wire/OAuth/DB 항목).
- **#17–#21 = skill-integration 레이어** ([index](../services/index) 가 도입). MCP 서버가 *판단 도구* (propose_deal_closure 류 write tool + ic / pmc skill 호출) 를 노출하면, 도구가 idempotent 한지 · citation 이 roundtrip 하는지 · skill 끼리 충돌 시 audit 에 남는지 · 의존 down 시 graceful 한지 · schema 가 진화해도 옛 artifact 가 읽히는지를 추가로 검증한다. write tool 없는 read-only MCP (matrix 류) 는 #17–#21 면제.

| # | 검증 | 명령 | 정답 |
|---|---|---|---|
| 1 | 컨테이너 healthy | `docker ps \| grep SERVICE-mcp` | `Up X (healthy)` |
| 2 | health endpoint | `curl https://axe.axelabs.ai/SERVICE/health` | 200 OK |
| 3 | OAuth metadata | `curl https://axe.axelabs.ai/SERVICE/.well-known/oauth-protected-resource` | 200 + 정확한 issuer/scopes |
| 4 | Bearer 없음 → 401 | `curl -X POST https://axe.axelabs.ai/SERVICE/mcp` | 401 + `WWW-Authenticate: Bearer ... resource_metadata="..."` |
| 5 | fake Bearer → 401 with reason | `curl -X POST ... -H "Authorization: Bearer xxx"` | 401 + body.error.context.reason 노출 |
| 6 | boot log: session manager | `docker logs SERVICE-mcp` | `StreamableHTTP session manager started` |
| 7 | JWKS fetch 성공 | (첫 인증 호출 후) `docker logs` | `GET .../discovery/v2.0/keys "HTTP/1.1 200 OK"` |
| 8 | 컨테이너 DNS alias | `docker exec axelabs-tunnel ping -c1 SERVICE-mcp` | 응답 |
| 9 | Caddy → backend | `docker logs axe-SERVICE-mcp-proxy` | `upstream "SERVICE-mcp:3000"` |
| 10 | cloudflared ingress | `cloudflared tunnel info axelabs` | hostname 포함 |
| 11 | Azure app manifest | `az ad app show --id APP_ID` | accessTokenAcceptedVersion=2, identifierUris=URL 형식 |
| 12 | SP 존재 | `az ad sp list --filter "appId eq 'APP_ID'"` | non-empty |
| 13 | vault secret 유효 | `axe secret get PATH -n \| wc -c` | 40~50 자 (270 자면 stderr 섞임) |
| 14 | claude.ai 직접 등록 | 위 § 7 의 1~5 단계 | tool 목록 노출 |
| 15 | **DATABASE_URL 유효** (D-bp-mcp-3) | `docker exec SERVICE-mcp printenv DATABASE_URL` | `postgresql://...` 또는 `postgresql+asyncpg://...` (SQLite `file:./...` 면 = .env 잔재 → 제거) |
| 16 | **startup probe 통과** (D-bp-mcp-3) | `docker logs SERVICE-mcp \| grep "startup probe"` | `startup probe: DB reachable, schema accessible` (없으면 lifespan fail = DB 미접속). ⚠️ `/health/ready` endpoint 만 가진 service (frame · hive 2026-05-22 상태) 는 broken DB 로 부팅 가능 — first request 가 와야 fail → blue/green swap promote 위험. **lifespan 안에서 SELECT 1 1 블록 호출** 패턴 강제. `blueprint/mcp/src/blueprint_mcp/mcp/http_server.py:238-260` 1:1 미러. |
| 17 | **skill write tool idempotency** (D-index-13) | 동일 input 으로 write tool 2회 호출 (예: `propose_deal_closure(deal_id=X, idempotency_key=K)` 두 번) | 1회차 = 적용 + `(artifact_id, was_inserted=true)`, 2회차 = **`409` / `IdempotencyConflict`** (또는 `was_inserted=false` idempotent skip) — **두 번째 호출이 중복 ledger row 를 만들면 FAIL**. index 는 `idempotency_record` 테이블 (hive 패턴 mirror, 24h TTL) 로 atomic propose 의 race 차단 — `index/migrations/20260528000001_shared_schema.up.sql` + `error.rs::IndexError::IdempotencyConflict` (→ `StatusCode::CONFLICT`). agent 가 retry/재호출해도 안전해야 함 (네트워크 timeout 후 재시도 = 흔함). |
| 18 | **citation resolver roundtrip** (durable ≠ fragile) | propose 한 artifact 의 citation 을 다시 resolve → 원본 anchor 와 대조 | resolve 한 값이 원본과 **상대오차 ±0.001% 이내** (재무 수치는 bit-exact 권장). `durable` citation (anchor = sha256 / drive_item_id / frame journal_id 같은 stable ref) 은 100% 재현, `fragile` citation (텍스트 anchor·페이지 추정) 은 별도 카운트되어 노출되어야 함 — `index/src/html.rs` 의 `citation_durable_total` / `citation_fragile_total`. fragile 비율이 0 이 아니면 status board 에 표시 (숨기면 FAIL). |
| 19 | **cross-skill conflict → audit_trail** (silent resolve 금지) | 두 skill (예: ic 재무모델 vs frame 실적분개) 이 같은 metric 에 다른 값을 propose → conflict 감지 | 충돌이 **자동으로 한쪽을 골라 덮어쓰지 않고**, `artifact_event` append-only 감사 로그 (op=propose) + reconcile 레이어에 `{min, max, relative_gap, [{value, source_file, location}]}` 로 노출되어야 함. index 는 상대갭 **10% 초과** 시 metric group 을 conflict 로 flag (`artifact.rs` conflict guard) — "conflict 는 metric 레벨에서 표면화, 절대 silent 해결 안 함". 판단 도구가 충돌을 조용히 삼키면 FAIL. |
| 20 | **graceful degradation** (의존 down 시 markdown-only / warn) | 의존 서비스 1개 강제 정지 후 도구 호출 (예: skill 의 humanize sub-agent OR frame MCP upstream 차단) | 전체 pipeline 이 **panic / 502 로 죽지 않고** degrade — index ic skill 은 humanize 2회 실패 시 `degrade-to-warn` (결과 폐기 → `pre_humanize.attempt1` backup 으로 PDF 렌더 + 워터마크, 진행은 막지 않음, `skills/ic/SKILL.md`). blue/green Caddy 는 한 컬러 down 시 다음 요청을 살아있는 컬러로 자동 라우팅 (graceful reload). 의존 부재가 hard-fail 을 일으키면 FAIL. |
| 21 | **schema evolution 하위호환** (옛 artifact 읽힘) | `schema_version` 을 올린 새 코드로 **이전 버전 artifact** 를 read/list | 옛 row 가 그대로 역직렬화되어야 함 — index artifact 는 `schema_version integer NOT NULL DEFAULT 1` (`migrations/20260526210000_initial_artifact_schema.up.sql`) + back-compat shim (`artifact.rs`: 기존 caller 는 default provenance `skill="manual"`) + seed 가 "schema 가 실데이터와 backward-compatible" 임을 증명 (`seed.rs`). migration 후 옛 artifact 가 깨지면 FAIL. **acceptance gate**: index 는 `cargo test` (seed roundtrip + 23 live seed 역직렬화) 를 배포 게이트로 사용 — read tool 변경 시 이 gate 통과 필수. |

> **#17–#21 운영 모니터 (배포 후 24h)**: idempotency 위반 (중복 ledger) · fragile citation 급증 · 미해결 conflict · degrade-to-warn 발동 빈도 · schema 역직렬화 실패는 **status board 지표**다 (index `/index` status page 의 `artifact_event` append-only count + `citation_durable/fragile` + `conflict` 카운트). 첫 24h 동안 fragile 비율 · conflict 건수 · degrade 발동을 관찰하고, 비정상 spike 면 promote 보류. read-only MCP (matrix) 는 write tool 이 없어 #17·#19 비해당, #18·#20·#21 도 노출 도구 한정으로 N/A 처리 가능.

---

## 9. 디버깅 — 첫 번째 행동

증상이 무엇이든 **이것부터**:

```bash
docker logs SERVICE-mcp --since 5m 2>&1 | grep -iE "401|reason|warning|error|exception|task group|invalid" | head -30
```

그 다음 proxy:

```bash
docker logs axe-SERVICE-mcp-proxy --since 5m 2>&1 | grep -E '"status": [45]' | tail -20
```

응답 body 가 진단 정보 들어있는지 확인:

```bash
curl -X POST https://axe.axelabs.ai/SERVICE/mcp \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZXN0IjoxfQ.invalid" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}' | jq .error
```

`.error.message` 와 `.error.context.reason` 이 모두 노출되어야 정상 (`§ 4.2 ②` 통과).

---

## 10. 양파껍질 회고 (Blueprint MCP, 2026-05-21)

5 단계 모두 frame 코드에 byte-by-byte 미러했더라면 0 단계.

| 차단 | 메시지 | 원인 | Fix |
|---|---|---|---|
| 1 | "integration may not be available" (500) | `httpx.Timeout(connect, read)` 2-arg | 4-arg form |
| 2 | (모든 401 이 같은 메시지) | `_unauthorized` body 가 reason 무시 | reason → body + log |
| 3 | "rejected the credentials" (401) | `audience=app_id_uri` 단일 | `audience=[client_id, app_id_uri]` |
| 4 | (auth 통과 후 첫 호출) `Task group not initialized` | Starlette Mount 가 FastMCP lifespan 미전파 | `lifespan=inner.router.lifespan_context` wrap |
| 5 | 421 `Invalid Host header: axe.axelabs.ai` | DNS rebinding guard | `mcp.settings.transport_security.allowed_hosts` env |
| 6 | "Authorization with the MCP server failed" (claude.ai UI, 종일 broken, D-bp-mcp-3 2026-05-22) | D-config-17 Postgres cutover 가 `.env` 의 `DATABASE_URL="file:./data/blueprint.db"` SQLite legacy line 안 지움 → mcp 가 받음 → SQLAlchemy `ArgumentError` deep in ASGI → 모든 Bearer 요청 silent 500 → claude.ai 는 일반적 OAuth fail 로 표시 → 운영자가 secret 만 의심 | (a) `.env` 의 stale `DATABASE_URL` line 제거 (compose default `${DATABASE_URL:-postgresql://...}` 적용), (b) `config.get_database_url()` fail-fast on non-postgres URL, (c) Starlette lifespan 에 `SELECT 1` startup probe → DB 미접속 시 healthcheck fail → blue/green swap 거부 |

**총 6 PR (#331~#335, #346)**, 약 2 시간. 같은 시간에 frame 1:1 copy + Postgres cutover env audit 했다면 30 분.

**Lesson 항구화**:
1. 새 MCP 서버 = `cp -R frame/src/frame /tmp/NEW` 부터.
2. 첫 PR 부터 `_unauthorized` reason 노출 — 진단 도구가 우선.
3. 등록 시도 시 claude.ai 의 generic 메시지 ↔ 컨테이너 로그 매핑 표 (`§ 7`) 참조.
4. **DB 종속 service 는 startup probe 필수** — lifespan 에서 `SELECT 1` 호출, 실패 시 raise → healthcheck fail → blue/green swap 거부. silent runtime 500 보다 명시적 부팅 실패가 훨씬 안전.
5. **env_file 잔재 검사** — 매 service launch 전 `docker exec SERVICE printenv DATABASE_URL` 가 정확한 형식인지 § 8 #15~16 으로 확인.

---

## 참고

- frame 구현: `/Users/axe/frame/src/frame/{auth_oidc.py, mcp/http_server.py}`
- hive 구현: `/Users/axe/hive/src/hive/{auth_oidc.py, mcp/http_server.py}`
- blueprint 구현: `/Users/axe/blueprint/mcp/src/blueprint_mcp/{auth_oidc.py, mcp/http_server.py}` (2026-05-21 standalone)
- index 구현 (skill-integration 레이어 #17–#21): `/Users/axe/index/src/{artifact.rs, error.rs, html.rs, seed.rs}` + `migrations/20260528000001_shared_schema.up.sql` (idempotency_record) — [/services/index](../services/index)
- 인증 흐름: [/architecture/auth](../architecture/auth)
- 비밀 매니페스트: [/architecture/secrets](../architecture/secrets) (D-ops-17)
- 결정 기록: [/ops/decisions](../ops/decisions)
