Skip to main content

부록 E. 자주 마주치는 에러와 해결법

MCP 서버를 만들다 보면 신기할 만큼 비슷한 곳에서 자꾸 넘어진다. 설정 파일의 쉼표 하나, PATH 한 줄, /mcp 경로 하나, Authorization 헤더 하나가 오후를 통째로 가져가 버리기도 한다. 특히 PKM-MCP처럼 처음에는 로컬 stdio로 잘 돌아가던 서버가, 나중에 OAuth를 붙이고 원격 배포까지 가는 순간부터는 에러의 종류가 갑자기 다채로워진다.

이 부록의 목표는 단순하다. 문제를 "깊이 이해하는 것"보다 먼저, 문제를 "빨리 좁히는 것"을 익히는 것이다. 좋은 개발자는 기능을 많이 만드는 사람만이 아니라, 망가진 기능을 빠르게 되살리는 사람이다.

부록 E. 자주 마주치는 에러와 해결법

에러는 대개 여섯 층에서 난다

MCP 문제는 얼핏 비슷해 보여도, 실제로는 서로 다른 층에서 터진다. 이 순서로 보면 훨씬 빨리 좁혀진다.

  1. 실행 층: 서버 프로세스가 아예 시작되지 않는다.

  2. 연결 층: 클라이언트가 서버에 도달하지 못한다.

  3. 프로토콜 층: 연결은 됐지만 JSON-RPC가 맞지 않는다.

  4. 인증 층: 서버는 보이지만 401, 403에서 막힌다.

  5. 스키마 층: 도구가 안 보이거나, 보여도 호출 스키마가 깨진다.

  6. 도구 로직 층: 호출은 됐지만 실제 비즈니스 로직이 실패한다.

증상으로 빠르게 분류하면 아래처럼 생각하면 된다.

증상가장 먼저 의심할 곳
서버가 목록에 안 뜬다설정 JSON, 실행 경로, 의존성
연결되자마자 끊긴다stdout 오염, 서버 시작 직후 크래시
404, 405, 406이 난다전송 방식 불일치, 잘못된 엔드포인트
401, 403이 난다헤더 형식, 토큰, 스코프, audience
도구가 안 보인다등록 누락, 스키마 문제, 클라이언트 제한
도구는 보이는데 호출만 실패한다파라미터 타입, 검증 로직, 외부 API, 타임아웃
로컬은 되는데 배포 후 안 된다CORS, 프록시, 포트, 헬스체크, 콜드 스타트

5분 응급실: 가장 먼저 해볼 일

문제가 생기면 감으로 고치지 말고, 다음 순서로 확인하자. 이 순서가 가장 싸게 먹힌다.

  1. 서버 실행 명령을 터미널에서 직접 실행한다. 설정 파일 안의 명령을 그대로 복사해서 실행해 본다. 설정 문제인지, 서버 코드 문제인지 바로 갈린다.1

  2. 설정 파일 JSON을 검증한다. 쉼표 하나, 따옴표 하나 때문에 서버가 통째로 무시되는 일이 흔하다.1

  3. 절대 경로를 쓴다. npx, uvx, python이 터미널에서는 보이는데 GUI 앱에서는 안 보일 수 있다.2

  4. stdout에 디버그 로그를 찍지 않았는지 확인한다. stdio 서버에서 stdout은 오직 프로토콜 메시지 전용이다.3

  5. Inspector로 분리 테스트한다. Inspector에서는 되는데 Claude Desktop이나 Cursor에서만 안 되면, 서버보다 클라이언트 설정 문제일 가능성이 크다.3

이 다섯 단계를 건너뛰고 곧장 코드를 뜯기 시작하면, 대개 한 시간쯤 뒤에 다시 이 다섯 단계로 돌아오게 된다.

설치와 실행 단계에서 자주 나는 문제

서버가 아예 보이지 않을 때

가장 흔한 원인은 설정 파일이다. JSON은 생각보다 예민해서 마지막 쉼표 하나, 작은따옴표 하나에도 전체가 무효가 된다. Claude Desktop이나 일부 IDE는 이때 친절하게 "여기 쉼표가 틀렸어요"라고 말해주지 않고, 그냥 서버가 없는 척하기도 한다.1

