MCP 클라이언트의 HTTP 503, JSON-RPC error, isError를 구분하는 방법
MCP 클라이언트가 도구를 호출했을 때 HTTP는 200을 반환했는데 Agent는 실행 실패를 보고할 수 있다. 모순이 아니다. HTTP 성공은 JSON-RPC 응답에 error가 없다는 뜻도, 도구 결과의 isError가 false라는 뜻도 아니다.
MCP 게이트웨이, 코딩 에이전트 통합 계층, 직접 만든 클라이언트에서 response.ok만 확인하면 중요한 두 가지 실패를 놓친다. HTTP 전송 상태를 먼저, JSON-RPC 응답 봉투를 다음으로, 도구 결과를 마지막으로 확인하는 편이 안전하다. 재시도 가능 여부는 별도로 판단해야 한다.
나는 Cakewalk MCP Test Kitchen의 고정 커밋 cf2f689을 검토하고 Node.js 로컬 루프백 분류기를 독립적으로 실행했다. 합성 사례 11개가 모두 통과했다. 여기서 확인한 것은 직접 작성한 분류 로직이지 Cakewalk .NET 서버나 Cursor, Codex 등 실제 MCP 클라이언트와의 상호운용성이 아니다.
tools/call의 세 가지 오류 계층: HTTP, JSON-RPC, 도구 결과
2026-07-28 MCP Tools 명세의 Error Handling은 프로토콜 오류와 도구 실행 오류를 구분한다. HTTP 전송 계층까지 포함하면 실패를 다음 세 가지로 나눌 수 있다.
| 실패 계층 | 응답 예시 | 클라이언트가 판단해야 할 사실 |
|---|---|---|
| HTTP 전송 | 503 Service Unavailable | HTTP 요청이 실패했다. 도구의 부작용이나 자동 재시도 여부는 아직 알 수 없다 |
| JSON-RPC 프로토콜 | HTTP 200, 최상위 error.code와 error.message | JSON-RPC 오류이며 성공한 도구 결과는 없다 |
| 도구 실행 | HTTP 200, result.isError: true | 프로토콜 교환은 완료됐더라도 도구 자체는 실패했다 |
HTTP 200을 받은 첫 번째 예시는 JSON-RPC 오류 봉투다.
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32602, "message": "Invalid params" }
}
다음은 도구가 업무 처리 실패를 보고한 응답이다.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Tool-side failure" }],
"isError": true
}
}
두 JSON은 서버의 응답 본문이다. 완전한 요청에는 _meta와 필수 HTTP 헤더가 추가로 필요하다.
2026-07-28 CallToolResult 스키마에서 isError는 선택적 boolean이며 생략 시 false로 간주한다. resultType이 없을 때 complete로 해석하는 것은 별도의 하위 호환 규칙이다. structuredContent도 포함할 수 있으며 반드시 객체일 필요는 없다. 그러나 이 버전은 배열인 content 필드를 여전히 요구한다. 이 분류기는 그 범위만 검사하며 모든 MCP 버전과 SDK를 검증하는 범용 검증기가 아니다.
도구 오류 메시지는 모델이 잘못된 인수를 수정하는 데 도움이 될 수 있다. Tools Error Handling 항목은 “Clients SHOULD provide tool execution errors to language models”라고 명시하고, 프로토콜 오류에 대해서는 “Clients MAY provide protocol errors to language models”라고 한다. SHOULD/MAY는 모델에 오류를 전달할지에 관한 것이지 요청을 무조건 재전송하라는 뜻이 아니다. 부작용이 있는 도구에서는 특히 중요하다.
resultType: "input_required"는 호출이 끝나지 않았고 추가 입력이 필요함을 뜻한다. MRTR(Multi Round-Trip Requests, 다중 왕복 요청)에서는 응답에 inputRequests를 담고, 클라이언트는 답을 모아 inputResponses와 필요한 경우 requestState를 포함해 다음 요청을 보낸다. JSON-RPC id도 새로 지정해야 한다. 이는 도구 오류도, 일반적인 타임아웃 재시도도 아니다.
Streamable HTTP에는 더 미묘한 함정이 있다. 2026-07-28 전송 명세는 같은 요청에 application/json 또는 text/event-stream 응답을 허용한다. HTTP 200 + SSE는 스트림이 시작됐다는 사실만 나타낸다. 최종 JSON-RPC 응답 전에 연결이 끊어지거나 최종 응답 자체가 error일 수 있다. 클라이언트는 Content-Type에 따라 파싱하고 요청 ID와 최종 결과 수신을 확인해야 한다. 스트림 완결성의 문제이지 도구 오류를 기계적으로 네 번째 유형으로 추가할 문제는 아니다.
JSON-RPC 알림(notification)에는 id가 없다. 수락된 notification POST에 대해 전송 명세는 본문이 없는 202 Accepted를 설명한다. 2026-07-28의 핵심 Streamable HTTP는 일반적인 클라이언트 시작 HTTP 알림 흐름을 정의하지 않으며 취소는 응답 스트림을 닫는 방식으로 표현된다. 빈 202 응답을 tools/call 결과 본문을 기대하는 분류기에 넣으면 안 된다.
Cakewalk 정적 소스 분석: 503 → 200이 자동 재시도를 뜻하지 않는 이유
Cakewalk는 선택한 시나리오에 따라 서버가 의도적으로 비정상적으로 동작하게 한다. 먼저 고정 소스에서 오류를 주입하는 위치를 살펴보고, 다음 절에서 독립적으로 실행한 합성 테스트를 본다.
HttpStatusSequenceScenario.cs 12~35행은 ScenarioWireInvocationKeys.HttpRequest로 세션 내 MCP POST 횟수를 세며, tools/call만 세지는 않는다. {"statusCodes":[503,200]}이면 첫 번째 POST는 503, 두 번째 POST가 실제로 도착했을 때 200을 이용해 처리를 계속한다. 다른 POST가 먼저 순서를 소비할 수 있다. 예를 들어 tools/list → 첫 POST가 503 → 클라이언트 중지 → tools/call은 보내지지 않는다. 이 순서는 소스의 계수 방식에서 추론한 예시이지 패킷 캡처가 아니다. [503,200]만으로 재시도 발생을 입증할 수 없다.
반면 JsonRpcErrorScenario.cs는 tools/call에 HTTP 200과 JSON-RPC error를 주입하고, ToolErrorScenario.cs는 CallToolResult.isError 경로를 사용한다. 오류를 주입하는 계층이 서로 다르다.
DI 등록 코드 308~315행과 고정 README 197~220행도 확인했다. 기본으로 활성화된 여섯 시나리오는 baseline, errors.http_status_sequence, errors.jsonrpc_error, errors.tool_error, elicitation.approval, compat.sdk_v2다. 저장소에 timeout, auth, catalog 등의 추가 구현이 있다고 해도 기본 DI에는 등록되어 있지 않다.
시나리오 선택에는 세션 시간차도 존재한다. README에 따르면 저장한 새 선택은 다음 MCP 세션부터 적용된다. 503을 선택했는데도 과거의 정상 응답이 보인다면 클라이언트 캐시를 의심하기 전에 세션과 실제 POST 순서를 확인해야 한다.
SdkV2CompatScenario.cs 74~106행에는 이 예시 서버의 stateless 호환성 행렬이 나온다. 2026-07-28 기본 stateless MRTR은 지원하지만 stateful 호스트 경로나 2025-11-25 stateful 브리지는 활성화되지 않았다. 구형 stateless 예시에서는 closeReason을 넣어 다시 호출하도록 안내할 수 있다. 이는 해당 서버의 구현 범위이지 모든 MCP 클라이언트의 한계가 아니다.
실제 SDK 소스를 정적으로 살펴봐도 같은 경계가 드러난다. 공식 TypeScript SDK Client.callTool() 코드와 설명은 결과로 반환되는 result.isError 도구 실패와 **예외로 던져지는 ProtocolError**를 구분한다. 내부 _onresponse는 error.code/message/data도 보존한다. 반면 Cakewalk의 공개 TypeScript 예제 클라이언트 표시 함수는 결과 텍스트를 추출해 출력하며, 그 함수 자체에는 별도의 isError 분기가 없다. 이는 소스 관찰일 뿐 예제 클라이언트를 실제 실행해 복구 동작을 측정한 결과가 아니다.
Node.js 로컬 합성 테스트 11개로 MCP 오류 분류하기
Linux 및 Node.js v22.16.0에서 처음에는 9/9를 통과했고, 레거시 resultType 처리를 수정해 10/10, 잘못된 도구 결과 형식 사례를 추가해 11/11을 달성했다. 세 버전의 소스와 TAP는 각각 보관했다. node:http, node:events, node:test, node:assert/strict 및 내장 fetch를 사용하며 127.0.0.1의 임시 포트만 열고 npm 패키지는 설치하지 않는다.
이전 분류기는 resultType이 없는 정상 응답을 INVALID_TOOL_RESULT로 잘못 분류했다. 2026-07-28 기본 명세 ResultType 절은 구형 서버에 대해 “clients MUST treat an absent resultType as "complete"”라고 요구한다. 2025-11-25 도구 결과 예시에는 그 필드가 없다. 폴백을 지원하지 않는 엄격한 분류기는 유효한 레거시 결과를 거부하게 된다.
content가 배열이 아니라 "not-an-array" 문자열인 사례도 추가했다. 분류기는 INVALID_TOOL_RESULT를 반환했다. 최신 실행은 11개 통과, 실패 0, 종료 코드 0이며 정의한 여덟 가지 outcome을 모두 관찰했다.
| 합성 입력 | 실제 분류 | 의미 |
|---|---|---|
| 정상 HTTP 200 도구 결과 | TOOL_SUCCESS | 성공 대조 사례 |
| 첫 POST 503 후 테스트 코드가 명시적으로 두 번째 POST를 보내 200 | HTTP_ERROR → TOOL_SUCCESS | 두 번째 요청은 자동 재시도가 아니다 |
HTTP 200 + JSON-RPC error: -32602 | JSONRPC_ERROR | HTTP 상태만 보면 성공으로 오판 |
HTTP 200 + isError: true | TOOL_ERROR | 프로토콜 성공과 도구 성공은 다르다 |
| HTTP 500 + 성공처럼 보이는 본문 | HTTP_ERROR | 전송 오류를 우선한다 |
| 잘못된 JSON | MALFORMED_JSON | 파싱 실패는 완료가 아니다 |
result와 error가 함께 존재 | INVALID_ENVELOPE | 응답 봉투가 모순된다 |
응답 id가 요청과 다름 | INVALID_ENVELOPE | 요청 ID로 대응시켜야 한다 |
input_required | INPUT_REQUIRED | 추가 입력이 필요하다 |
resultType이 없는 유효한 레거시 결과 | TOOL_SUCCESS | 필수 하위 호환 폴백 |
content: "not-an-array" | INVALID_TOOL_RESULT | 잘못된 도구 결과 구조 |
정의된 여덟 결과는 TOOL_SUCCESS, HTTP_ERROR, JSONRPC_ERROR, TOOL_ERROR, INPUT_REQUIRED, MALFORMED_JSON, INVALID_ENVELOPE, INVALID_TOOL_RESULT다. 여덟 결과가 모두 합성 입력으로 관찰됐지만, 코드의 모든 조건 분기를 100% 검증했다거나 공식 MCP 적합성 테스트에 통과한 것은 아니다.
하위 호환성 수정의 핵심은 classifyToolCall 내부의 다음 두 줄이다.
const result = response.result;
const resultType = has(result, 'resultType') ? result.resultType : 'complete';
전체 프로그램을 mcp-error-layers-v3.test.mjs로 저장하고 Node.js 22에서 실행한다.
node --test --test-reporter=tap mcp-error-layers-v3.test.mjs
펼치기: 합성 테스트 11개를 포함한 실행 가능한 Node.js 전체 소스
/**
* First-party synthetic MCP-like response classifier. Offline loopback only.
* Node.js 22; no MCP SDK, Cakewalk runtime, credentials or external network.
* 2026-07-28 base-protocol compatibility: missing resultType is "complete".
* This deliberately omits real MCP negotiation, _meta, authorization and MRTR.
* Run: node --test --test-reporter=tap mcp-error-layers-v3.test.mjs
*/
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer } from 'node:http';
import { test } from 'node:test';
const has = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
const good = { jsonrpc: '2.0', id: 1, result: {
resultType: 'complete', content: [{ type: 'text', text: 'ok' }], isError: false,
} };
const rpcError = { jsonrpc: '2.0', id: 1, error: {
code: -32602, message: 'synthetic invalid params',
} };
const toolError = { jsonrpc: '2.0', id: 1, result: {
resultType: 'complete', content: [{ type: 'text', text: 'failed' }], isError: true,
} };
export function classifyToolCall({ status, body, expectedId = 1 }) {
if (status < 200 || status >= 300) {
return { layer: 'transport', outcome: 'HTTP_ERROR', status };
}
let response;
try { response = JSON.parse(body); }
catch { return { layer: 'protocol', outcome: 'MALFORMED_JSON' }; }
if (!response || Array.isArray(response) || typeof response !== 'object' ||
response.jsonrpc !== '2.0' || response.id !== expectedId ||
has(response, 'result') === has(response, 'error')) {
return { layer: 'protocol', outcome: 'INVALID_ENVELOPE' };
}
if (has(response, 'error')) {
if (!response.error || !Number.isInteger(response.error.code) ||
typeof response.error.message !== 'string') {
return { layer: 'protocol', outcome: 'INVALID_ENVELOPE' };
}
return { layer: 'protocol', outcome: 'JSONRPC_ERROR', code: response.error.code };
}
const result = response.result;
if (!result || Array.isArray(result) || typeof result !== 'object') {
return { layer: 'protocol', outcome: 'INVALID_TOOL_RESULT' };
}
// Normative 2026-07-28 fallback: absent resultType means "complete".
const resultType = has(result, 'resultType') ? result.resultType : 'complete';
if (resultType === 'input_required') {
return { layer: 'interaction', outcome: 'INPUT_REQUIRED' };
}
if (resultType !== 'complete' || !Array.isArray(result.content) ||
(has(result, 'isError') && typeof result.isError !== 'boolean')) {
return { layer: 'protocol', outcome: 'INVALID_TOOL_RESULT' };
}
return result.isError === true
? { layer: 'application', outcome: 'TOOL_ERROR' }
: { layer: 'application', outcome: 'TOOL_SUCCESS' };
}
async function withPeer(t, reply) {
const calls = [];
const server = createServer(async (req, res) => {
let raw = '';
for await (const part of req) raw += part.toString('utf8');
calls.push({ method: req.method, path: req.url, body: raw });
const next = reply(calls.length);
res.writeHead(next.status, { 'content-type': 'application/json' });
res.end(typeof next.body === 'string' ? next.body : JSON.stringify(next.body));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => new Promise((resolve, reject) =>
server.close(error => error ? reject(error) : resolve())));
return { url: `http://127.0.0.1:${server.address().port}/mcp`, calls };
}
async function onePost(url) {
const response = await fetch(url, { method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'synthetic', arguments: {} } }),
});
const body = await response.text();
return classifyToolCall({ status: response.status, body });
}
function record(t, name, outcome, calls) {
t.diagnostic(JSON.stringify({ synthetic: true, name, outcome, postCount: calls.length }));
}
async function testCase(t, name, wire, expected) {
const peer = await withPeer(t, () => wire);
const actual = await onePost(peer.url);
assert.deepEqual(actual, expected);
assert.equal(peer.calls.length, 1);
record(t, name, actual, peer.calls);
}
test('baseline complete -> TOOL_SUCCESS', t => testCase(t, 'baseline',
{ status: 200, body: good }, { layer: 'application', outcome: 'TOOL_SUCCESS' }));
test('503 then 200 requires explicit second POST', async t => {
const peer = await withPeer(t, n => n === 1
? { status: 503, body: { note: 'synthetic outage' } }
: { status: 200, body: good });
const first = await onePost(peer.url);
assert.deepEqual(first, { layer: 'transport', outcome: 'HTTP_ERROR', status: 503 });
assert.equal(peer.calls.length, 1, 'no automatic retry by toy client');
const second = await onePost(peer.url); // Only this explicit call creates POST #2.
assert.deepEqual(second, { layer: 'application', outcome: 'TOOL_SUCCESS' });
assert.equal(peer.calls.length, 2);
record(t, 'explicit_http_503_then_200', first, peer.calls);
});
test('HTTP 200 + JSON-RPC error -> JSONRPC_ERROR', t => testCase(t, 'jsonrpc_error',
{ status: 200, body: rpcError },
{ layer: 'protocol', outcome: 'JSONRPC_ERROR', code: -32602 }));
test('HTTP 200 + tool isError -> TOOL_ERROR', t => testCase(t, 'tool_isError',
{ status: 200, body: toolError }, { layer: 'application', outcome: 'TOOL_ERROR' }));
test('HTTP 500 with success-looking body -> HTTP_ERROR', t => testCase(t, 'transport_precedence',
{ status: 500, body: good }, { layer: 'transport', outcome: 'HTTP_ERROR', status: 500 }));
test('malformed JSON -> MALFORMED_JSON', t => testCase(t, 'malformed_json',
{ status: 200, body: '{unclosed' }, { layer: 'protocol', outcome: 'MALFORMED_JSON' }));
test('result and error together -> INVALID_ENVELOPE', t => testCase(t, 'conflicting_envelope',
{ status: 200, body: { ...good, error: rpcError.error } },
{ layer: 'protocol', outcome: 'INVALID_ENVELOPE' }));
test('wrong JSON-RPC id -> INVALID_ENVELOPE', t => testCase(t, 'wrong_response_id',
{ status: 200, body: { ...good, id: 99 } },
{ layer: 'protocol', outcome: 'INVALID_ENVELOPE' }));
test('input_required is not successful completion', t => testCase(t, 'input_required',
{ status: 200, body: { jsonrpc: '2.0', id: 1, result: {
resultType: 'input_required', inputRequests: { approval: {
method: 'elicitation/create', params: { mode: 'form', message: 'synthetic input' },
} },
} } }, { layer: 'interaction', outcome: 'INPUT_REQUIRED' }));
test('legacy result without resultType -> TOOL_SUCCESS', t => testCase(t, 'legacy_resultType_absent',
{ status: 200, body: { jsonrpc: '2.0', id: 1,
result: { content: [{ type: 'text', text: 'legacy ok' }], isError: false } } },
{ layer: 'application', outcome: 'TOOL_SUCCESS' }));
test('invalid tool content shape -> INVALID_TOOL_RESULT', t => testCase(t, 'invalid_tool_result',
{ status: 200, body: { jsonrpc: '2.0', id: 1, result: {
resultType: 'complete', content: 'not-an-array', isError: false,
} } }, { layer: 'protocol', outcome: 'INVALID_TOOL_RESULT' }));
접힌 영역에 전체 실행 코드가 있다. 위 명령으로 다시 실행할 수 있다. 저장된 TAP의 요약은 다음과 같다.
# tests 11
# pass 11
# fail 0
연구 저장소는 비공개다. 원본 소스, 전체 TAP, 실행 기록 파일은 저장소 접근 권한이 있는 협업자에게만 제공되며하므로 일반 독자에게 제공되는 공개 다운로드 주소가 아니다. 원본 SHA-256은 0f4616594ea14f23081b3da60d91808363b3b5b24a0889c560332bb5977f1cb2이며 저장된 원본 바이트를 검증한다. 브라우저에서 복사하면 줄바꿈이 달라질 수 있다. 예전 9/9 및 10/10 증거는 그대로 남아 있다.
실제 MCP 클라이언트를 위한 진단 설계: 로그와 안전한 재시도 경계
실제 환경에서는 UI의 성공/실패 표시만 보지 말고 각 요청과 응답을 연결해야 한다. 예를 들어 다음과 같은 가상 값으로 만든 로그 구조를 고려할 수 있다.
{
"client": "your-client@version",
"protocolVersion": "2026-07-28",
"scenario": "errors.jsonrpc_error",
"method": "tools/call",
"postCount": 1,
"httpStatuses": [200],
"jsonrpc": { "id": 1, "errorCode": -32602 },
"resultType": null,
"outcome": "JSONRPC_ERROR",
"retryOrigin": "none"
}
클라이언트 버전, 협상된 프로토콜 버전, 요청 _meta는 클라이언트 로그에서 확인한다. 시나리오 설정, POST 수, 순서는 서버 로그와 대조한다. HTTP 상태, Content-Type, JSON-RPC id, 민감 정보를 제거한 error.code / error.message / error.data도 남긴다. 이 간단한 분류기는 주로 오류 코드만 반환하므로 운영 진단 로그를 대신하지 못한다. retryOrigin으로 SDK 재시도, 비즈니스 로직 재전송, 수동 반복을 구별해야 한다.
| 관찰된 상태 | 자동 재시도? | 선행 조건 |
|---|---|---|
| HTTP 503 또는 일시적 네트워크 오류 | 조건부 | 읽기 전용 또는 멱등성을 확인하고 시간·횟수를 제한. 부작용이 불명확하면 중지 |
| HTTP 429 요청 제한 | 조건부 | Retry-After가 있으면 따르고, 없으면 서비스 할당량과 제한된 백오프 사용. 병렬 요청이나 여러 계정으로 우회 금지 |
JSON-RPC -32602 잘못된 인수 | 같은 인수로는 보통 재시도하지 않음 | 인수를 수정하고 새로 호출 |
도구 isError: true | 도구 의미에 따라 | 오류 내용과 업무 상태를 점검하고 무작정 재전송하지 않음 |
MALFORMED_JSON / INVALID_ENVELOPE / INVALID_TOOL_RESULT | 무작정 재전송 금지 | 비식별화한 응답, 요청 ID, 프로토콜 버전을 남겨 호환성 문제 조사 |
INPUT_REQUIRED | 오류 재시도가 아님 | MRTR 입력을 수집해 inputResponses를 포함한 후속 요청 발행 |
부작용이 없다고 확인된 읽기 요청에는 최대 두 번의 재시도, 초기 200 ms 지수 백오프와 지터를 시작 설정으로 고려할 수 있다. SLA와 요청 제한에 맞춰 조정한다. 429의 Retry-After가 실제로 제공되면 그 값이 우선한다. 결제, DB 쓰기, 알림 전송 같은 도구는 멱등 키나 서버 중복 제거가 없으면 503만으로 재실행해서는 안 된다. SSE는 Content-Type, 이벤트 수, 최종 JSON-RPC 응답 수신 여부 및 연결 종료 원인도 기록해야 한다. 최종 결과 없는 HTTP 200은 도구 성공이 아니다.
복구 전략은 어느 계층에서 실패했는지, 최종 결과가 있는지, 안전하게 재실행할 수 있는지에 따라 결정해야지 200과 503이라는 숫자만으로 결정할 수 없다.
추가 정보: 테스트 도구, 로컬 환경, 호스팅 서비스 제한
MCP Inspector는 메시지를 수동으로 관찰하는 도구이고, 공식 MCP Conformance는 프로토콜 적합성 검사에 쓰인다. Cakewalk Test Kitchen은 실패를 주입해 클라이언트 행동을 관찰한다. 목적이 서로 다르다.
Cakewalk 고정 README에 따르면 로컬 설치에는 .NET 10 SDK, Docker/PostgreSQL이 필요하며 Web UI 개발에는 Node.js 22.17.1을 사용한다. 로컬 /mcp에서는 테스트 PAT를 사용할 수 있고 관리 UI의 Google OAuth 설정은 별개 문제다.
Cakewalk 호스팅 약관(2026-09-21 표시)의 5.2는 공식 엔드포인트를 CI나 자동 부하 테스트에 연결하는 행위를 금지한다. 5.3은 IP당 분당 120건의 MCP 요청과 초과 시 HTTP 429를 명시한다. `8은 주입된 장애가 실제 장애를 재현한다고 보장하지 않는다. 약관에는 아직 서버가 비공개 소스라고 적혀 있지만 고정 공개 저장소에는 MIT LICENSE가 있다. 코드 라이선스와 호스팅 서비스 사용 조건은 나누어 판단해야 한다. 본문의 모든 자동 테스트는 로컬 루프백에서만 이루어졌다.
관련 글로 Gatus의 HTTP 503 경고와 복구는 실패 감지 시점을, Claude Code 로그 압축의 오류 보존 한계는 감지 뒤 증거를 보존하는 방법을 다룬다. 이 글은 그 사이에서 어느 계층에서 실패했는지를 분류한다.
로컬 루프백 테스트: 2026-10-17(UTC), 합성 사례 11개. Cakewalk 소스 고정 커밋: cf2f6897b9712ec0cb9a7e01abee5109385fcd8b. 실제 MCP 클라이언트의 재시도, SSE 연결 중단, MRTR 상호운용 테스트는 수행하지 않았다.