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 한 줄로 끝나지 않는다. 각 항목 확인.
# 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 분리.
검증:
az ad app show --id APP_ID --query "{accessTokenAcceptedVersion:api.requestedAccessTokenVersion, identifierUris:identifierUris, signInAudience:signInAudience}" -o jsonframe · 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-혼동):
| 기능 | 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). 비-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:
- 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 사건):
# ① 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 가지:
# ① 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 항목
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 매니페스트
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)
- Settings → Connectors → + Add custom connector
- URL:
https://axe.axelabs.ai/SERVICE/mcp - Client ID + Client Secret 입력 (vault 에서
axe secret get SERVICE/CUSTOMER/mcp-client-secret -n) - Microsoft 로그인 (axellc.com 계정) → 동의 → claude.ai 복귀
- 성공 신호: 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 가 도입). 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
/indexstatus page 의artifact_eventappend-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. 디버깅 — 첫 번째 행동
증상이 무엇이든 이것부터:
docker logs SERVICE-mcp --since 5m 2>&1 | grep -iE "401|reason|warning|error|exception|task group|invalid" | head -30그 다음 proxy:
docker logs axe-SERVICE-mcp-proxy --since 5m 2>&1 | grep -E '"status": [45]' | tail -20응답 body 가 진단 정보 들어있는지 확인:
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 항구화:
- 새 MCP 서버 =
cp -R frame/src/frame /tmp/NEW부터. - 첫 PR 부터
_unauthorizedreason 노출 — 진단 도구가 우선. - 등록 시도 시 claude.ai 의 generic 메시지 ↔ 컨테이너 로그 매핑 표 (
§ 7) 참조. - DB 종속 service 는 startup probe 필수 — lifespan 에서
SELECT 1호출, 실패 시 raise → healthcheck fail → blue/green swap 거부. silent runtime 500 보다 명시적 부팅 실패가 훨씬 안전. - 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 - 인증 흐름: /architecture/auth
- 비밀 매니페스트: /architecture/secrets (D-ops-17)
- 결정 기록: /ops/decisions