따라서 서버가 목록에 안 보이면 제일 먼저 설정 파일 전체를 JSON validator에 넣어 보자. 그리고 서버 이름이 중복되지 않는지도 확인하자. 같은 키를 두 번 쓰면 뒤의 설정이 앞의 설정을 덮어써서, 분명히 작성한 서버가 사라진 것처럼 보일 수 있다.1

spawn ENOENT, command not found가 날 때

이 문제는 유난히 사람을 화나게 한다. 터미널에서는 uvx가 잘 실행되는데 Claude Desktop에서는 못 찾는다. 이유는 단순하다. GUI 앱이 셸의 PATH를 그대로 상속하지 않기 때문이다. 특히 macOS에서 ~/.local/bin, ~/.cargo/bin, Homebrew 경로가 빠지는 일이 흔하다.2

해결책은 두 가지다. 하나는 설정 파일에서 command를 절대 경로로 바꾸는 것이다. 다른 하나는 서버 설정의 env.PATH에 필요한 경로를 명시하는 것이다. 실무에서는 두 번째가 더 이식성이 좋다.2

PKM-MCP를 uvx로 띄운다면, 다음처럼 생각하면 된다.

{
  "mcpServers": {
    "pkm-mcp": {
      "command": "uvx",
      "args": ["pkm-mcp"],
      "env": {
        "PATH": "/Users/you/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
      }
    }
  }
}

그래도 안 되면, 설정 파일에 적은 바로 그 명령을 터미널에 붙여 넣어 실행해 보자. 여기서도 실패하면 MCP가 아니라 런타임, 가상환경, 패키지 설치 문제다.1

연결되자마자 끊길 때

이 경우는 stdout 오염을 가장 먼저 의심하자. stdio 전송에서는 stdout이 JSON-RPC 메시지 전용인데, 여기에 print("starting...")나 console.log(...)가 섞이면 프로토콜이 깨진다. 그러면 클라이언트 입장에서는 "연결되려다 이상한 텍스트가 들어와서 끊김"처럼 보인다.3

안전한 규칙은 간단하다. 모든 로그는 stderr로 보낸다. Python이면 print(..., file=sys.stderr), Node.js면 console.error(...)를 쓴다.3

PKM-MCP에 구조화된 로그를 이미 넣어 두었다면 더 좋다. 다만 그 로그의 출력 대상이 stdout이면, 그 순간 로그는 관측성이 아니라 자폭 버튼이 된다.

연결 단계에서 자주 나는 문제

404, 405, 406, 타임아웃이 날 때

이 구간의 핵심은 늘 같다. 클라이언트와 서버가 서로 다른 전송 방식을 상상하고 있다. 한쪽은 Streamable HTTP라고 믿고 있는데, 다른 쪽은 SSE라고 믿고 있거나, 경로를 /mcp라고 생각하는데 실제 서버는 /mcp/mcp에 떠 있는 식이다.4

특히 Python에서 FastMCP를 FastAPI에 마운트할 때 자주 나오는 실수가 있다. SDK가 내부적으로 /mcp 하위 경로를 붙이는데, 개발자가 다시 /mcp에 마운트해 버리면 최종 경로가 /mcp/mcp가 된다. 이때 클라이언트가 /mcp로 붙으면 404가 난다. 이 경우에는 루트(/)에 마운트해서 최종 경로가 /mcp가 되게 해야 한다.4

원격 서버라면 가장 먼저 curl로 초기화 요청을 날려 보자.

curl -X POST "https://your-domain.com/mcp" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":"1","method":"initialize"}'

이 한 번의 호출로 적어도 다음은 알 수 있다.

  • DNS가 맞는지

  • TLS가 살아 있는지

  • 경로가 맞는지

  • 서버가 POST를 받는지

Method not found가 날 때

이 에러는 괜히 무섭게 들리지만, 대부분 사소하다. 흔한 원인은 세 가지다. 첫째, initialize 전에 다른 메서드를 먼저 보냈다. 둘째, 메서드 이름을 tools.list처럼 점 표기법으로 썼다. MCP는 tools/list, tools/call처럼 슬래시 표기법을 쓴다. 셋째, SSE 예제를 보며 만든 클라이언트가 HTTP 서버에 붙고 있거나, 그 반대다.4

