AI 에이전트와의 대화 도중 MCP 서버가 사라질 수 있습니다: 저를 당황하게 만든 30초 타임아웃 이야기
요약
MCP(Model Context Protocol) 서버 초기화 과정에서 발생하는 30초 타임아웃 문제와 그로 인해 에이전트의 도구 카탈로그가 누락되는 현상을 분석합니다. stdio 방식의 MCP 서버가 핸드셰이크 단계에서 지연될 경우 에이전트가 도구의 부재를 인지하지 못하는 위험성을 다룹니다.
핵심 포인트
- MCP 서버 초기화가 30초를 초과하면 클라이언트가 연결을 포기함
- 도구 누락 시 에이전트는 인지하지 못하고 대체 수단(Bash 등)을 사용함
- Top-level await 지연이 stdio 핸드셰이크 실패의 주요 원인임
- MCP 개발 시 초기화 단계의 타임아웃 관리가 필수적임
버그 리포트는 다음과 같았습니다: "브라우저 도구들이 사라졌습니다."
저는 한 시간 동안 동일한 Claude Code 세션을 실행하며 safari_navigate, safari_click, safari_read_page와 같은 일반적인 흐름으로 호출을 이어가고 있었습니다. 그러다 같은 프로젝트에서 새로운 대화를 시작했는데, Safari 도구들이 카탈로그(catalog)에 전혀 없었습니다. 에이전트는 "safari-mcp를 사용하려고 시도했으나 사용할 수 없습니다"라고 말하지 않았습니다. 그냥... 사용하지 않았습니다. 대신 제가 필요로 했던 기능의 절반을 Bash와 curl로 다시 구현했습니다.
그 두 번째 부분이 가장 최악입니다. 에이전트는 도구 카탈로그가 불완전하다는 사실을 알지 못합니다. 에이전트는 오직 눈앞에 있는 것만 압니다. 도구가 누락되면 에이전트는 가진 것으로 어떻게든 해내려 하며, 사용자는 자신의 마지막 릴리스가 도구 발견 가능성(discoverability)을 망가뜨렸다는 사실을 전혀 알 수 없습니다.
이 포스트는 이 문제를 일으킨 30초 타임아웃(timeout), 진단 과정, 그리고 한 줄로 해결하는 방법에 대해 다룹니다. 하지만 그보다 더 중요한 것은, 모든 MCP 개발자가 알아야 하지만 대부분은 모르고 있는 stdio MCP의 실패 모드(failure mode)에 관한 것입니다.
설정 (The setup)
safari-mcp는 실제 macOS Safari를 구동하는 MCP 서버입니다. 사용자가 에이전트가 별도의 브라우저 프로필(예: "업무용" 대 "개인용")을 사용하기를 원할 때, SAFARI_PROFILE=work와 함께 서버를 실행하면 서버는 모든 도구 호출의 범위를 해당 프로필의 창으로 제한합니다. 즉, 시작 시 서버는 창을 찾아야 합니다. AppleScript를 호출하고, Safari의 열린 창들을 열거하며, 프로필 이름으로 매칭하고, 창 참조(window ref)를 캐싱해야 합니다.
기존의 시작 코드는 다음과 같았습니다:
if (SAFARI_PROFILE) {
await new Promise(r => setTimeout(r, 50));
await refreshTargetWindow(true); // <-- 이 라인
...
ES 모듈의 최상위 await (top-level await)입니다. 보기에는 괜찮아 보입니다. 프로필 감지가 한 번 실행되고, 서버는 어떤 창을 대상으로 할지 알게 되며, 모든 것이 순조롭게 진행됩니다.
테스트 시에는 약 50~200ms가 소요되었습니다. 하지만 운영 환경에서는 때때로 30초 이상이 걸리기도 했습니다.
stdio MCP에서 "30초 이상"이 의미하는 것
Claude Code가 MCP 서버를 실행할 때, 30초 이내에 initialize 응답이 올 것으로 기대합니다. 이것이 핸드셰이크 (handshake)입니다. 서버는 자신의 프로토콜 버전과 도구 카탈로그 (tool catalog)를 알리고, 클라이언트는 "좋아요, 여기 제 세션입니다"라고 말합니다. 이 핸드셰이크가 완료될 때까지 서버의 도구들은 대화의 도구 카탈로그에 포함되지 않습니다.
만약 최상위 await (top-level await)가 stdio 루프가 응답할 기회를 얻기 전에 >30초 동안 실행된다면, 핸드셰이크는 마감 시간을 놓치게 됩니다. 클라이언트는 포기합니다. 서버는 종료됩니다. 재시도도 없습니다. 사용자에게 나타나는 경고도 없습니다. 그저 Claude Code 내부 깊숙한 곳의 로그에 "MCP 서버가 30초 이내에 초기화되지 않았습니다"라는 항목이 남을 뿐입니다.
그리고 결정적으로: 대화는 계속됩니다. 에이전트의 도구 카탈로그는 제시간에 응답한 것들로만 구성됩니다. Safari 도구들은 그냥 그 자리에 없게 됩니다. 에이전트는 해당 도구들이 원래 있어야 했다는 사실을 알 방법이 없습니다.
이 점을 강조하고 싶습니다: 사용자로서 이 실패는 완전히 보이지 않았습니다. 스택 트레이스 (stack trace)를 보지도 못했고, "도구를 로드하지 못했습니다"라는 메시지도 보지 못했습니다. 제가 방금 수정을 배포한 도구를 에이전트가 사용하지 않는 상황만을 목격했을 뿐입니다.
AppleScript가 30초를 넘겨 지연되는 이유
refreshTargetWindow(true)는 다음과 같이 실행되는 Swift 헬퍼 (helper)를 호출합니다:
tell application "Safari"
return name of every window
end tell
탭이 3개 있는 깨끗한 상태의 Safari에서는 이것이 12ms 만에 반환됩니다. 하지만 실제 사용자의 Safari에서는 다음과 같은 상황 중 하나가 발생합니다:
- Spotlight가 재인덱싱 (reindexing) 중입니다. AppleScript의 mach 포트 (mach port)가 인덱서가 점유하고 있는 동일한 락 (lock)을 얻기 위해 경쟁합니다. 15~60초의 일시 정지가 발생합니다.
- 사용자가 12개의 창에 걸쳐 80개 이상의 탭을 열어두었으며, 그중 절반이 로딩 중입니다.
name of every window는 각 창의 제목이 안정될 때까지 기다립니다. 5~20초가 소요됩니다. - 사용자가 방금 Safari를 닫았다가 다시 열었습니다. Apple 이벤트 매니저 (Apple Event Manager)가 Safari 자체의 시작 활성화 작업 뒤로 귀하의 요청을 큐 (queue)에 쌓아둡니다. 10~30초가 소요됩니다.
- 어떤 백그라운드 프로세스가
~/Library/Containers/com.apple.Safari/에 접근했습니다. TCC 개인정보 보호 서브시스템 (privacy subsystem)이 귀하의 번들 (bundle)에 대한 자동화 권한을 재검증합니다. 즉시 완료될 수도 있고, "사용자가 마우스를 움직일 때까지" 걸릴 수도 있습니다.
이 중 그 어떤 것도 버그가 아닙니다. 이는 정상적인 macOS의 동작입니다. 제가 테스트했을 때는 99백분위수(99th percentile) 안에 들지 않았지만, 서버가 실제 사용자 층에 도달했을 때는 99.9백분위수(99.9th percentile)에서 나타났습니다.
순진한 진단 경로 (나의 경우)
제가 가장 먼저 한 일은 버그가 MCP 프로토콜 계층 (protocol layer)에 있다고 가정하는 것이었습니다. 저는 stdio 프레이밍 (framing) 코드, JSON-RPC 파서 (parser), 요청 디스패처 (request dispatcher)를 찾아보았습니다. 그 중 어느 것도 문제의 원인이 아니었습니다.
두 번째로 한 일은 refreshTargetWindow 호출을 살펴보고 "음, 내 테스트에서는 잘 작동하는데."라고 생각하는 것이었습니다. 이는 소프트웨어 개발에서 가장 비용이 많이 드는 문장입니다.
제가 찾아내는 데 약 20분이 걸린 실제 진단 방법은 Claude Code MCP 디버그 로그 (debug logs)를 읽는 것이었습니다:
[MCP] safari-mcp: spawned (pid 47192)
[MCP] safari-mcp: sending initialize request
[MCP] safari-mcp: initialize timed out after 30000ms, killing process
그게 전부였습니다. 그것이 유일한 신호였습니다. MCP 클라이언트 (client)는 서버가 무엇을 하고 있었는지 알려주지 않습니다. 서버에게 "멈춰 있나요?"라고 묻지도 않습니다. 그저 프로세스를 종료할 뿐입니다.
그 한 줄을 확인하고 나니 나머지는 명확했습니다. stdio가 실행되기 전에 실행되는 유일한 것이 refreshTargetWindow였습니다. 만약 refreshTargetWindow가 느리다면, stdio는 기회조차 얻지 못합니다. 따라서: 그것 때문에 stdio를 차단(block)하지 마십시오.
해결책
if (SAFARI_PROFILE) {
(async () => {
await new Promise(r => setTimeout(r, 50));
...
전체 시작 프로브 (startup probe)를 실행 후 방치하는 방식인 IIFE (즉시 실행 함수 표현식)로 감쌉니다. 모듈 초기화 (module init)는 즉시 반환됩니다. stdio 루프 (loop)가 바인딩됩니다. 초기화 핸드셰이크 (handshake)는 약 5ms 내에 응답합니다. 첫 번째 safari_* 도구 (tool) 호출이 도착할 때쯤이면 프로필 창 프로브는 대개 완료되어 있습니다. 만약 완료되지 않았더라도, getTargetWindowRef()에는 이미 캐시 누락 시 프로브를 인라인 (inline)으로 실행하여 처리하는 지연 새로고침 (lazy-refresh) 경로가 마련되어 있습니다.
정확한 메커니즘은 이렇습니다: 프로브는 캐시 워밍업 (cache warm-up)이지, 필수 전제 조건이 아닙니다. 도구 호출 경로는 이미 콜드 캐시 (cold cache)를 처리하는 방법을 알고 있습니다. 따라서 모듈 초기화가 기다리게 만들 이유가 없습니다.
세 줄이 바뀌었습니다. 버그가 사라졌습니다.
모든 MCP 작성자가 이 사례를 통해 얻기를 바라는 교훈
만약 당신의 MCP 서버가 시작 시점에 외부 프로세스, 외부 API, 번들 외부의 파일 시스템, 또는 시스템 서비스에 접근하는 작업을 무엇이라도 수행한다면, 절대로 초기화 (initialize) 과정을 차단 (block)하게 해서는 안 됩니다.
- 데몬 (daemon)을 생성하나요? 생성만 하세요, 기다리지 마세요.
- 사용자 설정을 읽나요? 임포트 (import) 시점이 아니라
tools/list또는 첫 번째 도구 호출 시에 수행하세요. - 자격 증명 (credentials)을 검증하나요? 지연 (Lazy) 처리하세요. 보호된 도구에 대한 첫 번째 호출 시점에 수행하세요.
- AppleScript / xdotool / win32 /
xprop를 호출하나요? 이들에 대한 서비스 수준 협약 (SLA)은 보장되지 않습니다. 미루세요. ~/.config/<your-tool>파일을 로드하나요? 꽤 안전합니다. 하지만 여전히: 파일이 없거나 손상되었다면 로그를 남기고 계속 진행하세요. 모듈 초기화가 중단되게 해서는 안 됩니다.
여기서는 비대칭적인 비용 (asymmetric cost)이 중요합니다. 만약 당신의 느린 조사 (probe) 작업이 initialize를 차단한다면, 실패 모드는 최악의 형태가 됩니다. 즉, 도구들이 에러 메시지도 없이 조용히 사라지며, 에이전트 (agent)는 재시도해야 한다는 사실조차 알지 못하게 됩니다. 반면, 느린 조사 작업이 백그라운드에서 실행 중이고 작업이 완료되기 전에 도구 호출이 도착한다면, 실패 모드는 기껏해야 단 한 번의 느린 도구 호출일 뿐입니다. 그리고 에이전트가 확인하고 조치를 취할 수 있는 명확한 에러 메시지를 반환할 수도 있습니다.
차단 (blocking)이 정답이 되는 거래는 이 시나리오 어디에도 없습니다.
stdio MCP가 다르게 동작했으면 하는 점
이런 버그는 배포되지 않도록 설계되었어야 합니다. 제가 보고 싶은 구체적인 변경 사항은 다음과 같습니다:
initialize는 도구 카탈로그 (tool catalog)의 완전성을 기다리며 차단해서는 안 됩니다. 서버는 "준비되었습니다. 제 카탈로그는tools/list?ttl=lazy입니다. 나중에 다시 물어보세요."라고 말할 수 있어야 합니다.- MCP 클라이언트 (client)는 서버 부팅 실패를 대화 중에 드러내야 합니다. 단 한 줄이라도 좋습니다: "참고: safari-mcp 시작에 실패했습니다.
package.json에 나열된 도구들은 이번 세션에서 사용할 수 없습니다." 에이전트가 이를 읽고, 사용자가 이를 읽으면 모두가 조치를 취할 수 있습니다. initialize타임아웃 (timeout)은 프로세스를 종료(kill)하는 것이 아니라 재시도 (retry)를 생성해야 합니다. 첫 번째 타임아웃 발생 시 종료하고 다시는 확인하지 않는 현재의 동작은 서버가 고장 났다고 가정합니다. 하지만 종종 서버는 단지 바쁠 뿐입니다.
이 중 어느 것도 서버 작성자가 해결해야 할 문제는 아니지만, 이 모든 것들이 있었다면 사용자가 발견하기 전에 저에게 이 버그를 잡아낼 수 있게 해주었을 것입니다.
이것이 에이전트 생태계 전반에 중요한 이유
우리는 수십 개의 MCP (Model Context Protocol) 서버를 탑재한 에이전트 시대로 나아가고 있습니다. 서버를 10개 이상 쌓기 시작하면, 특정 실행 시점에 그중 최소 하나가 초기화에 조용히 실패할 확률은 "낮음"에서 "거의 확실함"으로 변합니다. 만약 실패 모드가 "에이전트가 마땅히 가져야 할 도구(tools)를 조용히 갖지 못하는 것"이라면, AI 에이전트의 사용자 경험은 "가끔 AI가 평소보다 멍청해지는데 그 이유를 알 수 없다"가 되어버립니다.
그것은 제가 원하는 미래가 아닙니다. 그것은 사용자가 하네스 (harness)가 제대로 노출하지도 못한 기능의 부재를 모델의 탓으로 돌리는 미래입니다.
만약 여러분이 MCP 서버를 배포하고 있다면: 여러분의 최상위 await (top-level await)를 감사(audit)하십시오. 만약 그것이 지연될 수 있는 무언가에 접근한다면, 지금 당장 크리티컬 패스 (critical path)에서 분리하십시오. 제가 방금 저 자신에게 제출한 버그 리포트가 누군가에 의해 접수되기 전에 말입니다.
해결책은 오늘 safari-mcp v2.11.9에 배포되었습니다. 전체 diff는 여기에서 확인할 수 있습니다. MCP 서버가 어떻게 Safari 프로필에 범위를 지정하는지 확인하고 싶거나, 다음 버그를 제보하고 싶다면 GitHub를 방문해 주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기