MCP 호출 안 되는데 오류도 없이 HTTP 200 뒤 조용한 실패 두 가지
MCP에서 HTTP 200은 전송만 통과됐음을 뜻하며, 프로토콜 계층 error와 도구 계층 result.isError, 문자열 id가 사라진 빈 body 등 계층별 조용한 실패를 정리했다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
HTTP 200, Content-Length: 0, 서버 로그에 오류 한 줄도 없음. MCP 클라이언트는 이번 tools/call을 성공으로 처리했고, 사용자 쪽에서는 모델이 진지한 얼굴로 "못 찾았어요"라고 답하는 모습을 보게 됐다.
더 흔한 얼굴도 있다. HTTP 200, body에 내용은 있지만 오류가 result.isError: true 안에 숨어 있고, error 필드는 비어 있는 경우다. JSON-RPC 2.0의 오랜 관행대로 error를 판단하면 역시 호출 성공으로 판정된다.
이 두 가지 실패는 MCP 서버 연동 테스트에서 나도 모두 겪었다. 이것들은 "서비스가 안 떴다"는 식의 요란한 장애가 아니라, 요청이 끝까지 돌고 응답도 돌아왔는데 결과만 틀린 경우다.
error가 비었다고 성공이 아니다. result.isError가 바로 도구 계층의 종국 상태다.
먼저 구분하자: HTTP 200 이후에도 두 계층이 더 있다
그림 1: HTTP 200은 전송 계층만 덮는다. 프로토콜 계층과 도구 계층은 모두 실패할 수 있다 (2026-11-04 실측)
200은 HTTP가 통했다는 것만 뜻하고, 호출 성공을 뜻하지 않는다
MCP 메시지는 JSON-RPC 2.0을 따른다. 프로토콜 계층의 오류는 error 필드에 담긴다. 필드 누락, 타입 오류, 메서드 부재 시 -32602, -32601 같은 오류 코드를 돌려준다. 도구 실행 기간의 오류는 여기를 거치지 않고, SDK가 이를 정상 결과로 감싸 result.content에 오류 텍스트를 넣고 isError: true를 표시한다.
따라서 한 번의 호출에는 세 계층의 상태가 있다. HTTP가 통했는지, JSON-RPC 구조가 맞는지, 도구가 해냈는지. 앞의 두 계층을 통과해도 세 번째 계층은 여전히 실패일 수 있다.
프로토콜 계층은 error를 보고, 도구 계층은 result.isError를 본다
판단 순서를 반대로 하면 안 된다:
- 먼저 HTTP 상태 코드를 본다. 401/403/5xx는 전송 및 인증 계층의 문제다.
- 200 이후에는 body 길이를 본다. 0바이트라면 먼저 id 타입을 확인한다.
- body에 내용이 있으면 먼저 error 필드를 본다. 있으면 프로토콜 계층 오류다.
- error가 비어 있으면 다음으로 result.isError를 본다. true는 도구 계층 오류, false여야 성공이다.
나는 처음에 3단계까지만 했고, 4단계를 빠뜨렸다.
얼굴 하나: body에 내용이 있고, 오류는 result.isError에 있다
파라미터 이름을 잘못 써서 도구 실행 기간 쪽에 걸린 경우
서버에 등록된 도구 시그니처는 question, topK, minScore이고, tools/list가 반환하는 inputSchema.required도 이 세 가지다:
{ "name" : "searchKnowledgeBase" , "inputSchema" : { "type" : "object" , "properties" : { "question" : { "type" : "string" } , "topK" : { "type" : "integer" } , "minScore" : { "type" : "number" } } , "required" : [ "question" , "topK" , "minScore" ] } }
클라이언트가 기억에 의존해 query로 작성하면 받는 것은 -32602가 아니라 200의 정상 응답이다:
{ "id" : 2 , "result" : { "content" : [ { "text" : "text cannot be null or blank" , "type" : "text" } ] , "isError" : true } , "jsonrpc" : "2.0" }
error 필드는 없고, 오류는 result.isError에 걸린다. 프로토콜 계층을 거치지 않은 것은 SDK가 프로토콜 계층에서 필드 누락을 막지 않았기 때문이다. arguments가 곧바로 @Tool 메서드에 전달되고, question은 null이며, 도구 안의 requireQuestion()이 이를 빈 문자열로 바꾸고, 마지막에 하위 embedding의 인자 검증이 "text cannot be null or blank"를 던지고, SDK가 이를 잡아 isError로 감싼다. 파라미터 이름을 잘못 쓴 것이 마침 "도구 실행 기간" 쪽에 걸린 것이다.
MCP 규범은 이렇게 나눈다. 프로토콜 계층은 메시지 구조만 담당하고, 도구 내부에서 무슨 일이 일어나든 일률적으로 결과 형식으로 반환한다. 설계 의도를 알고 나면 오히려 error 필드와 씨름하지 않게 된다.
파라미터 이름의 유일한 권위는 JSON Schema의 required다
수정은 두 갈래로 나뉜다:
- 클라이언트는 tools/list가 반환하는 inputSchema.required에 따라 파라미터를 구성한다. README도, 기억도, 모델의 추측도 따르지 않는다.
- 서버는 @Tool 메서드 진입부에서 비어 있음 검증을 하고, 오류 텍스트에 필드명을 담는다. KnowledgeBaseMcpTools.java:196의 requireQuestion()은 현재 빈 문자열을 반환하는데, 다음 단계에서는 필드명을 담은 비즈니스 예외를 던지도록 바꿔야 한다. 하위 embedding의 오류가 그 책임을 대신 지지 않게 해야 한다.
경계는 바로 isError라는 필드에 있다. 이 필드는 도구 계층 실패만 뜻하고, 파라미터 오류와 비즈니스 거절, 내부 예외를 구분하지 않는다. 구분하려면 content.text에 구조화된 오류 코드를 넣어야 하고, 클라이언트가 문자열 매칭에 의존하게 해서는 안 된다.
얼굴 둘: body가 비어 있고, HTTP 200인데도 오류를 보고하지 않는다
그림 2: 문자열 id가 SDK의 extractId()에서 버려져 응답이 생성되지 않는다 (McpHttpController → McpJsonRpcDispatcher → McpServer.handle)
SDK의 extractId는 숫자만 인정하지만, 규범은 문자열을 허용한다
위 요청을 문자열 id로 바꾸면:
curl -s -i -X POST http://localhost:8090/mcp \ -H "Content-Type: application/json" \ -H "Authorization: Bearer [redacted]" \ -d '{"jsonrpc":"2.0","id":"2","method":"tools/call", "params":{"name":"searchKnowledgeBase", "arguments":{"question":"七天无理由退货的条件是什么?", "topK":3,"minScore":0.5}}}'
반환값은 HTTP/1.1 200 , Content-Length: 0 , body는 비어 있었다.
공식 SDK 소스를 뜯어보니, McpServer.handle() 안의 extractId() 는 오직 idNode.isNumber() 만 인식한다. 문자열이면 곧바로 null을 반환하고, 이후 흐름에서 id가 비어 있다고 판단해 응답을 생성하지 않는다. JSON-RPC 2.0 규격에서 id는 String, Number 또는 NULL을 허용하므로, 클라이언트가 문자열을 보내는 것 자체는 규격에 맞다. 관대하지 않은 쪽은 SDK 구현이며, 그 좁은 쪽이 오히려 조용하다.
같은 엔드포인트, 절반은 관대하고 절반은 엄격
더 난감한 것은 같은 서비스 안에 id 기준이 두 가지라는 점이다.
직접 구현한 Resources / Prompts 분기는 Object로 JsonNode를 그대로 전달하며( McpHttpController.java:77 , McpJsonRpcDispatcher.java:65 ), 문자열 id도 정상적으로 반환된다. Tools 분기만 공식 SDK에 맡겨져 숫자만 인식한다. 같은 /mcp 엔드포인트에서 절반은 관대하고 절반은 엄격하니, 이런 불일치는 전면 엄격보다 문제를 찾기更难다.
나는 그때 게이트웨이 로그, 커넥션 풀, 타임아웃 설정을 한참 들여다보며, 한때는 Spring의 어떤 filter가 응답을 삼킨 게 아닐까 의심했다. 진범은 id 타입 불일치였다. HTTP 계층은 200, 응답 본문은 0바이트, 4xx도 없고, 예외도 없고, 로그도 없다.
빈 body면 먼저 숫자 id로 바꾸고, 그다음 게이트웨이를 확인하라
점검 동작은 단 하나: id를 문자열에서 숫자로 바꿔 재시도하는 것이다.
{ "jsonrpc" : "2.0" , "id" : 2 , "method" : "tools/call" , "params" : { "name" : "searchKnowledgeBase" , "arguments" : { "question" : "七天无理由退货的条件是什么?" , "topK" : 3 , "minScore" : 0.5 } } }
바꾼 뒤 통과하면 게이트웨이 설정은 더 건드리지 마라. 통과하지 않으면 그때 401/403/5xx 순으로 내려가며 확인하라. 반대로 하면 게이트웨이부터 filter까지 죽 훑게 된다.
클라이언트가 시작하자마자 끊긴다
图 3: stdout은 JSON-RPC만 나가야 한다; 로그가 한 줄이라도 있으면 클라이언트 파싱이 실패한다(McpServerStarter.java:138 의 리다이렉트)
stdout은 프로토콜 채널, 로그는 반드시 stderr로
stdio 모드는 Java 프로세스를 Claude Desktop의 자식 프로세스로 취급하며, 프로토콜 메시지는 stdin/stdout으로 오간다. stdout은 프로토콜 채널이지 로그 채널이 아니다. StdioMcpServerTransport 는 JSON-RPC를 System.out 에 쓰므로, 기본 로그가 stdout으로 나가기만 해도 프로토콜 스트림이 오염되어 클라이언트 파싱이 실패하고 즉시 연결이 끊긴다.
나는 이 지점에서 세 번 좌절했다. 첫 번째는 Logback 기본 appender였다. 두 번째는 Logback만 끄면 끝이라고 생각했지만, Spring Boot의 시작 배너와 Bean 조립 로그가 여전히 stdout에 쓰이고 있었다. 세 번째는 가장 억울했는데, 의존성을 하나 바꿨더니 stdout에 Standard Commons Logging discovery in action with spring-jcl... 한 줄이 더 찍혔고, 범인은 pdfbox 전이 의존성으로 딸려온 commons-logging이었다.
// McpServerStarter.java:138 private static void redirectLogbackToStderr () { LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); loggerContext.reset(); // 기본 stdout appender 제거 ConsoleAppender<ILoggingEvent> appender = new ConsoleAppender <>(); appender.setContext(loggerContext); appender.setTarget( "System.err" ); // 핵심: 로그를 stderr로 전환 appender.start(); loggerContext.getLogger(Logger.ROOT_LOGGER_NAME) .addAppender(appender); }
판별 방법: java -jar ... 2>/dev/null 로 한 번 실행해 보고, stdout이 순수 JSON인지 확인하라. JSON이 아닌 줄이 하나라도 있으면 그것이 범인이다.
자식 프로세스는 application.yml을 자동으로 읽지 않으며, fat jar는 java -jar로만 실행할 수 있다
AnnotationConfigApplicationContext으로 기동할 때는 하위 프로세스가 SpringApplication처럼 application.yml을 자동으로 읽지 않아서, @Value가 전부 애노테이션 기본값으로 떨어진다. 필자의 현상은 reranker 설정이 dashscope로 바뀌었는데 key는 없어서 기동하자마자 실패하는 것이었다. 기동기 안에서 직접 YamlPropertySourceLoader로 설정을 Environment에 넣어야 한다(McpServerStarter.java:173의 loadClasspathYaml()).
기동 명령에도 하드 제약이 하나 있다. mvn package로 나오는 것은 Spring Boot fat jar이고, 클래스는 BOOT-INF/classes/에, 의존성은 BOOT-INF/lib/에 있어서 -cp로는 이들을 아예 볼 수 없고, -cp xxx.jar com.example.assistant.mcp.McpServerStarter라고 쓰면 곧바로 ClassNotFoundException이 난다. -Pmcp 프로파일로 실행 가능한 jar를 따로 빌드한 뒤 java -jar로 기동해야 한다.
Resources와 Prompts는 직접 채워야 한다
PDF는 blob, 텍스트는 text
공식 SDK는 Tools만 커버하므로 Resources / Prompts는 직접 채워야 한다. 채울 때 가장 틀리기 쉬운 것은 바이너리 리소스다. PDF는 blob 필드(base64), 텍스트는 text 필드로 보낸다. 이것이 MCP 규범의 이분법이며, 섞어 쓰면 클라이언트가 받는 것은 base64 깨진 문자다.
// McpResourcesProvider.java:117 content.put( "blob" , Base64.getEncoder().encodeToString(entry.bytes));
실측상 resources/list는 classpath 문서 4개를 반환하고, resources/read로 PDF를 읽을 때 mimeType은 application/pdf이고 blob은 base64이며, txt를 읽을 때는 text를 탄다.
값이 없는 {{var}}는 반드시 빈 문자열로 치환해야 한다
Prompts 템플릿의 {{var}} 치환에서 검증 포인트는 “응답에 잔여 플레이스홀더가 없는가”다. 치환하지 않으면 {{language}}가 그대로 LLM에 들어가서 명령의 일부로 취급된다.
// McpPromptsProvider.java:173: 값이 없으면 빈 문자열로 치환하고 플레이스홀더를 남기지 않는다 private static String renderTemplate (String template, Map<String, Object> args) { // 정규식으로 {{var}}를 매칭해 항목별로 치환; args에 없는 키는 ""로 치환 }
실측 3개 템플릿: qa-policy-check, review-code, summarize-doc. prompts/get에 question과 context를 넘기면, 반환되는 user message 안에서 두 플레이스홀더가 모두 실인자로 치환되어 있다.
보안 설정도 조용히 무너진다
빈 key는 기동 시 죽고, 기본 플레이스홀더 key는 warning만 남긴다
McpSecurityService.java:56의 @PostConstruct는 API Key를 검증한다. null이거나 16자 미만이면 곧바로 예외를 던져 프로세스가 뜨지 않는다. 이는 런타임 거부보다 안정적이어서, 운영 사고가 “문제를 안고서” 배포되는 일은 없다.
하지만 기본 플레이스홀더 key인 dev-mcp-key-replace-in-production은 문자 33개라서 길이 검증을 통과하고, warning 한 줄만 남긴다. 즉 기본 설정대로면 공개된 key를 달고서 배포될 수 있어 인증이 사실상 무력하다. 이 부분은 부채이며, “기동 시 검증”을 “기본값 안전”으로 착각하지 말아야 하고, 운영 환경에서는 반드시 chat.mcp.api-key를 명시적으로 덮어써야 한다.
툴 화이트리스트는 단 하나의 진실 원천만 가져야 한다
화이트리스트의 진실 원천이 둘이다. 등록처에 하나, 화이트리스트에 하나. 새 툴을 추가할 때 동기화를 잊으면, 새 툴이 막히거나 기존 툴이 그대로 노출된다. 목록을 단일 상수 KnowledgeBaseMcpTools.TOOL_NAMES(KnowledgeBaseMcpTools.java:56)로 모으고, McpSecurityService가 이를 직접 참조하면(McpSecurityService.java:49) 두 곳이 어긋나지 않는다.
화이트리스트 밖의 툴은 곧바로 403이다.
{ "id" : 8 , "error" : { "code" : -32601 , "message" : "Tool not allowed: rm_rf (allowed: 3 tools)" } , "jsonrpc" : "2.0" }
Bearer의 scheme 비교는 대소문자를 반드시 무시해야 한다
HTTP 규범에서 scheme은 대소문자를 구분하지 않으므로 소문자 bearer xxx도 통과해야 한다. McpSecurityService.java:81은 equalsIgnoreCase("Bearer ")로 앞 7개 문자를 비교한 뒤, 상수 시간 비교로 key를 검증해 타이밍 공격을 피한다.
감사 파이프라인도 redact() 정규식(password / token / apiKey 계열, McpAuditService.java:91)을 거쳐야 한다. 사용자가 “비밀번호는 123456”이라고 물어도 그대로 DB에 적재되면 안 된다.
필자의 현재 트러블슈팅 순서
그림 4: 먼저 body를 보고, 다음으로 error가 어느 계층에 떨어지는지 보고, 마지막에야 게이트웨이를 살핀다
- body 길이 0 : 먼저 숫자 id로 재시도한다. 그래도 비어 있으면 401/403/5xx를 확인한다.
- body에 내용이 있고 error가 비어 있지 않다 : 프로토콜 계층 에러이므로 요청 구조, 메서드 이름, 파라미터 타입을 확인한다.
- error가 비어 있다 : result.isError를 본다. true면 파라미터 이름과 schema.required를 확인하고, false라야 성공이므로 다시 비즈니스 결과를 확인한다.
MCP에 문제가 생겼을 때, 상태 코드는 가장 믿을 수 없는 신호다.
부록: 계층별 속성 조회
같은 체인에서 곁들여 기록해 둔 것들을 계층별로 정리했으니, 다음에 바로 대조해 보면 된다.
| 계층 | 증상 | 처리 | |---|---|---| | 전송 | stdout에 로그가 한 줄 더 찍히면 클라이언트가 즉시 끊긴다 | 로그를 stderr로 리다이렉트; `java -jar ... 2>/dev/null`로 stdout 검증 | | 전송 | 자식 프로세스에서 @Value가 전부 기본값으로 떨어진다 | 수동 `loadClasspathYaml()`; fat jar는 `java -jar` 원시 방식 사용 | | | PDF를 텍스트로 읽어서 클라이언트가 base64 깨진 문자를 받는다 | 바이너리는 blob, 텍스트는 text 원시 타입으로 | | | {{var}}가 그대로 LLM에 들어간다 | `renderTemplate()`에서 결측값을 빈 문자열로 치환 | | 보안 | 기본 자리표시자 API Key로도 배포가 된다 | 운영 환경에서 `chat.mcp.api-key`를 명시적으로 덮어쓰기 | | 보안 | 툴 화이트리스트에 진실 원천이 두 개다 | TOOL_NAMES 단일 소스 | | 보안 | 감사 로그에 "비밀번호는 123456"이 그대로 DB에 기록된다 | `redact()`로 password / token / apiKey 필터링 | | 보안 | bearer xxx 인증 실패 | `equalsIgnoreCase("Bearer ")` |
직접 한번 돌려보고 싶다면
엔드투엔드 검증은 ch19 §19.9에 있다: stdio 3개 원시 타입 + Streamable HTTP 9단계 엔드포인트 당일 실측, resources 4개, prompts 3개 템플릿, 403 화이트리스트, 감사 전 구간. 직접 해보고 싶다면 labs/lab4-meeting-mcp가 쓰기 작업이 있는 MCP 서버 시나리오이며, LLM 비용은 들지 않는다.
규격은 modelcontextprotocol.io/specificati… 참고, 저장소 github.com/hengdboy/la… 114개 테스트는 key에 의존하지 않는다
梦想恒远
시니어 백엔드 개발 엔지니어
1
글
0
읽음
2
팬