프로토콜은 의외로 꼼꼼해서, jsonrpc 필드가 정확히 "2.0"인지, id가 문자열 또는 숫자인지 같은 부분도 본다. 아주 사소한 오타가 -32601이나 -32600으로 돌아온다.4

브라우저에서는 안 되고 데스크톱 앱에서는 될 때

이 경우는 대개 CORS다. 브라우저 기반 클라이언트는 OPTIONS 프리플라이트, Authorization 헤더, Mcp-Session-Id 같은 헤더 노출 조건에 민감하다. 반면 데스크톱 앱은 브라우저 CORS 제약을 받지 않는 경우가 많다.4

따라서 "Inspector에서는 안 되는데 VS Code 데스크톱에서는 된다"면 서버가 아니라 CORS 설정 문제일 가능성이 높다. 배포 환경에서 일단 원인을 좁히는 단계라면 와일드카드로 잠깐 열어볼 수 있지만, 운영에서는 반드시 신뢰된 origin만 허용해야 한다.4

인증 단계에서 자주 나는 문제

401과 403을 먼저 구분하자

두 에러는 비슷해 보여도 다르다. 401은 "인증이 안 됨"이고, 403은 "인증은 됐지만 권한이 모자람"이다. 이 둘을 섞어 생각하면 계속 엉뚱한 곳을 보게 된다.

PKM-MCP를 예로 들면, read:notes 토큰으로 search_notes를 호출하는 것은 될 수 있어도, delete_note는 403이어야 맞다. 반대로 토큰이 아예 없거나 형식이 틀리면 401이다.

OAuth 흐름이 아예 시작되지 않을 때

HTTP 기반 MCP 서버에서 OAuth를 제대로 붙였다면, 보호된 리소스에 토큰 없이 접근했을 때 서버는 401을 주고, 클라이언트가 다음 단계를 찾을 수 있도록 WWW-Authenticate 헤더나 well-known 메타데이터를 제공해야 한다. 이 단계가 빠지면 클라이언트는 "로그인하라"는 뜻은 알지만, 어디로 가야 로그인할지를 모른다.5

실전에서는 다음 두 URL을 브라우저나 curl로 직접 확인하는 것이 빠르다.

  • /.well-known/oauth-protected-resource

  • /.well-known/oauth-authorization-server

둘 중 하나가 비어 있거나, JSON이 깨졌거나, HTTPS가 아니거나, 프록시 뒤에서 접근이 막혀 있으면 OAuth 흐름이 시작도 못 한다.5

토큰은 있는데도 401, 403이 날 때

이때는 aud와 resource를 의심하자. MCP 인증에서는 토큰이 "이 서버를 위한 토큰인지"를 엄격하게 확인해야 한다. 따라서 클라이언트가 토큰 요청 때 보낸 resource 값과, 토큰 안의 aud 값, 서버가 기대하는 리소스 식별자가 정확히 맞아야 한다. https와 http가 다르거나, 끝 슬래시 하나가 달라도 틀릴 수 있다.6

말하자면 이런 느낌이다.

  • 서버 기대값: https://mcp.example.com/mcp

  • 클라이언트 요청값: https://mcp.example.com

  • 결과: "토큰은 맞는데 너한테 발급된 토큰은 아님"

겉으로 보면 아주 억울한 401이지만, 실제로는 서버가 제 역할을 잘하고 있는 것이다.6

API 키와 OAuth를 섞어 쓰다가 꼬일 때

원격 MCP에서 특히 자주 보는 사고다. 서버는 API 키 전용인데, 클라이언트가 OAuth를 시도한다. 혹은 서버는 Authorization: Bearer ...를 기대하는데, 클라이언트는 X-API-Key를 보내고 있다. 이런 경우에는 인증 실패가 반복되고, 어떤 클라이언트에서는 토큰 갱신 루프와 429까지 이어질 수 있다.7

