1. MCP와 JSON‑RPC: 한 번은 꼭 이해해야 하는 ‘지루하지만’ 탄탄한 기반
지난 강의에서는 MCP가 왜 필요한지, 그리고 Apps SDK 스택에 어떻게 들어맞는지 이야기했습니다. 이번에는 가장 ‘지루한’ 층위 — MCP 메시지 형식에 초점을 좁혀, 원시 JSON 로그를 자신 있게 읽고 ChatGPT가 여러분의 서버에 무엇을 보내고 서버가 무엇을 응답하는지 이해할 수 있도록 하겠습니다.
MCP는 데이터 운반에 JSON‑RPC 2.0을 사용합니다. 모든 요청, 응답, 알림은 예측 가능한 스키마를 가진 평범한 JSON 객체입니다.
즉 “서비스마다 제각각 포맷을 만든다” 대신, 기본 계약이 있습니다:
- 요청에는 필수 필드 jsonrpc(보통 "2.0"), 고유한 id, 문자열 메서드 이름 method, 그리고 파라미터를 담는 객체 params가 있다;
- 응답은 id로 요청과 연결되며, result나 error 중 하나를 포함한다;
- 알림(notification)은 요청과 비슷하지만 id가 없고, 응답이 없다.
대략 다음과 같습니다:
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/list",
"params": {
"cursor": null
}
}
이것이 request입니다. 성공 시 응답은 다음과 같습니다:
{
"jsonrpc": "2.0",
"id": 42,
"result": {
"tools": [],
"nextCursor": null
}
}
머릿속에 “이거 그냥 RPC잖아”가 스쳤다면 맞습니다. MCP는 어떤 메서드가 존재하는지(tools/list, tools/call, resources/list, prompts/list, …)와 어떤 형식으로 파라미터를 기대하고 데이터를 반환하는지를 정합니다.
핵심은 이것입니다. JSON‑RPC는 ‘요청–응답–알림’이라는 골격이고, MCP는 ‘구체적으로 어떤 요청들이 있고 그 안에 무엇이 들어가는가’를 정의합니다.
2. Request: MCP가 작업을 요청하는 방식
요청부터 시작합시다. 요청은 항상 “누군가가 무엇인가를 하길 원한다”라는 방향입니다. 일반적으로 클라이언트 → 서버(예: ChatGPT → 여러분의 MCP 서버)이지만, MCP는 서버가 클라이언트에게 sampling이나 elicitation을 요청하는 역방향 요청도 허용합니다. 이 강의에서는 주로 고전적인 경우, 즉 클라이언트가 서버에 요청하는 흐름을 살펴봅니다.
모든 MCP‑request에는 세 가지 핵심 필드가 있습니다:
- jsonrpc — JSON‑RPC 프로토콜 버전(보통 "2.0").
- id — 요청 식별자. JSON 타입이면 무엇이든 되지만, 실제로는 숫자나 문자열이 흔합니다. 중요한 것은 활성 요청 동안 id가 고유해야 한다는 점입니다.
- method — "tools/list" 또는 "tools/call" 같은 문자열. MCP는 허용 가능한 메서드 집합을 정의합니다.
그리고 각 메서드의 파라미터가 담기는 params 객체가 있습니다.
예시: 도구 목록 요청
ChatGPT가 방금 여러분의 MCP 서버에 연결되어, 호출 가능한 tools가 무엇인지 알고 싶다고 해봅시다. 대략 다음과 같은 요청을 보냅니다:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"cursor": null
}
}
cursor 필드는 페이지네이션을 위한 것입니다. 도구가 많다면 서버는 여러 번에 나눠 돌려줄 수 있습니다.
우리의 학습용 애플리케이션(선물 추천)에서는 지금은 도구가 한두 개뿐이라 단출하겠지만, 프로토콜은 동일합니다. 일단은 직관적인 예로 받아들이세요. 공식 구조는 아래 tools 섹션에서 자세히 다룹니다.
예시: 도구 호출(tools/call)
이제 조금 더 흥미로운 부분입니다. MCP‑tool suggest_gifts가 있다고 가정합시다(서버 강의에서 구현할 예정). 이 도구는 다음 파라미터를 기대합니다:
- occasion — 계기(예: Birthday, Wedding, …),
- budget — 달러 단위의 숫자,
- recipient — 선물을 받을 사람에 대한 설명 문자열.
ChatGPT가 이 도구를 사용하기로 결정하면 MCP 요청을 다음과 같이 구성합니다:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "suggest_gifts",
"arguments": {
"occasion": "birthday",
"budget": 100,
"recipient": "friend who loves board games"
}
}
}
몇 가지 디테일에 주목하세요.
첫째, 도구 이름은 서버 쪽에서 여러분이 선언한 값(server.registerTool("suggest_gifts", …))에서 가져옵니다. 둘째, arguments 객체는 도구 설명에 포함된 JSON Schema를 만족해야 합니다.
만약 GPT가 스키마를 어기는 인자를 보내려 한다면(예: budget: "백 달러"), 구현에 따라 서버는 프로토콜 레벨 또는 비즈니스 로직 레벨에서 오류를 반환할 수 있습니다. 지금은 이러한 요청의 전반적인 형태를 파악하는 것이 중요합니다. 아래 tools 섹션에서 동일한 메시지를 더 체계적으로 다시 살펴보겠습니다.
리소스와 프롬프트를 위한 Requests
리소스와 프롬프트에 대한 요청도 비슷한 모양입니다. MCP 스펙은 다음 메서드를 정의합니다:
- resources/list — 사용 가능한 리소스 열거;
- resources/read (또는 resources/get) — URI로 특정 리소스를 읽기;
- prompts/list — 사용 가능한 프롬프트 목록 얻기;
- prompts/get — 특정 프롬프트의 텍스트 얻기.
선물 카탈로그 리소스를 읽는 요청 예:
{
"jsonrpc": "2.0",
"id": 15,
"method": "resources/read",
"params": {
"uri": "mcp://gift-server/resources/gift_catalog"
}
}
지금은 두 가지만 기억하세요. 첫째, 각 프리미티브에는 */list와 */get/*/read 메서드가 있다. 둘째, 메서드 이름은 항상 문자열 필드 method에 있고, 모든 내용물은 객체 params에 들어간다.
3. Reply: MCP의 응답 — result와 error
응답(reply)은 항상 요청의 id와 연결됩니다. 이는 많은 분산 시스템에서의 correlationId와 유사합니다. 로그에서 id=7인 요청이 있고, id=7인 응답을 보면 한 쌍이라는 뜻입니다.
JSON‑RPC는 간단한 규칙을 정합니다: 응답에는 오직 result 또는 error만 있고, 둘 다 동시에 존재하지 않습니다. MCP는 여기에 더해, 각 메서드(tools/list, tools/call 등)에 대한 result 구조와 권장 오류 코드를 구체화합니다.
성공 응답(result)
우리의 suggest_gifts에 대한 tools/call 성공 응답 예를 보겠습니다. 서버가 정상 처리하여 적절한 선물을 찾았고, 그 목록을 result에 담아 반환합니다:
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"content": [
{
"type": "text",
"text": "Here are some gift ideas for your friend..."
}
],
"structuredContent": {
"gifts": [
{ "name": "Board game: Catan", "price": 45 },
{ "name": "Dice set", "price": 20 }
]
},
"isError": false
}
}
여기서 중요한 포인트가 몇 가지 있습니다.
- 첫째, content와 structuredContent는 Apps SDK에서 이미 보았던 MCP‑tools 응답의 두 부분입니다. 모델은 content의 텍스트를 활용하고, 여러분의 위젯은 structuredContent의 데이터를 보기 좋게 렌더링합니다.
- 둘째, isError 플래그는 비즈니스 결과에 관한 것입니다. 프로토콜 관점에서는 모든 것이 성공했습니다: JSON은 유효하고, 메서드도 존재하며, 인자도 파싱되었습니다. 하지만 비즈니스 로직은 “적절한 선물 아이디어를 하나도 찾지 못했으니 UX 관점에서 오류로 간주하자”라고 판단할 수 있습니다. 이 경우 isError: true로 설정하고, 문제를 content에 설명합니다.
- 셋째, MCP 스펙은 각 메서드(tools/list, tools/call, */list, */get)에 대해 result에 어떤 필드가 있어야 하는지 상세히 규정합니다. 예를 들어 tools/list에서는 서버가 도구의 이름, 제목, 설명, 입력 인자의 JSON Schema를 포함한 도구 설명 배열을 반환합니다.
오류 응답(error)
프로토콜 또는 서버 레벨에서 문제가 생기면 result 대신 error 객체를 반환합니다. 일반적으로 다음을 포함합니다:
- code — 숫자형 오류 코드;
- message — 사람이 읽을 수 있는 설명;
- data — 선택적 추가 데이터(스택 트레이스, 상세 정보 등).
예: 존재하지 않는 메서드를 호출한 경우:
{
"jsonrpc": "2.0",
"id": 99,
"error": {
"code": -32601,
"message": "Method not found: tools/col"
}
}
코드 -32601은 JSON‑RPC에서 “method not found”의 고전적인 코드입니다.
두 가지 오류 유형 사이에는 미묘하지만 중요한 경계가 있습니다.
프로토콜 오류 — MCP/JSON‑RPC 규칙이 위반된 경우: 알 수 없는 메서드, params의 잘못된 타입, 유효하지 않은 JSON 등. 이때는 상위 레벨에서 error를 반환하는 것이 적절합니다.
비즈니스 오류 — 프로토콜은 만족하지만 도메인 사유로 작업이 실패한 경우: 빈 카탈로그, 특정 리소스에 대한 권한 없음, 잘못된 비즈니스 식별자 등. MCP는 보통 유효한 result를 반환하되 isError: true로 표시하고, 내용을 통해 문제를 설명하도록 권장합니다.
이 구분은 ChatGPT와 디버깅 도구에 큰 도움이 됩니다. 로그만 보아도 기술적 고장인지, 비즈니스 로직의 의도적 거부인지 즉시 파악할 수 있습니다.
4. Notifications: 단방향 메시지
알림(notification)은 “응답을 기대하지 않는 편지”입니다. JSON‑RPC에서 알림은 필드 id가 없는 일반 요청처럼 보입니다. 클라이언트는 이에 대한 reply를 보내지 않아야 합니다.
MCP에서 알림은 이벤트 용도로 사용됩니다: tools/resources/prompts 목록 변경, 장기 작업의 진행 상황, 로그 메시지 등.
가장 단순하면서도 실제로 자주 보게 될 예는 도구 목록이 변경되었다는 알림입니다. tools에 대한 MCP 스펙은 capability listChanged와 알림 tools/list_changed를 정의하며, 사용 가능한 도구 집합이 바뀌면 서버가 이 알림을 보냅니다.
알림은 다음처럼 생길 수 있습니다:
{
"jsonrpc": "2.0",
"method": "tools/list_changed",
"params": {
"reason": "New tool 'suggest_gift_cards' was added"
}
}
이에 대한 응답은 필요 없습니다. 클라이언트는 이 알림을 받으면 “아, tools/list를 다시 호출해 도구 캐시를 갱신해야겠다”라고 판단할 수 있습니다.
다른 전형적인 MCP 알림(스트림과 이벤트 모듈에서 자세히 다룹니다):
- 장기 작업을 위한 진행 이벤트(notifications/progress);
- 서버 로그(notifications/logging/message);
- 리소스(resources/list_changed) 및 프롬프트(prompts/list_changed) 변경.
지금 중요한 점은 하나입니다: 알림 = id 가 없는 요청이며, 응답을 기대하지 않는다. 로그에서 id 없는 JSON을 보면, 대개 notification입니다.
인사이트
실험적으로 확인된 바에 따르면 ChatGPT App은 자신에게 전달된 MCP 알림(MCP‑notification)을 무시합니다. 다만 ChatGPT Apps가 이제 막 성장 초기 단계인 점을 감안하면, 가까운 미래에 MCP 프로토콜의 모든 측면을 충분히 지원할 가능성이 큽니다. 그러니 MCP 프로토콜의 이 측면도 꼭 익혀 두시길 권합니다.
5. 메시지에서 tools/resources/prompts는 어떻게 보이나
이제 가장 핵심적인 부분입니다. 우리가 계속 이야기해 온 tools, resources, prompts가 MCP 메시지 내부에서 구체적으로 어떻게 표현되는지 살펴보겠습니다.
Tools: 설명과 호출
프로토콜 레벨에서 tools에는 두 가지 주요 과정이 있습니다:
- discovery — 클라이언트가 어떤 도구가 있는지 알아냅니다;
- invocation — 클라이언트가 특정 도구를 호출합니다.
위에서 이미 tools/list와 tools/call을 살짝 보았습니다. 이제 이들이 어떤 프로세스를 다루는지, 그리고 result에 무엇이 들어오는지 좀 더 체계적으로 보겠습니다.
5.1.1. 도구 목록 — tools/list
우리는 이미 tools/list의 request를 보았습니다. 응답 구조를 살펴봅시다. MCP 스펙에 따르면 result.tools에는 각 도구를 설명하는 객체들의 배열이 와야 합니다. 각 도구에는 다음이 반드시 있어야 합니다:
- name — 나중에 tools/call로 호출할 때 사용할 고유 이름;
- title — 짧은 제목(사람과 모델 모두가 봅니다);
- description — 동료에게 설명하듯 도구가 무엇을 하는지 더 자세한 설명;
- inputSchema — 도구 인자에 대한 JSON Schema.
우리의 suggest_gifts에 대해, tools/list 응답은(매우 단순화하여) 다음처럼 생길 수 있습니다:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "suggest_gifts",
"title": "Gift ideas generator",
"description": "Suggests gift ideas for a given occasion and budget.",
"inputSchema": {
"type": "object",
"properties": {
"occasion": { "type": "string" },
"budget": { "type": "number" },
"recipient": { "type": "string" }
},
"required": ["occasion", "budget"]
}
}
],
"nextCursor": null
}
}
여러분이 Apps SDK에서 도구를 등록할 때 inputSchema를 적었다면, 사실상 방금 본 것과 동일한 객체를 “위에서” — TypeScript 객체 형태로 본 셈입니다. MCP는 이를 프로토콜을 통해 클라이언트로 전달할 뿐입니다.
5.1.2. 도구 호출 — tools/call
호출 형식은 이미 언급했습니다. MCP 스펙은 params에 다음이 있어야 한다고 설명합니다:
- name — 도구 이름;
- arguments — inputSchema에 맞는 객체.
예를 들어:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "suggest_gifts",
"arguments": {
"occasion": "wedding",
"budget": 150,
"recipient": "coworker from marketing"
}
}
}
응답에서 서버는 content, structuredContent, 그리고 선택적으로 _meta(예: 특정 위젯과 이 도구를 연결하고 싶을 때 openai/outputTemplate 표기)를 담은 result를 반환합니다.
이 tools/list → tools/call 연결이 MCP‑tools의 기본 사이클입니다. 먼저 discovery, 그다음 사용입니다.
Resources: 주소가 있는 데이터
MCP의 리소스는 클라이언트가 URI로 접근할 수 있는 모든 데이터 조각입니다. 파일, DB 레코드, 구성, 카탈로그 등 무엇이든 될 수 있습니다.
표준 작업 집합은 다음과 같습니다:
- resources/list — 어떤 리소스가 있는지 확인;
- resources/read — 특정 리소스(또는 일부)를 읽기.
예를 들어 gift_catalog라는 리소스가 있고, 기본 선물 카탈로그(카테고리, 브랜드, 최소/최대 가격)를 설명한다고 합시다. 서버는 이를 URI "mcp://gift-server/resources/gift_catalog"로 선언할 수 있습니다.
resources/list에 대한 응답은(단순화하여) 다음과 같을 수 있습니다:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resources": [
{
"uri": "mcp://gift-server/resources/gift_catalog", // 그냥 고유한 문자열. mcp는 프로토콜이 아님.
"name": "gift_catalog",
"description": "Base catalog of gifts with categories and prices",
"mimeType": "application/json"
}
],
"nextCursor": null
}
}
resources/read — 리소스 읽기 요청:
{
"jsonrpc": "2.0",
"id": 4,
"method": "resources/read",
"params": {
"uri": "mcp://gift-server/resources/gift_catalog"
}
}
응답에는 실제 콘텐츠와 메타데이터가 담길 수 있습니다:
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"contents": [
{
"uri": "mcp://gift-server/resources/gift_catalog",
"mimeType": "application/json",
"text": "{\"categories\":[\"boardgames\",\"books\"]}"
}
]
}
}
핵심 개념은 이렇습니다. 리소스는 주소 지정 가능한 데이터이고, 도구는 작업(연산)입니다. MCP는 이 둘을 프로토콜에서 명시적으로 드러냅니다.
Prompts: 재사용 가능한 템플릿
프롬프트는 서버가 클라이언트에 제공할 수 있는 “준비된 힌트” 또는 템플릿입니다. MCP는 다음을 가진 프리미티브로 다룹니다:
- 이름;
- 사람이 읽을 수 있는 제목/설명;
- 내용(종종 system 프롬프트 템플릿 또는 소수의 예시 세트).
예상대로 두 가지 메서드가 있습니다:
- prompts/list — 어떤 프롬프트가 있는지 알아보기;
- prompts/get — 단일 프롬프트의 내용을 가져오기.
예컨대 선물과 함께 축하 메시지를 생성할 때 사용할 특별한 스타일을 지정하고 싶다면 MCP 서버에 gift_congrats_style 프롬프트를 선언할 수 있습니다.
prompts/list에 대한 응답은 다음과 같을 수 있습니다:
{
"jsonrpc": "2.0",
"id": 10,
"result": {
"prompts": [
{
"name": "gift_congrats_style",
"description": "Style guide for birthday congratulations in a friendly tone"
}
]
}
}
그리고 prompts/get은 클라이언트가 이후 LLM의 system 프롬프트 일부로 전달할 수 있는 텍스트(또는 구조화된 내용)를 반환합니다. 요청과 응답 예시는 다음과 같습니다:
{
"jsonrpc": "2.0",
"id": 11,
"method": "prompts/get",
"params": {
"name": "gift_congrats_style"
}
}
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"prompt": {
"name": "gift_congrats_style",
"messages": [
{
"role": "system",
"content": [
{
"type": "text",
"text": "You are a friendly assistant that writes short, warm birthday congratulations..."
}
]
}
]
}
}
}
6. Apps SDK와 우리 위젯과의 연관성
지금 MCP‑JSON이 여전히 조금은 장황해 보일 수 있습니다. 여러분이 이미 Apps SDK로 해본 것과 연결해 보겠습니다.
리마인드: 프런트엔드 위젯에는 다음과 같은 코드가 있을 수 있습니다:
// ChatGPT 샌드박스의 React 컴포넌트 내부
async function fetchGifts() {
const result = await window.openai.callTool("suggest_gifts", {
occasion: "birthday",
budget: 50,
recipient: "friend who loves sci-fi"
});
console.log(result);
}
Apps SDK 레벨에서 이 함수는 다음을 편리하게 처리합니다:
- MCP 서버 URL을 알고 있고(앱 구성에서 가져옴);
- suggest_gifts라는 이름으로 도구 설명을 찾을 수 있으며;
- 여러분의 호출을 MCP‑request tools/call로 포장하고;
- 선택한 전송(HTTP/SSE)으로 보낸 뒤;
- MCP‑reply를 기다렸다가 result를 풀어서 JavaScript의 result로 돌려줍니다.
이를 다이어그램으로 그리면 대략 이렇습니다:
sequenceDiagram
participant Widget
participant AppsSDK as Apps SDK
participant MCP as MCP 서버
Widget->>AppsSDK: window.openai.callTool("suggest_gifts", {...})
AppsSDK->>MCP: JSON { id:7, method:"tools/call", params:{...} }
MCP-->>AppsSDK: JSON { id:7, result:{ content, structuredContent } }
AppsSDK-->>Widget: result (ToolOutput)
Widget->>Widget: setState(toolOutput)
MCP 형식을 이해하면 두 가지 멋진 능력이 생깁니다.
첫째, 원시 MCP 로그(MCP Inspector에 대해서는 별도 강의 예정)를 제대로 볼 수 있습니다. 어떤 tools/call이 나갔는지, 그 안의 인자는 무엇이었는지, result 또는 error에 무엇이 들어왔는지를 확인할 수 있습니다.
둘째, 도구와 리소스를 설계할 때 TypeScript 타입만이 아니라 MCP 스키마 관점에서도 생각할 수 있습니다. JSON에서 어떻게 보일지, 다른 클라이언트(예: 여러분의 MCP 서버에 붙을 수 있는 에이전트들)에게도 얼마나 사용하기 편한지까지 고려하는 것이죠.
7. 미니 실습: MCP‑JSON 읽고 ‘고치기’
MCP 형식을 체화하려면 직접 몇 가지 메시지를 분해해 보는 것이 가장 좋습니다. tools/list → tools/call → 결과의 한 흐름을 예로 들어봅시다.
클라이언트가 도구 목록을 원합니다
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}
우리가 보는 것:
- request입니다(id가 있음);
- 메서드는 tools/list — 도구 discovery에 관한 것입니다;
- 페이지네이션 없이 비어 있는 파라미터.
서버가 응답합니다:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "suggest_gifts",
"title": "Gift ideas generator",
"description": "Suggests gift ideas",
"inputSchema": { "type": "object", "properties": { "occasion": { "type": "string" } } }
}
]
}
}
이것이 바로 그 요청에 대한 응답임이 바로 보입니다(동일한 id: 1). 프로토콜도 성공(result가 있고 error는 없음). 이제 클라이언트는 suggest_gifts 도구가 존재함을 알게 됩니다.
클라이언트가 도구를 호출합니다
이어서 클라이언트가 tools/call을 수행합니다:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "suggest_gifts",
"arguments": {
"occasion": "anniversary"
}
}
}
서버가 budget도 기대하지만 모델이 이를 지정하지 않았다면, 서버는 다음 중 하나를 선택할 수 있습니다:
- 프로토콜 오류를 반환(예: “invalid params” 코드의 error);
- 기본 결정을 적용(예: 평균 예산 사용)하고 정상적인 result를 반환.
앞서 소개한 용어로, 첫 번째는 프로토콜 오류(상위 레벨의 error)이고, 두 번째는 비즈니스 로직의 영역입니다. 이 경우에도 유효한 result를 반환하고, 이를 비즈니스 오류로 볼지(isError: true) 정상 동작으로 볼지 결정할 수 있습니다.
인자 오류일 때의 응답 예시는 다음과 같을 수 있습니다:
{
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": -32602,
"message": "Missing required property 'budget' in arguments"
}
}
다시 강조하지만, 이것은 비즈니스 오류와 구별해야 합니다. 인자가 스키마와 맞지 않아 프로토콜이 위반되었으므로 여기서는 error가 적절합니다.
깨진 예: 버그 찾기
초보자가 가끔 작성하는 JSON입니다:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"tool": "suggest_gifts",
"args": {
"occasion": "birthday",
"budget": 100
}
}
}
언뜻 그럴싸해 보이지만, MCP 스펙과 대조해 보면 tool과 args 필드가 기대되는 name과 arguments와 일치하지 않는다는 것을 알 수 있습니다.
MCP‑SDK의 클라이언트/서버는 이런 JSON을 생성하지 않겠지만, 스펙을 모른 채 수동으로 통합한다면 이런 버그가 충분히 발생할 수 있습니다. 바로 이런 이유로, 우리는 SDK 래퍼만이 아니라 프로토콜 자체를 ‘맨바닥’부터 다루고 있습니다.
8. MCP 메시지 작업 시 흔한 실수
오류 1: 프로토콜 오류와 비즈니스 오류를 뒤섞기.
개발자들은 흔히 “잘못된 건 전부” 상위 error로 감싸곤 합니다 — 리소스 없음, 잘못된 인자, DB 장애까지 모두요. MCP에서는 구분하는 것이 유익합니다. JSON 구조와 호출 스키마가 위반되었을 때(잘못된 메서드, 잘못된 필드, 타입 불일치)는 error를 반환하세요. 반면 도구가 도메인 작업을 수행하지 못한 경우(해당 예산에 맞는 선물이 없음, 사용자를 찾을 수 없음)는 유효한 result를 반환하되 isError: true와 함께 content에 명확한 메시지를 넣는 편이 좋습니다. 그러면 ChatGPT 모델과 디버거가 “통신 채널이 망가짐”과 “서버의 의도적 거부”를 정확히 구분할 수 있습니다.
오류 2: id와 요청 상관관계 무시.
가끔 MCP 서버 로그에서 id 없이 수동으로 찍힌 출력이나, 서로 다른 활성 요청에 중복된 id 값을 볼 수 있습니다. 단일 스레드의 hello‑world 수준에서는 그럭저럭 돌아갈 수 있지만, 병렬 호출이나 재시도가 등장하는 순간 “어떤 응답이 어떤 요청의 것인지” 파악하기 어려워집니다. JSON‑RPC는 요청의 생명 주기 동안 고유한 id를 요구하며, MCP는 이 규칙에 의존합니다. 공식 SDK를 사용한다면 id를 신경 쓸 필요가 거의 없지만, 전송이나 로깅을 직접 구현한다면 id를 저장하고 출력하는 습관을 잊지 마세요 — 이상한 버그를 디버깅할 때 가장 먼저 보게 될 정보입니다.
오류 3: 동일 메서드에 대해 불안정한 result 구조.
상황에 따라 응답 형식을 ‘살짝’ 바꾸고 싶어질 때가 있습니다. 어떤 때는 선물 배열을, 어떤 때는 문자열 하나만 든 객체를, 또 어떤 때는 structuredContent 없이 text만 반환하는 식으로요. 모델은 운 좋게 넘어갈 수 있어도, 여러분의 위젯과 다른 MCP 클라이언트들은 그렇지 않습니다. MCP 스펙은 각 메서드에 대해 예측 가능한 result 구조를 설명합니다. 그 구조를 지키도록 하세요. 다른 형식이 필요하다면 스키마를 상황에 따라 바꾸기보다는 별도의 도구나 버전을 선언하는 편이 낫습니다.
오류 4: params의 과잉 또는 누락 필드.
커스텀 구현에서 흔한 문제는 MCP가 기대하지 않는 것을 params에 추가하거나, 필수 필드를 빼먹는 것입니다. 예를 들어 tools/call에서 name 대신 toolName을 보내거나, resources/read에서 uri 대신 resourceId를 보내는 경우입니다. MCP‑SDK는 보통 이런 것을 검증하고 이해하기 쉬운 예외를 던지지만, 프로토콜에 더 가까이서 작업하면 “왜 서버가 나를 이해하지 못하는지”를 한참 찾게 됩니다. 좋은 방법은 핸들러 옆에 스펙이나 정상 동작하는 클라이언트 로그에서 가져온 올바른 JSON 요청 예시를 두고, 여러분이 보내는 것과 비교하는 것입니다.
오류 5: 알림을 ‘두 번째 응답 채널’로 쓰려는 시도.
어떤 개발자들은 notifications를 보고, 일반 reply 대신 알림으로 작업 결과를 보내기 시작합니다. “어차피 MCP고 SSE도 있으니, 다 알림으로 푸시하자”라는 식이죠. 문제는 JSON‑RPC 알림은 정의상 특정 id에 묶이지 않으며, 클라이언트도 이를 요청의 응답으로 인식하지 않는다는 점입니다. 그 결과 디버깅이 어려워지고, 특정 도구 호출에 어떤 메시지가 대응되는지 알 수 없게 됩니다. 알림은 이벤트(도구/리소스/프롬프트 목록 변경, 진행 상황, 로그)에 아주 적합하지만, tools/call 등의 일반 응답을 대체할 수는 없습니다.
오류 6: MCP 로그와 인스펙터를 보지 않기.
가장 인간적인 실수는 ChatGPT UI만으로 통합을 디버깅하려는 것입니다. “버튼 눌렀는데 뭐가 안 왔네, 언젠가 보자” 같은 식이죠. 원시 MCP 메시지(requests, replies, notifications)를 보지 못하면 문제가 어느 레벨에서 생겼는지 파악하기 어렵습니다. 모델이 도구를 호출하지 않았는지, Apps SDK가 MCP 서버까지 가지 못했는지, 서버가 잘못된 JSON을 반환했는지, 아니면 위젯 렌더링에서 깨졌는지 말입니다. MCP Inspector / Jam과 MCP 메시지의 구조화된 로깅은 최고의 도우미입니다. 실제 tools/call과 tools/list가 로그에 찍히는 것을 한 번만 봐도, MCP 메시지 형식은 ‘마법’이 아니라 일상적인 엔지니어링 루틴으로 느껴질 것입니다.
GO TO FULL VERSION