Claude.ai를 내 Obsidian 보관함에 연결하는 작동 가능한 OAuth 2.1 MCP 서버 구축 — 전체 가이드와 몇 시간의
요약
Claude.ai와 개인 Obsidian 보관함을 연결하기 위해 OAuth 2.1 및 PKCE를 적용한 MCP 서버를 구축하는 가이드입니다. 외부 프레임워크 없이 순수 Python http.server를 사용하여 VPS 환경에서 보안 연결을 구현하는 과정을 상세히 다룹니다.
핵심 포인트
- OAuth 2.1과 PKCE를 활용한 보안 인증 구현
- 외부 의존성을 최소화한 순수 Python 기반 MCP 서버 구축
- Cloudflare와 Nginx를 이용한 HTTPS 및 리버스 프록시 설정
- Claude.ai(웹/데스크톱/iOS)와 로컬 지식 베이스 간의 읽기/쓰기 권한 부여
Claude.ai(웹, 데스크톱, iOS 모두 동일한 설정으로 연결됨)와 커스텀 MCP 서버를 완전히 작동시킨 후의 전체 기록을 공유하고자 합니다. 이 과정은 예상보다 훨씬 더 깊은 탐구 과정이 되었기에, 실수들을 포함한 전체 경로를 공유합니다. 몇몇 실수들은 조용히 실패하여 단순히 "Claude.ai가 고장 났다"라고 보일 수 있는 종류이기 때문입니다. 요약하자면: (PyYAML을 제외한 외부 의존성 없는) 순수 Python HTTP 서버, OAuth 2.1 + PKCE + 동적 클라이언트 등록 (Dynamic Client Registration), VPS 상의 Nginx + Cloudflare 뒤에서 실행되며, Claude에게 개인 지식 베이스(Obsidian vault)에 대한 읽기/쓰기 권한을 부여합니다. stdio 브릿지도, 프레임워크도 없이 — 표준 라이브러리의 http.server만을 사용했습니다.
Claude.ai (Web / Desktop / iOS) │ OAuth 2.1 + PKCE, 모두 HTTPS ▼ Cloudflare (DNS 프록시 + 보안 규칙) │ ▼ Nginx (리버스 프록시 (reverse proxy), SSL 종료 (SSL termination), SSE 헤더) │ ▼ Python MCP Server (systemd 서비스, 포트 3000) │ ▼ 로컬 파일 (markdown vault)
1단계 — VPS + 도메인: 어떤 작은 VPS라도 작동합니다 (저는 2 vCPU / 4GB 사양을 사용했습니다). 필요한 사항:
- VPS를 가리키는 서브도메인 (예: mcp.yourdomain.com)
- Cloudflare를 통해 프록시 처리된 도메인의 DNS (이것이 중요합니다 — 주의 사항 섹션 참조)
- Let's Encrypt / certbot을 통한 SSL
2단계 — MCP 서버 (Python, 의존성 없음): 처음에는 Node.js + Express + JWT 라이브러리를 시도했지만, VPS에서 의존성 설치 문제에 부딪혀 순수 Python http.server로 전환했습니다 — OAuth/MCP 핵심 기능을 위해 필요한 pip 패키지가 전혀 없습니다 (구조화된 frontmatter 편집을 원하는 경우에만 PyYAML이 필요하며, 이는 apt install python3-yaml로 설치 가능하며 pip는 필요하지 않습니다).
핵심 구조(간소화됨, 전체 버전은 약 500줄): from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import json, hashlib, base64, os, secrets, time
from pathlib import Path
VAULT_PATH = os.getenv('VAULT_PATH', '/opt/mcp-server/vault')
SECRET = os.getenv('MCP_CLIENT_SECRET', 'change-me')
ISSUER = ' https://mcp.yourdomain.com '
REDIRECT_URIS = [ ' https://claude.ai/api/mcp/auth_callback ', ' https://claude.ai/api/auth/callback ', ]
codes, tokens = {}, set()
registered_clients = {'static-client': {'client_secret': SECRET, 'redirect_uris': REDIRECT_URIS}}
def b64url(data):
return base64.urlsafe_b64encode(data).decode('ascii').rstrip('=')
def verify_pkce(verifier, challenge):
return b64url(hashlib.sha256(verifier.encode()).digest()) == challenge
class MCPHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
path, query = parsed.path, parse_qs(parsed.query)
# RFC 9728 - Protected Resource Metadata
if path == '/.well-known/oauth-protected-resource':
self._json(200, {
'resource': ISSUER,
'authorization_servers': [ISSUER],
'bearer_methods_supported': ['header'],
})
return # RFC 8414 - Discovery
if path == '/.well-known/oauth-authorization-server':
self._json(200, {
'issuer': ISSUER,
'authorization_endpoint': f'{ISSUER}/authorize',
'token_endpoint': f'{ISSUER}/token',
'registration_endpoint': f'{ISSUER}/register',
# <- see gotcha #3
'response_types_supported': ['code'],
'grant_types_supported': ['authorization_code', 'refresh_token'],
'code_challenge_methods_supported': ['S256'],
'redirect_uris': REDIRECT_URIS,
})
return
if path == '/authorize':
# validate client_id, redirect_uri, code_challenge (S256 mandatory)
# store code_challenge, issue an auth code, 302 redirect back...
if path == '/': # 최소한의 SSE 엔드포인트, 일부 클라이언트는 연결 시 탐색함
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
self.end_headers()
self.wfile.write(b'data: {"jsonrpc":"2.0","result":{"type":"initialize"}}\n\n')
return
def do_POST(self):
# RFC 7591 - 동적 클라이언트 등록 (Dynamic Client Registration)
if self.path == '/register':
# 새로운 client_id/client_secret을 생성 및 저장하고, 이를 반환함 ...
if self.path == '/token':
# PKCE 검증 (code_verifier의 SHA256 == 저장된 code_challenge)
# client_secret 검증 (정적 또는 동적으로 등록됨)
# Bearer access_token 발급 ...
if self.path in ('/', '/mcp'):
# Authorization: Bearer <token> 확인
# JSON-RPC 본문 파싱, "method"에 따라 디스패치(dispatch) 수행:
# initialize -> protocolVersion + capabilities + serverInfo 반환
# tools/list -> 도구 정의(tool definitions) 반환
# tools/call -> 도구 실행, {content: [...]} 반환
...
if __name__ == '__main__':
server = ThreadingHTTPServer(('0.0.0.0', 3000), MCPHandler) # <- HTTPServer가 아님, 주의사항 #5 참조
server.serve_forever()
Step 3 — Nginx 리버스 프록시 (reverse proxy)
여기서 SSE 전용 헤더는 선택 사항이 아닙니다. 이 헤더가 없으면 연결이 중단되거나 끊어집니다:
server {
listen 443 ssl http2;
server_name mcp.yourdomain.com ;
ssl_certificate /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem;
location / {
proxy_pass http://localhost:3000 ;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE / 스트리밍 (streaming)을 위해 필수
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_read_timeout 86400s;
}
}
Step 4 — systemd 서비스 (service)
[Unit]
Description=MCP Server
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/mcp-server
ExecStart=/usr/bin/python3 -u /opt/mcp-server/server.py
Restart=always
RestartSec=10
Environment="PORT=3000"
[Install]
WantedBy=multi-user.target
-u 플래그가 중요한 이유 — Python은 TTY가 아닐 때 기본적으로 stdout을 버퍼링하므로, 이 플래그가 없으면 print() 디버그 로그가 journalctl에 전혀 나타나지 않습니다.
5단계 — Cloudflare 설정
- 도메인을 Cloudflare 뒤에 배치합니다 (오렌지 클라우드 프록시 활성화).
- OAuth 경로에 대해 봇 보호(bot protection)를 우회하는 보안 규칙(Security Rule)을 생성합니다:
(http.request.uri.path contains "/authorize") or (http.request.uri.path contains "/token") or (http.request.uri.path contains "/.well-known") or (http.request.uri.path contains "/register")
→ Action: Skip
→ WAF components to skip: All managed rules - AI 크롤 제어(AI Crawl Control) → 보안(Security) 항목 아래에서, Claude-User 에이전트를 명시적으로 허용합니다 (이는 Anthropic의 실시간 페치(live-fetch) 식별자로, 학습용 크롤러와는 별개입니다). 제 경우에는 이것이 기본적으로 차단되어 조용한 403 오류가 발생했습니다.
실제로 시간을 잡아먹은 5가지 주의사항:
- Claude는 MCP JSON-RPC 호출을 단순히 POST /mcp가 아니라 POST /로 보냅니다. /mcp만 구현하면 OAuth는 성공한 것처럼 보이지만 로그에는 404 오류가 나타날 것입니다.
- tools/list가 작동하기 전에 MCP 초기화 핸드셰이크(initialize handshake)가 필수적입니다. 만약 JSON-RPC 디스패처가 method: "initialize" (protocolVersion, capabilities, serverInfo 반환)를 명시적으로 처리하지 않으면, Claude.ai는 "연결됨(connected)"이라고 표시하지만 "사용 가능한 도구 없음(no tools available)"이라고 나타납니다. 에러는 발생하지 않고 그냥 침묵할 뿐입니다.
- Claude.ai는 동적 클라이언트 등록(Dynamic Client Registration, RFC 7591)을 수행합니다. 커넥터 UI에 입력한 Client ID/Secret만 사용하는 것이 아니라, 발견 문서(discovery document)에 registration_endpoint를 공지하면 스스로 POST /register를 호출할 수 있습니다. 이 엔드포인트가 없으면 수동으로 설정한 자격 증명만으로는 충분하지 않을 때가 있습니다.
- RFC 9728 보호된 리소스 메타데이터(Protected Resource Metadata)와 WWW-Authenticate 헤더가 중요합니다. 401 오류 발생 시 다음을 반환하세요: WWW-Authenticate: Bearer resource_metadata="https://mcp.yourdomain.com/.well-known/oauth-protected-resource" 그리고 해당 URL에 {"resource": ..., "authorization_servers": [...]}를 응답으로 제공해야 합니다.
이것은 MCP 인증 사양(auth spec)의 비교적 최신 부분이며, 이전 가이드들을 따르고 있다면 놓치기 쉽습니다. 가장 중요한 점은 HTTPServer가 아니라 ThreadingHTTPServer를 사용해야 한다는 것입니다. 이것이 제 경우에 발생했던 "연결은 되었으나 사용 가능한 도구가 없음" 문제의 실제 근본 원인이었으며, 저는 이것이 다른 곳에서 "Claude.ai 버그"라고 비난받았던 수많은 유사한 보고 사례들을 설명해 줄 것이라고 강력하게 의심합니다. 만약 GET / 엔드포인트가 Content-Type: text/event-stream (SSE)을 반환한다면, 제대로 동작하는 클라이언트는 이벤트를 기다리며 해당 연결을 계속 열어둡니다. 단일 스레드(single-threaded) HTTP 서버는 한 번에 단 하나의 연결만 처리할 수 있습니다. 따라서 그 하나의 열려 있는 SSE 연결이 도구를 실제로 전달하는 후속 POST / 호출을 서버가 처리하지 못하도록 전체 서버를 차단해 버립니다. 도구 목록이 조용히 로드되지 않을 때까지는 모든 것이 정상적으로 보입니다 (OAuth가 완료되고, 토큰 문제도 없습니다). 코드 한 줄을 변경(HTTPServer → ThreadingHTTPServer)하는 것만으로 즉시 해결되었습니다. 최종 결과: Claude (웹 + 데스크톱 + iOS, 동일한 커넥터, 동일한 자격 증명)는 이제 다음을 수행할 수 있습니다:
- 보관함(vault) 내의 모든 노트 목록화 및 검색
- 개별 노트 및 해당 YAML 프론트매터(frontmatter)/속성(properties) 읽기
- 노트 생성, 편집 및 삭제 (삭제 시 완전 삭제 대신
.trash폴더로 이동) - 폴더 생성, 노트 이동/이름 변경, 기존 노트에 내용 추가 (예: 데일리 로그)
모든 쓰기 가능 도구는 Claude.ai의 도구별 권한 설정에서 "승인 필요(Needs approval)"로 설정되어 있으므로, 명시적인 확인 없이는 아무것도 일어나지 않습니다. 만약 누군가 동일한 "인증되었으나 도구가 없음"이라는 벽에 부딪혀 싸우고 있다면 기꺼이 질문에 답해 드리겠습니다. 특히 5번째 함정(gotcha #5)이 누군가의 시간을 몇 시간이라도 아껴줄 수 있기를 바랍니다. /u/martn_lrnce 제출 [링크] [댓글]
AI 자동 생성 콘텐츠
본 콘텐츠는 r/ClaudeAI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기