중요한 것은 "내 서버가 어떤 인증 방식을 받는지"를 문서와 설정에서 한 번 더 맞추는 일이다. API 키 서버면 API 키답게, OAuth 서버면 OAuth답게 응답해야 한다. 둘 다 허용할 수는 있지만, 그럴수록 우선순위와 실패 응답 형식을 더 명확히 해야 한다.7

리버스 프록시가 Authorization 헤더를 먹어버릴 때

Cloudflare, Nginx, 기타 프록시를 통과하는 순간 인증 헤더가 백엔드로 전달되지 않는 경우가 있다. 이때 클라이언트는 분명 Bearer 토큰을 보내는데, 서버 로그에는 헤더가 아예 안 보인다. 너무 황당해서 애플리케이션 코드만 계속 뒤지게 되는 종류의 문제다.5

배포 후 인증이 갑자기 모두 401로 바뀌었다면, 애플리케이션보다 먼저 프록시 설정을 확인하자. 토큰이 백엔드까지 전달되는지 보는 것이 우선이다.5

배포 단계에서 자주 나는 문제

컨테이너가 계속 재시작될 때

MCP HTTP 엔드포인트는 대개 POST를 기대한다. 그런데 컨테이너 플랫폼의 기본 헬스체크는 GET을 보내는 경우가 많다. 그러면 플랫폼은 열심히 살아 있는지 확인한다고 GET /mcp를 두드리고, 서버는 "그 경로는 POST만 받는데?"라고 응답하고, 플랫폼은 "죽었네?"라고 오해해서 컨테이너를 재시작한다.4

이 문제의 해법은 아주 정석적이다. 별도의 GET /health 엔드포인트를 만들고, 헬스체크는 거기로 붙인다. 헬스체크를 MCP 엔드포인트에 직접 붙이지 말자.4

502 Bad Gateway가 날 때

이 경우는 포트가 어긋났을 가능성이 높다. 컨테이너는 8080에서 듣고 있는데 ingress는 3000으로 생각하거나, 반대로 애플리케이션은 PORT 환경 변수를 무시하고 다른 포트에서만 뜨고 있을 수 있다.4

배포 플랫폼에서는 포트를 두 군데에서 본다. 플랫폼 설정의 대상 포트, 그리고 애플리케이션이 실제로 listen하는 포트다. 둘이 다르면 서버는 살아 있는데 문만 다른 데 달린 셈이 된다.4

가끔만 느리고, 첫 요청만 유독 느릴 때

콜드 스타트를 의심하자. 서버리스나 scale-to-zero 환경에서는 한동안 요청이 없다가 첫 요청이 오면, 그때 컨테이너가 깨면서 일어난다. 그러면 첫 initialize나 첫 tools/list가 유난히 오래 걸린다.4

이때는 최소 인스턴스를 1로 유지하거나, 첫 요청 타임아웃을 늘리거나, 최소한 사용자에게 "첫 연결은 조금 느릴 수 있음"을 알려주는 편이 낫다. 아무 설명 없이 20초 멈춰 있으면 사람은 버그라고 생각한다.4

스키마와 도구 노출 단계에서 자주 나는 문제

도구가 안 보이거나 일부만 보일 때

서버가 연결은 됐는데 도구 목록이 비어 있다면, 첫째는 등록 자체를 확인해야 한다. 도구 등록이 connect()나 run() 이후에 이뤄졌다면 클라이언트는 빈 목록을 받는다. 둘째는 스키마를 확인해야 한다. 일부 클라이언트와 플랫폼은 JSON Schema 전체를 다 지원하지 않고, 단순한 부분집합만 허용한다.8

특히 이식성을 원한다면 다음 요소를 조심하자.

  • anyOf, oneOf, allOf

  • string | null 같은 nullable union

  • 지나치게 복잡한 중첩 참조($ref)

  • 클라이언트 쪽 검증기가 힘들어하는 정교한 제약식

어떤 플랫폼에서는 등록은 되는데 실행 때 스키마 검증에서 죽기도 한다. 그러므로 범용 호환성이 목표라면 단순한 object + 단일 타입 필드 + required 최소화 전략이 가장 안전하다.8

PKM-MCP의 create_note를 예로 들면, summary가 선택 필드라면 string | null보다 "없으면 아예 생략"이 낫다. 사람 입장에서는 둘이 비슷하지만, 플랫폼 입장에서는 전혀 다를 수 있다.8

