MCP 서버 연결 실패 디버깅 — stdio vs HTTP transport 불일치 (1편)
어느 날부터 Claude Code에서 제 블로그 MCP 서버가 보이지 않았습니다. claude mcp list를 돌리면 "✗ Failed to connect"만 반복해서 나왔고, 다른 모든 MCP 서버(grafana, playwright, coolify 등)는 멀쩡히 연결돼 있었습니다. 제가 직접 만든 서버만 안 붙는다는 게 가장 당혹스러웠습니다.
추적 과정에서 알게 된 건 단순한 설정 누락이 아니라, MCP transport 방식 두 가지의 근본적 불일치였습니다. 이 글은 그 추적 기록과, 비슷한 증상을 만난 분이 참고할 수 있는 진단 체크리스트입니다.
증상
증상은 간단했습니다.
$ claude mcp list
...
blog: node /.../packages/personal/mcp/dist/index.js - ✗ Failed to connect
...
Claude Code를 재시작해도 같았습니다. .mcp.json에 토큰, URL, 명령어 인자까지 다 들어 있었고, node /.../dist/index.js를 터미널에서 직접 돌리면 서버가 정상적으로 뜨는 것처럼 보였습니다. 그런데 왜 연결이 안 될까.
1차 진단 — orphan 프로세스
가장 먼저 의심한 건 포트 충돌이었습니다. 서버가 0.0.0.0:3004를 쓰는데 그 포트를 누가 이미 쓰고 있는지 봤습니다.
$ lsof -i :3004 -P
COMMAND PID USER FD TYPE ... NAME
node 66600 jeongkichang 14u IPv4 ... *:3004 (LISTEN)
$ ps -p 66600 -o pid,ppid,etime,command
PID PPID ELAPSED COMMAND
66600 1 03:43:16 node /.../packages/personal/mcp/dist/index.js
3시간 43분 동안 혼자 떠 있던 orphan 프로세스였습니다. PPID가 1(init)이라는 건 부모가 이미 죽고 혼자 살아남았다는 뜻이었습니다. Claude Code가 새로 MCP를 spawn하려 해도 포트가 이미 점유돼 있으니 EADDRINUSE로 즉사했고, 반대로 이미 떠 있는 프로세스는 stdin/stdout handshake가 끊긴 상태라 Claude Code가 연결할 수 없었습니다.
해당 프로세스를 kill한 뒤 Claude Code를 재시작했습니다. 이걸로 끝날 줄 알았는데, 결과는 여전히 Failed to connect였습니다. orphan 정리는 필요했지만 본질 문제는 아니었습니다.
2차 진단 — 소스 코드의 힌트
index.ts를 열어봤습니다. import문이 처음 눈에 들어왔습니다.
import {
StreamableHTTPServerTransport,
} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
그리고 파일 끝부분.
app.post('/mcp', bearerAuth, express.json(), async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(PORT, '0.0.0.0', async () => {
console.log(`Blog MCP server listening on port ${PORT}`);
});
여기서 조각이 맞았습니다. 이 서버는 HTTP Streamable transport로만 동작합니다. POST /mcp와 GET /mcp 엔드포인트가 OAuth bearer 인증을 거쳐야 서버 기능을 씁니다.
그런데 .mcp.json을 다시 봤습니다.
{
"mcpServers": {
"blog": {
"command": "node",
"args": ["/.../packages/personal/mcp/dist/index.js"],
"env": { "BLOG_API_URL": "...", "BLOG_API_TOKEN": "..." }
}
}
}
문제가 명확해졌습니다. command + args 구조는 stdio transport에 쓰는 포맷입니다. Claude Code가 자식 프로세스를 spawn해서 stdin/stdout으로 JSON-RPC를 주고받는 방식인데, 제 서버는 stdin/stdout 리스너를 전혀 갖고 있지 않았습니다. 대신 HTTP 포트를 열고 앉아 있었던 겁니다.
애초에 연결이 될 수가 없는 구조였습니다. 재시작을 몇 번 하든 이 불일치가 해결되지 않으면 무한히 Failed to connect가 반복됩니다.
왜 이렇게 됐을까
git 로그를 봤습니다.
0be88b1 review feedback: server.close 제거, express.json 추가, JWT_SECRET 검증
7a80a70 feat: MCP 서버 OAuth 인증 고도화 — 공유 패키지 + JWT 무상태 토큰 + Refresh 60일
답이 거기 있었습니다. 원래 이 서버는 stdio 기반이었는데, OAuth 2.1 도입 작업에서 HTTP Streamable transport + 자체 Authorization Server로 리팩토링됐습니다. 그런데 .mcp.json은 옛 stdio 스펙 그대로 남아 있었고, 운영 중 한 번도 실제로 사용되지 않은 덕분에 불일치가 드러나지 않았던 겁니다.
개인 블로그의 1인 MCP 서버에 OAuth까지 붙은 이유는 별개 글로 다루겠습니다만, 요지는 "더 상위 멀티테넌트 MCP(글력 같은 SaaS)와 공통 패키지를 쓰려다 보니 personal MCP도 같은 구조로 통일됐다"는 것이었습니다. 그 과정에서 남은 설정 유실이 지금의 증상이었습니다.
3가지 해결 경로
이 시점에서 선택지를 정리했습니다.
| 옵션 | 작업 | 시간 | 트레이드오프 |
|---|---|---|---|
| A. OAuth HTTP 정식 | plist에 MCP_JWT_SECRET + .mcp.json HTTP transport + Claude Code OAuth flow | 1~2h | 표준 준수, 토큰 자동 재사용(60일), 상주 가능 |
| B. stdio 모드 추가 | 서버 코드에 MCP_TRANSPORT=stdio 분기 추가 후 빌드 | 30~45min | 가장 단순, 세션당 spawn 1-2초 오버헤드 |
| C. bearerAuth 우회 | bearerAuth를 BLOG_API_TOKEN 단순 비교로 교체 | 30min | OAuth 포기, 로컬 바인딩으로만 안전 담보 |
A를 선택한 이유
처음에는 B가 끌렸습니다. 1인 personal MCP에 OAuth는 오버킬 같았고, 빌드 한 번만 돌리면 되는 일이었기 때문입니다. 그런데 다시 생각해보니 요구사항에 "상주하면서 git 변경을 감지해 자동 재빌드" 가 있었습니다. stdio 모드에서는 Claude Code가 세션 시작 시에만 spawn하므로 "상주"의 의미가 약해지고, 여러 세션을 동시에 열었을 때 각 세션이 독립 프로세스를 띄우는 불편도 남았습니다.
반면 A는 HTTP 서버 한 개가 모든 세션에서 공유되는 자원이 됩니다. LaunchAgent로 상주시키고, Claude Code 쪽은 이미 로그인한 토큰을 재사용하기만 하면 됩니다. 60일 refresh 덕분에 로그인 1회로 두 달을 갑니다. 초기 설정은 더 복잡하지만, 일상적 오버헤드는 B보다 오히려 적다는 계산이 나왔습니다.
C는 제가 보기에 너무 쉬운 길이었습니다. OAuth 구조를 통째로 우회하면 장기적으로 표준 패턴에서 떨어지게 되고, SaaS 쪽 MCP(글력)와 코드 공유가 어려워집니다. 이미 작동하는 설계를 빼내는 건 나중에 더 비싼 비용으로 돌아오리라 판단했습니다.
다음 편 예고
다음 글(2편)에서는 A 경로의 실제 구축 과정을 다룹니다. OAuth 2.1 Discovery 3단계(.well-known/*, 401 + WWW-Authenticate challenge), MCP SDK의 mcpAuthRouter 표준, macOS LaunchAgent로 MCP 서버 상주시키기, 그리고 git pull과 자동 재빌드를 1시간 주기로 돌리는 watcher 스크립트까지 실제 파일 수준으로 정리하겠습니다.
비슷한 증상으로 시간을 낭비하신 분이라면, 서버 코드가 HTTP인지 stdio인지부터 확인하시는 게 가장 빠른 길이라는 점을 이 글로 먼저 남깁니다.
관련 글
Claude Code에서 블로그 글 작성하기: MCP를 직접 만들어 활용한 경험
Claude Code로 개발하면서 얻은 지식을 블로그에 정리하고 싶었습니다. 하지만 AI에게 블로그 관리자 권한을 모두 주기엔 불안했고, 필요한 API만 분기 처리하기엔 번거로웠습니다. MCP(Model Context Protocol)를 직접 만들어서 권한을 명확히 분리하고, Few-shot 예시와 SEO 가이드라인을 1회 호출로 제공하도록 개선한 경험을 공유합니다.
Claude Code MCP 서버 정리 — 불필요한 도구 제거로 토큰 35% 절약하기
Claude Code의 MCP 서버 10개를 분석해 불필요한 4개를 제거했습니다. MCP 도구 정의가 매 메시지마다 시스템 프롬프트에 들어가 71K 토큰을 소비하고 있었다는 사실, 그리고 실제 정리 과정을 공유합니다.
Claude Code에 Google Search Console MCP를 추가하고, OAuth 토큰 만료를 해결한 경험
GA4 MCP를 운영하다 OAuth 토큰이 만료되고, GSC MCP를 추가하면서 gcloud CLI 설치 오류까지 겪었습니다. ADC 토큰 갱신부터 Python 호환성 문제까지, 실전에서 마주친 트러블슈팅 과정을 정리했습니다.