URL 하나로 MCP를 연결하기까지
목표는 단순했다.
https://geul.io/mcp 여기에 MCP를 등록해줘.
사용자는 client ID, callback 포트, 제품별 설정을 몰라도 되어야 했다. 하지만 이 한 줄을 구현하려면 MCP 전송, OAuth discovery, 클라이언트 등록, PKCE, 권한 검사를 모두 연결해야 했다.
로그인은 성공했는데 도구가 보이지 않았다
첫 문제는 OAuth 로그인이 끝났는데 MCP 도구가 나타나지 않는 현상이었다.
로그를 확인하니 클라이언트는 이전 MCP 버전으로 initialize를 요청했고, 서버는 최신 버전만 허용하고 있었다. 인증 문제가 아니라 로그인 이후의 프로토콜 협상 문제였다.
공식 SDK의 legacy: "stateless"를 적용해 이전 initialize와 최신 server/discover를 함께 처리했다. 이후 클라이언트를 다시 시작하자 도구 목록과 실제 글 조회가 정상적으로 동작했다.
여기서 중요한 기준을 얻었다.
OAuth 로그인
MCP 초기화
tools/list실제 도구 호출
저장 결과 재조회
이 과정은 각각 따로 검증해야 한다. 로그인 성공만으로 MCP 연결 성공을 판단할 수 없다.
localhost callback은 오류가 아니었다
다음 문제는 invalid_redirect였다.
Claude Code와 Command Code는 브라우저 인증 결과를 로컬 CLI로 돌려받기 위해 http://localhost:{임시 포트}/callback을 사용한다. 운영 MCP 서버를 연결해도 callback은 localhost로 표시될 수 있다. 이것은 OAuth 응답이 사용자의 CLI로 돌아가기 위한 정상적인 구조다.
문제는 서버가 이런 요청을 일반 웹 클라이언트로 판단한 데 있었다.
처음에는 HTTPS 메타데이터 주소를 client ID로 사용하는 CIMD를 중심으로 연결했다. 하지만 클라이언트마다 지원 방식이 달랐고, 사용자가 client ID를 직접 지정해야 하는 경우도 생겼다.
새 연결은 DCR을 우선하도록 바꿨다. 공개 클라이언트이고 모든 callback이 HTTP loopback이며 application_type만 빠진 경우에 한해 서버가 native를 보완한다. callback, scope, resource를 임의로 고치거나 클라이언트 이름별 예외를 만들지는 않았다.
클라이언트가 authorization code나 refresh token 요청에서 resource를 생략하면 geul의 /mcp를 기본값으로 사용했다. 다른 resource를 명시하면 기존 검증 절차에 따라 거부한다.
로컬 실패가 운영 실패는 아니었다
로컬 Workers 환경에서는 외부 CIMD 문서 조회가 403으로 실패했다. 처음에는 배포 후에도 같은 문제가 생길 수 있다고 생각했다.
하지만 같은 요청을 Cloudflare Worker에서 실행하자 정상 응답이 왔다. 외부 서버가 로컬 요청과 Cloudflare 네트워크 요청을 다르게 처리한 것이었다.
이를 운영 코드에서 우회하지 않았다. dev:mcp에서만 동작하는 개발 중계를 만들었다.
명시적인 HTTPS 허용 목록만 조회
실행마다 새로운 내부 키 사용
OAuth 토큰과 로그인 쿠키 전달 금지
리다이렉트 차단
요청 시간 제한
잘못된 JSON, 큰 응답, 느린 응답 재현
기본 개발 실행과 프로덕션 빌드에는 이 중계가 들어가지 않는다. 개발 편의를 위한 코드가 운영 보안 경계를 바꾸지 않게 했다.
실제로 확인한 클라이언트
Claude Code: URL 등록, DCR, OAuth 로그인,
list_my_posts성공Codex CLI: URL 등록, DCR, OAuth 로그인,
list_my_posts성공Command Code: URL 등록, DCR, OAuth 로그인,
list_my_posts성공Pi:
pi-mcp-adapter설치 후 DCR, 도구 조회,list_my_posts성공
Claude Code, Codex CLI, Command Code는 https://geul.io/mcp만 등록하면 된다. client ID와 고정 callback 포트를 준비할 필요가 없다.
Pi는 MCP가 내장되어 있지 않아 pi-mcp-adapter가 필요하다. macOS Keychain 창도 geul 서버가 아니라 어댑터가 OAuth 정보를 저장하는 과정에서 표시된다.
코드는 클라이언트가 아니라 책임으로 나눴다
src/auth/mcp.ts는 DCR 요청 보정과 기존 CIMD 연결을 담당한다. src/auth/config.ts는 Better Auth와 MCP 설정을 조합한다. /mcp 라우트는 요청 전달만 맡는다. 글 권한과 비즈니스 로직은 기존 서버 계층을 그대로 사용한다.
해결에 가장 도움이 된 자료는 MCP Streamable HTTP, MCP 버전 처리, OAuth Dynamic Client Registration, OAuth 네이티브 앱 지침, Better Auth MCP 문서였다.
하지만 최종 판단에는 실제 로그가 더 중요했다. discovery 응답부터 실제 글 조회까지 연결 과정을 나누어 확인하면서 문서와 구현 사이의 차이를 찾을 수 있었다.
범용 MCP 서버는 클라이언트별 예외가 많은 서버가 아니다. 서로 다른 CLI가 같은 URL에서 표준 discovery와 OAuth 흐름을 시작하고, 실제 도구 호출까지 완료할 수 있는 서버다.
#dev