도구는 보이는데, 특정 도구만 유독 실패할 때

이 경우는 스키마 복잡도를 의심할 수 있다. 실제로 어떤 클라이언트는 단순한 도구는 되는데, 복잡한 정수 정의나 깊은 참조가 들어간 도구에서 클라이언트 측 스키마 컴파일 오류를 내고 타임아웃으로 이어지기도 한다. 이럴 때는 멋진 스키마보다 평범한 스키마가 더 강하다.8

출력 스키마를 붙였는데 오히려 더 자주 깨질 때

출력 스키마는 좋다. LLM과 클라이언트가 결과를 더 안정적으로 읽을 수 있게 해 주기 때문이다. 하지만 출력 스키마를 선언했다면 서버는 그 형식에 맞는 structuredContent를 실제로 반환해야 한다. 텍스트만 대충 돌려주면 오히려 클라이언트가 더 엄격하게 실패할 수 있다.9

자신이 없으면 출력 스키마를 과감히 생략하고, 자신이 있으면 정확히 지키자. 반쯤 맞는 출력 스키마는 없는 것보다 더 위험하다.9

도구가 너무 많아서 일부만 보일 때

이건 서버 버그가 아닐 수도 있다. 일부 클라이언트는 한 번에 다룰 수 있는 도구 수에 제한이 있다. 예를 들어 VS Code 계열 환경에서는 128개 제한 이슈가 알려져 있다. 이럴 때는 서버를 둘로 나누거나, namespace별로 나누거나, 자주 쓰는 도구만 노출하는 쪽이 현실적이다.12

PKM-MCP도 언젠가 GitHub, Notion, 캘린더, 벡터 검색, 관리자 도구까지 몽땅 붙이게 되면 "잘 만들었는데 안 보이는" 억울한 순간이 올 수 있다. 그때는 기능이 아니라 노출 전략을 바꿔야 한다.12

도구 호출 단계에서 자주 나는 문제

프로토콜 에러와 도구 실행 에러를 구분하자

MCP에서 이 둘을 헷갈리면 디버깅이 꼬인다. JSON-RPC의 error 필드로 돌아오는 것은 프로토콜 에러다. 예를 들어 메서드 이름이 틀렸거나, 파라미터 구조가 잘못됐거나, 서버 내부에서 요청 자체를 처리하지 못한 경우다. 반면 result는 받았지만 isError: true가 들어 있으면, 그건 도구 실행 에러다. 도구는 호출됐지만 비즈니스 로직이 실패한 것이다.910

이 차이는 중요하다. 프로토콜 에러는 클라이언트나 개발자가 고쳐야 하고, 도구 실행 에러는 LLM이 읽고 다음 행동을 판단할 수 있어야 한다.10

예를 들어 PKM-MCP에서 다음은 서로 다른 문제다.

  • tools/call에 arguments 대신 args를 보냄 → 프로토콜 에러

  • delete_note는 호출됐지만 해당 note ID가 없음 → 도구 실행 에러

후자의 경우에는 isError: true와 함께 "해당 노트가 존재하지 않습니다. 먼저 search_notes 또는 get_note로 ID를 확인하세요."처럼 다음 행동을 안내하는 문장을 돌려주는 편이 좋다.910

Invalid params가 날 때

이 문제는 대개 타입 때문이다. 문자열이어야 하는데 숫자를 보내거나, 정수여야 하는데 문자열 "123"을 보내는 식이다. 날짜 포맷, enum 값, 중첩 객체 구조도 자주 틀린다.10

이럴 때 가장 좋은 해결법은 쓰기 도구부터 붙들고 씨름하지 않는 것이다. 먼저 읽기 도구로 확인하자. PKM-MCP라면 update_note가 실패할 때, 먼저 search_notes나 list_notes로 실제 ID를 확인한다. 읽기 작업은 검증 규칙이 덜 복잡해서 문제 범위를 빠르게 줄여 준다.11

호출은 되는데 한참 후 타임아웃될 때

타임아웃은 네 부류로 나뉜다. 외부 API가 느리거나, 큰 데이터를 동기적으로 처리하거나, 코드가 교착 상태나 무한 루프에 빠졌거나, DNS나 네트워크 문제가 숨어 있거나다. 이런 작업은 "그냥 기다리면 되겠지"라고 두면 결국 사용자가 먼저 포기한다.10

해결책은 세 방향이다.

  1. 외부 호출마다 타임아웃을 건다.

  2. 긴 작업을 배치 단위로 쪼갠다.

  3. 즉시 완료할 수 없으면 작업 ID를 반환하고, 상태 조회 도구를 따로 둔다.

예를 들어 semantic_reindex_all_notes 같은 작업은 한 번에 끝내려 하지 말고, start_reindex와 check_reindex_status로 나누는 편이 낫다. MCP에서 긴 작업을 짧은 왕복으로 바꾸는 순간, 에러의 성질도 훨씬 관리 가능해진다.

반환 형식이 미묘하게 틀릴 때

도구 구현에서는 결과의 모양도 엄격하다. 텍스트를 반환할 때는 그냥 문자열 하나를 던지는 것이 아니라, content 배열 안에 type: text와 text를 가진 블록으로 넣어야 한다. 이 형식이 어긋나면 "호출은 된 것 같은데 결과가 없다"는 이상한 상태가 나오기 쉽다.9

즉, 이런 모양이 기본이다.9

{
  "content": [
    {
      "type": "text",
      "text": "검색 결과 3건"
    }
  ],
  "isError": false
}

단순해 보여도, MCP 디버깅에서는 이런 단순함이 큰 돈이다.

클라이언트 자체의 한계도 의심하자

가끔은 서버가 틀린 게 아니라 클라이언트가 오래됐거나 캐시가 꼬였다. VS Code 계열에서는 창 다시 로드만으로 해결되는 경우가 있고, elicitation처럼 추가 입력을 요구하는 기능은 클라이언트 버전이 낮으면 조용히 실패하기도 한다.12

이럴 때의 판단 기준은 간단하다. Inspector에서는 되는데 특정 클라이언트에서만 안 되면, 서버를 뜯기 전에 클라이언트 버전, 캐시, 기능 지원 범위를 먼저 보자.12

문제를 빨리 고치는 사람이 결국 더 멀리 간다

MCP 서버 개발은 기능 추가와 디버깅이 반반이다. 특히 PKM-MCP 같은 메인 프로젝트는 1부의 읽기 전용 서버에서 시작해, 2부의 CRUD와 RAG, 3부의 OAuth와 배포, 4부의 UI와 공개 배포로 갈수록 문제의 층이 많아진다. 초반에는 함수 하나 고치면 끝나지만, 뒤로 갈수록 설정, 네트워크, 인증, 플랫폼 제약이 함께 얽힌다.

그래서 중요한 것은 "나는 에러를 안 만나는 개발자다"가 아니다. 그런 사람은 없다. 더 중요한 것은 "나는 에러를 만나면 어디부터 볼지 안다"이다.

마지막으로 한 문장만 기억하자.

실행 → 연결 → 프로토콜 → 인증 → 스키마 → 로직.

이 순서만 몸에 익어도, 대부분의 MCP 문제는 공포 영화가 아니라 추리 소설이 된다.

참고

1MCP Server Not Working? 9 Common Fixes

2MCP 서버 PATH 문제 완전 해결 가이드

3Debugging MCP Servers: A Practical Troubleshooting Guide

4Troubleshoot MCP servers on Azure Container Apps

5MCP OAuth Troubleshooting Checklist

6MCP Authentication: OAuth 2.1 기반 인증 제대로 이해하기

7MCP OAuth Troubleshooting - MetaMCP

8Schema validation of MCP server tools/list operation fails when using MCP tools in Foundry

9Tools - Model Context Protocol

10MCP Error Handling Explained: Protocol Errors, Tool Failures, and Recovery Patterns

11MCP Tool Calls Fail: Debugging Inputs, Validation, and Error Payloads

12Troubleshooting - Azure MCP Server

책 목록

만들면서 배우는 MCP: PKM-MCP로 끝내는 설계·구현·운영·배포

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기