본문으로 건너뛰기
AIPida

MCP(Model Context Protocol) 완전 가이드

LLM에 도구·데이터·프롬프트를 표준 방식으로 연결하는 오픈 프로토콜 — 첫 서버부터 프로덕션 배포·인증·디버깅까지

실전AI 에이전트·

AI 에이전트를 만들다 보면 똑같은 문제에 반복적으로 부딪힌다. 모델에게 사내 데이터베이스를 쿼리하게 하고, GitHub 이슈를 읽게 하고, 파일 시스템을 뒤지게 하려면 매번 함수 호출(tool use) 스키마를 직접 정의하고, 실행 루프를 짜고, 그걸 각 모델 제공자의 API 형식에 맞춰 또 변환해야 한다. 같은 "GitHub 연동"을 Claude용, GPT용, 로컬 LLM용으로 세 번 만든다. 이게 N×M 통합 지옥이다 — N개의 모델/호스트 × M개의 도구.

MCP(Model Context Protocol)는 이 문제를 어댑터 한 겹으로 해결한다. Anthropic이 2024년 11월 오픈소스로 공개했고, 지금은 OpenAI·Google DeepMind를 포함한 광범위한 생태계가 채택했다. 핵심 아이디어는 단순하다: 도구·데이터·프롬프트를 노출하는 표준 서버 인터페이스(MCP 서버)를 한 번 만들면, 그 서버를 이해하는 어떤 호스트(Claude Desktop, Claude Code, Cursor, 커스텀 에이전트…)에서도 그대로 쓸 수 있다. USB-C가 충전기·모니터·디스크를 한 포트로 통합한 것처럼, MCP는 "LLM에 컨텍스트를 주입하는 방식"을 한 프로토콜로 통합한다.

이 가이드는 개념 설명에서 그치지 않는다. JSON-RPC 위에서 MCP가 실제로 어떻게 동작하는지, stdio와 Streamable HTTP 두 전송 방식이 어떻게 다른지, TypeScript와 Python 공식 SDK로 도구·리소스·프롬프트를 구현하는 법, Claude Code/Desktop과 Claude API(mcp_servers)에 연결하는 법, OAuth 인증을 붙이는 법, 그리고 가장 많이 막히는 디버깅 함정들까지 — 복사해서 바로 돌릴 수 있는 코드로 다룬다.

버전 주의: MCP 명세는 날짜 기반으로 빠르게 버전이 올라간다(예: 2025-03-26에 Streamable HTTP 도입). 이 가이드의 코드는 현행 명세 기준이지만, 실제 구현 전 modelcontextprotocol.io에서 최신 명세 버전과 SDK 버전을 확인하라. 본문에서 "최신 버전에서 도입"이라고 표기한 것은 정확한 날짜 버전을 기억에 의존하지 말고 직접 확인하라는 뜻이다.

MCP의 아키텍처: 호스트 · 클라이언트 · 서버

MCP는 세 가지 역할로 구성된다. 이 셋을 헷갈리지 않는 게 출발점이다.

역할정체하는 일
호스트(Host)LLM을 품은 애플리케이션Claude Desktop, Claude Code, Cursor, 커스텀 에이전트. 사용자와 모델이 여기 산다
클라이언트(Client)호스트 안의 커넥터MCP 서버 하나당 클라이언트 하나. 1:1로 연결을 관리한다
서버(Server)능력을 노출하는 프로세스당신이 만드는 것. 도구·리소스·프롬프트를 제공한다

관계는 이렇다. 하나의 호스트는 여러 클라이언트를 띄우고, 각 클라이언트는 정확히 하나의 서버와 연결된다. Claude Desktop에 GitHub 서버, Postgres 서버, 파일시스템 서버를 동시에 붙이면, 내부적으로 클라이언트 3개가 각각의 서버 프로세스와 1:1로 말을 주고받는다.

┌─────────────── Host (Claude Desktop) ───────────────┐
│                                                      │
│   LLM  ◄──────►  Client A ──── stdio ────► Server: filesystem
│                  Client B ──── stdio ────► Server: github
│                  Client C ──── HTTP ─────► Server: company-db (원격)
│                                                      │
└──────────────────────────────────────────────────────┘

왜 1:1인가? 격리 때문이다. 한 서버가 크래시하거나 악의적으로 굴어도 다른 서버 연결에 영향을 주지 않는다. 각 클라이언트-서버 쌍은 독립적인 capability(능력) 협상과 상태를 가진다.

서버가 노출하는 세 가지 원시 능력(primitives):

  • Tools (도구) — 모델이 호출하는 것. create_issue, query_db, send_email. 부수효과가 있고, 모델이 능동적으로 결정해서 실행한다. (model-controlled)
  • Resources (리소스) — 모델/사용자가 읽는 것. 파일 내용, DB 레코드, 로그. URI로 식별되고 부수효과가 없다. (application-controlled)
  • Prompts (프롬프트) — 사용자가 선택하는 재사용 템플릿. "이 PR 리뷰해줘" 같은 슬래시 명령. (user-controlled)

이 세 가지의 "누가 제어하는가" 축이 설계 시 가장 중요하다. 데이터를 모델이 알아서 가져오게 하고 싶으면 Tool, 사용자가 컨텍스트로 첨부하게 하고 싶으면 Resource, 정형화된 워크플로를 버튼처럼 노출하고 싶으면 Prompt다.

흔한 함정: 모든 걸 Tool로 만든다. 읽기 전용 데이터까지 Tool로 만들면 모델이 그걸 "행동"으로 인식해 불필요하게 호출하고, 호스트는 그것을 사용자 승인 게이트에 걸 수도 있다. 순수 읽기는 Resource가 맞다 — 다만 현실적으로 많은 호스트(Claude 포함)가 Resource보다 Tool 지원이 성숙해서, "모델이 능동적으로 가져와야 하는 데이터"는 Tool로 두는 게 실용적일 때가 많다. 둘 다 제공하는 것도 가능하다.

프로토콜의 실체: JSON-RPC 2.0 메시지

MCP는 마법이 아니라 JSON-RPC 2.0 위에 정의된 메시지 규약이다. 전송 채널(stdio든 HTTP든) 위로 다음 세 종류의 JSON 메시지가 오간다.

  • Requestid가 있고 응답을 기대한다
  • Response — 같은 id로 답한다 (result 또는 error)
  • Notificationid가 없고 응답을 기대하지 않는다 (단방향 알림)

1) 연결은 항상 initialize 핸드셰이크로 시작한다. 클라이언트가 먼저 보낸다:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-03-26",
    "capabilities": { "tools": {}, "resources": {} },
    "clientInfo": { "name": "claude-desktop", "version": "1.0.0" }
  }
}

서버가 자신의 능력을 응답한다:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-03-26",
    "capabilities": {
      "tools": { "listChanged": true },
      "resources": { "subscribe": true }
    },
    "serverInfo": { "name": "my-server", "version": "0.1.0" }
  }
}

그 뒤 클라이언트가 notifications/initialized 알림을 보내면 세션이 운영 단계로 들어간다. 이 capability 협상이 핵심이다 — 서버가 tools 능력을 광고하지 않으면 클라이언트는 도구 관련 메서드를 아예 호출하지 않는다.

2) 도구 목록 조회 — tools/list:

{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }

응답에는 각 도구의 name, description, 그리고 **JSON Schema 형식의 inputSchema**가 담긴다. 모델은 이 description과 schema를 보고 언제 어떻게 호출할지 판단한다.

3) 도구 호출 — tools/call:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "city": "Seoul" }
  }
}

응답의 result.content는 텍스트/이미지/리소스 블록의 배열이다:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [{ "type": "text", "text": "Seoul: 22°C, clear" }],
    "isError": false
  }
}

중요한 에러 구분: 도구 실행 중 발생한 에러(예: 외부 API 실패)는 JSON-RPC 에러로 던지지 말고, result 안에서 isError: true로 표시하라. 그래야 모델이 그 에러를 보고 다른 접근을 시도할 수 있다. JSON-RPC 프로토콜 레벨 에러(error 객체)는 "존재하지 않는 메서드 호출" 같은 프로토콜 위반에만 쓴다. 이걸 헷갈리면 도구 실패가 모델에게 전달되지 않고 연결만 끊긴 것처럼 보인다.

실무에서는 이 JSON을 손으로 짤 일이 거의 없다 — SDK가 추상화해준다. 하지만 디버깅할 때 와이어에 흐르는 게 결국 이 메시지들이라는 걸 알면 문제 추적이 훨씬 빠르다.

전송 방식: stdio vs Streamable HTTP

MCP 서버는 두 가지 표준 전송 방식 중 하나로 동작한다. 어느 쪽을 고르느냐가 배포 모델을 결정한다.

stdio (표준 입출력)

호스트가 서버를 자식 프로세스로 직접 실행하고, 그 프로세스의 stdin/stdout으로 JSON-RPC 메시지를 줄바꿈 구분(newline-delimited)으로 주고받는다.

Host ──(spawn)──► node my-server.js
     ──stdin────► {"jsonrpc":"2.0","id":1,...}\n
     ◄──stdout──── {"jsonrpc":"2.0","id":1,"result":...}\n
  • 장점: 네트워크 없음, 인증 불필요, 지연 최소, 로컬 파일·환경에 직접 접근. 로컬 도구(파일시스템, git, 로컬 DB)에 최적.
  • 단점: 호스트와 같은 머신에 있어야 함. 원격 공유 불가.
  • 언제: 개인 개발 도구, Claude Desktop/Code용 로컬 서버. MCP 입문은 거의 항상 stdio로 시작한다.

⚠️ stdio 최대 함정: stdout은 오직 JSON-RPC 메시지 전용이다. console.log()(JS)나 print()(Python)로 디버그 로그를 stdout에 찍으면 그게 JSON 스트림에 섞여 들어가 파싱이 깨지고, 호스트는 "서버가 죽었다"고만 보고한다. 모든 로그는 stderr로 보내라console.error(), 또는 Python이면 logging을 stderr 핸들러로 설정. SDK의 stdio_server/StdioServerTransport는 이미 이걸 전제로 동작한다.

Streamable HTTP

서버가 HTTP 엔드포인트(예: POST /mcp)로 동작한다. 클라이언트가 HTTP POST로 요청을 보내고, 서버는 단일 JSON 응답을 주거나 SSE(Server-Sent Events) 스트림으로 여러 메시지를 흘려보낼 수 있다(긴 작업의 진행 알림, 서버→클라이언트 알림 등).

  • 장점: 원격 배포 가능, 여러 사용자가 한 서버 공유, 표준 HTTP 인프라(로드밸런서·인증·CDN) 활용.
  • 단점: 인증·세션·CORS·보안을 직접 관리. 운영 복잡도 ↑.
  • 언제: 팀/조직이 공유하는 서버, SaaS형 MCP 서버, 클라우드 배포.

역사 참고: 초기 명세에는 "HTTP+SSE"라는 별도 전송이 있었으나, 이후 버전에서 Streamable HTTP로 통합·대체되었다. 새로 만든다면 Streamable HTTP를 쓰고, 오래된 튜토리얼의 두 엔드포인트(SSE+POST) 패턴은 레거시로 간주하라.

어떤 걸 고를까

로컬에서 나만 쓰는 도구?           → stdio
파일시스템/git/로컬 프로세스 접근?  → stdio
팀이 공유? 클라우드 배포?          → Streamable HTTP
사내 데이터를 여러 에이전트가 사용?  → Streamable HTTP (+ 인증)

좋은 소식: 같은 비즈니스 로직을 두 전송 모두에 노출할 수 있다. SDK에서 핸들러는 동일하게 짜고, 마지막에 어떤 transport로 run/connect하느냐만 바꾸면 된다. 로컬에서 stdio로 개발·테스트한 뒤 HTTP로 배포하는 흐름이 일반적이다.

첫 서버 만들기 (TypeScript, stdio)

공식 TypeScript SDK(@modelcontextprotocol/sdk)로 도구 하나를 가진 최소 서버를 만들어 본다.

mkdir weather-mcp && cd weather-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

package.json"type": "module"을 추가한다(ESM 필요). 그리고 src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// 1) 서버 인스턴스 — 이름/버전이 initialize에서 serverInfo로 노출된다
const server = new McpServer({
  name: "weather",
  version: "0.1.0",
});

// 2) 도구 등록: 이름, 설명+Zod 입력 스키마, 핸들러
server.registerTool(
  "get_forecast",
  {
    title: "Get weather forecast",
    description:
      "Get the weather forecast for a city. Call this when the user asks " +
      "about current or upcoming weather conditions for a specific place.",
    inputSchema: {
      city: z.string().describe("City name, e.g. 'Seoul' or 'Busan'"),
      days: z.number().int().min(1).max(7).default(1)
        .describe("Number of days to forecast (1-7)"),
    },
  },
  async ({ city, days }) => {
    // 실제로는 여기서 외부 API를 호출. 데모용 더미 응답.
    const forecast = `${city}: ${days}-day forecast — sunny, 18-24°C`;
    return {
      content: [{ type: "text", text: forecast }],
    };
  }
);

// 3) stdio transport로 연결하고 대기
const transport = new StdioServerTransport();
await server.connect(transport);
// 주의: 여기서 console.log 금지! stdout은 JSON-RPC 전용.
console.error("weather MCP server running on stdio");

빌드 설정(tsconfig.json)을 ESM("module": "NodeNext")으로 맞추고 컴파일한다:

npx tsc
node build/index.js   # 그냥 멈춰 있으면 정상 — stdin 입력을 기다리는 중

핵심 포인트:

  1. description이 곧 모델의 사용 설명서다. "언제 호출하는가"를 명시적으로 써라 — get_forecast라는 이름만으로는 모델이 호출 타이밍을 모른다. "Call this when the user asks about weather" 같은 트리거 조건을 description에 넣으면 호출 정확도가 크게 오른다.
  2. Zod 스키마는 자동으로 JSON Schema가 되어 inputSchema로 노출된다. .describe()로 각 파라미터를 설명하면 모델이 인자를 더 정확히 채운다.
  3. 반환은 content 배열. 텍스트 외에 { type: "image", data, mimeType }, { type: "resource", resource }도 가능하다.
  4. 핸들러가 throw하면 SDK가 이를 isError: true 결과로 변환해 모델에게 전달한다. 명시적으로 에러 콘텐츠를 반환하고 싶으면 { content: [...], isError: true }를 직접 리턴하라.

같은 서버를 Python으로 (FastMCP)

Python 공식 SDK(mcp 패키지)는 FastMCP라는 데코레이터 기반 고수준 API를 제공한다. 타입 힌트에서 입력 스키마를 자동 생성하므로 보일러플레이트가 거의 없다.

pip install "mcp[cli]"
# 또는 uv 사용 시: uv add "mcp[cli]"

weather.py:

from mcp.server.fastmcp import FastMCP

# 서버 인스턴스 (name이 serverInfo로 노출)
mcp = FastMCP("weather")

@mcp.tool()
def get_forecast(city: str, days: int = 1) -> str:
    """Get the weather forecast for a city.

    Call this when the user asks about current or upcoming
    weather conditions for a specific place.

    Args:
        city: City name, e.g. 'Seoul' or 'Busan'.
        days: Number of days to forecast (1-7).
    """
    # 실제로는 외부 API 호출. 데모용 더미.
    return f"{city}: {days}-day forecast — sunny, 18-24°C"

if __name__ == "__main__":
    # 기본 전송은 stdio
    mcp.run()

실행:

python weather.py

여기서 주목할 점:

  • docstring이 그대로 description이 된다. 타입 힌트(city: str, days: int = 1)에서 inputSchema가 자동 생성되고, docstring의 Args: 섹션이 파라미터 설명으로 들어간다. 그래서 docstring을 잘 쓰는 게 곧 도구 품질이다.
  • 비동기도 지원: async def로 선언하면 SDK가 알아서 처리한다. 외부 API 호출이 있으면 async def + httpx.AsyncClient를 쓰는 게 정석이다.
  • 로깅 주의(stdio): Python에서도 print()는 stdout으로 가므로 절대 쓰지 마라. 로그가 필요하면 import logging; logging.basicConfig()로 두면 기본이 stderr다. mcp[cli]를 깔았다면 mcp dev weather.py로 인스펙터를 띄워 디버그하는 게 더 낫다(다음 섹션 참고).

리소스와 프롬프트도 같은 패턴이다:

@mcp.resource("config://app-settings")
def get_settings() -> str:
    """현재 앱 설정을 반환 (읽기 전용 데이터)."""
    return '{"theme": "dark", "locale": "ko-KR"}'

@mcp.prompt()
def review_pr(diff: str) -> str:
    """PR diff를 리뷰하는 프롬프트 템플릿."""
    return f"다음 변경사항을 보안/성능 관점에서 리뷰하라:\n\n{diff}"

@mcp.resource의 인자는 URI다. 동적 리소스는 config://users/{user_id} 같은 URI 템플릿으로 만들 수 있고, 그러면 {user_id}가 함수 인자로 들어온다. 이 셋(tool/resource/prompt)이 한 서버에 공존할 수 있다.

MCP Inspector로 디버깅하기

서버를 호스트에 붙이기 전에 단독으로 테스트해야 한다. 호스트에 바로 붙이면 "안 되는데 어디가 문제인지" 알 수 없다 — 서버 버그인지, 설정 오류인지, 모델이 호출을 안 하는 건지 구분이 안 된다. MCP Inspector가 이걸 해결한다. 브라우저 UI로 서버에 직접 연결해 도구·리소스·프롬프트를 수동 호출해볼 수 있는 공식 도구다.

# 별도 설치 없이 npx로 실행 — 임의의 stdio 서버에 붙인다
npx @modelcontextprotocol/inspector node build/index.js

# Python 서버
npx @modelcontextprotocol/inspector python weather.py

Python SDK를 mcp[cli]로 깔았다면 더 간단하다:

mcp dev weather.py

Inspector가 로컬 웹 UI를 띄운다. 거기서 할 수 있는 것:

  • Connect 버튼으로 핸드셰이크가 성공하는지 확인 (실패하면 서버 시작 자체가 문제)
  • Tools 탭: 등록된 도구 목록과 inputSchema가 의도대로 노출되는지 확인. 인자를 폼에 채워 직접 실행하고 응답을 본다
  • Resources 탭: 리소스 목록과 내용 읽기
  • Notifications/로그: 서버가 stderr로 찍는 로그와 프로토콜 메시지를 실시간으로 본다

디버깅 워크플로:

  1. Inspector로 connect → 핸드셰이크 OK 확인
  2. tools/list 결과에 내 도구가 보이고 스키마가 맞는지 확인
  3. 도구를 손으로 호출 → 기대한 content가 나오는지 확인
  4. 에러 케이스(잘못된 인자, 외부 API 실패)를 일부러 일으켜 isError 처리 확인
  5. 여기까지 다 통과한 뒤에야 호스트(Claude Desktop/Code)에 연결

이 순서를 지키면 "호스트에서 도구가 안 보여요" 같은 모호한 문제의 절반이 사라진다 — 대부분 서버가 애초에 stdout 오염이나 스키마 오류로 죽어있던 것이다.

흔한 함정: Inspector에선 잘 되는데 Claude Desktop에선 안 된다 → 거의 항상 설정 파일의 경로/명령어 문제다(다음 섹션). Inspector는 당신이 친 명령을 그대로 실행하지만, Desktop은 자기 환경에서 실행하므로 node/python의 절대 경로, 작업 디렉터리, 환경변수가 다를 수 있다.

호스트에 연결하기 ① Claude Desktop · Claude Code

서버가 단독으로 검증됐으면 이제 호스트에 등록한다. 호스트마다 설정 방식이 다르다.

Claude Desktop (JSON 설정 파일)

Claude Desktop은 claude_desktop_config.json 파일로 stdio 서버를 등록한다. 위치:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": ["/absolute/path/to/weather-mcp/build/index.js"]
    },
    "weather-py": {
      "command": "python",
      "args": ["/absolute/path/to/weather.py"],
      "env": { "API_KEY": "sk-..." }
    }
  }
}

저장 후 Claude Desktop을 완전히 재시작한다(트레이/독에서 종료 후 재실행 — 창만 닫는 건 안 됨). 그러면 입력창 근처에 도구 아이콘이 나타나고, 모델이 필요할 때 도구를 호출한다.

⚠️ 반드시 절대 경로를 써라. Desktop은 당신의 셸 환경(PATH, nvm으로 깐 node 등)을 모른다. "command": "node"가 안 먹으면 which node로 절대 경로를 찾아 그걸 넣어라(예: /usr/local/bin/node). 마찬가지로 스크립트 경로도 절대 경로. 상대 경로·~ 확장은 작동 안 한다.

민감 정보: API 키 같은 건 env에 직접 박기보다, 서버가 OS 환경변수나 시크릿 매니저에서 읽게 설계하는 게 안전하다. 이 설정 파일은 평문이다.

Claude Code (CLI)

Claude Code는 명령줄로 서버를 추가한다 — 파일을 손으로 편집할 필요가 없다:

# stdio 로컬 서버 추가
claude mcp add weather -- node /absolute/path/to/build/index.js

# 환경변수 전달
claude mcp add weather-py --env API_KEY=sk-... -- python /abs/weather.py

# 원격 HTTP 서버 추가
claude mcp add --transport http company-db https://mcp.example.com/mcp

# 등록된 서버 확인 / 제거
claude mcp list
claude mcp remove weather

Claude Code에는 스코프 개념이 있다: --scope local(현재 프로젝트, 기본), --scope project(.mcp.json으로 팀과 공유, 레포에 커밋), --scope user(내 모든 프로젝트). 팀이 공유할 서버는 --scope project로 추가하면 레포에 .mcp.json이 생겨 동료들도 같은 서버를 자동으로 쓴다.

세션 안에서 /mcp 명령으로 연결 상태와 사용 가능한 도구를 확인할 수 있다. 연결이 빨간색이면 서버가 시작에 실패한 것 — Inspector로 돌아가 단독 테스트하라.

호스트에 연결하기 ② Claude API와 직접

커스텀 에이전트를 짜고 있다면, 호스트 앱 없이 Claude API에서 직접 MCP 서버에 연결할 수 있다. 두 가지 방식이 있고, 용도가 다르다.

A) 원격 MCP 서버 — mcp_servers 파라미터

Claude API는 mcp_servers 파라미터로 원격(HTTP) MCP 서버에 직접 연결한다. Claude의 오케스트레이션 레이어가 서버와 통신하고, 도구 실행을 알아서 처리한다. 클라이언트 측 루프를 짤 필요가 없다.

import anthropic

client = anthropic.Anthropic()

response = client.beta.messages.create(
    model="claude-opus-4-8",
    max_tokens=16000,
    mcp_servers=[
        {
            "type": "url",
            "name": "company-db",
            "url": "https://mcp.example.com/mcp",
        }
    ],
    betas=["mcp-client-2025-11-20"],  # 정확한 beta 헤더 값은 현행 문서로 확인
    messages=[{"role": "user", "content": "지난주 신규 가입자 수를 조회해줘"}],
)

원격 서버가 인증을 요구하면 OAuth 토큰 등을 함께 전달한다(서버 정의에 인증 필드). 이 경로는 HTTP 전송 서버에만 쓸 수 있다 — Claude의 인프라가 당신 로컬 stdio 프로세스를 띄울 수는 없기 때문이다.

B) 로컬 MCP 서버 — SDK의 MCP 변환 헬퍼

로컬 stdio 서버를 쓰거나 연결을 직접 제어하고 싶으면, Python SDK의 anthropic.lib.tools.mcp 헬퍼로 MCP 도구를 Claude의 tool runner에 꽂는다. pip install "anthropic[mcp]" 필요(Python 3.10+).

from anthropic import AsyncAnthropic
from anthropic.lib.tools.mcp import async_mcp_tool
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters

client = AsyncAnthropic()

async with stdio_client(
    StdioServerParameters(command="python", args=["weather.py"])
) as (read, write):
    async with ClientSession(read, write) as mcp_session:
        await mcp_session.initialize()

        tools_result = await mcp_session.list_tools()
        # MCP 도구를 Anthropic tool runner가 쓸 수 있는 형태로 변환
        runner = client.beta.messages.tool_runner(
            model="claude-opus-4-8",
            max_tokens=16000,
            messages=[{"role": "user", "content": "서울 날씨 알려줘"}],
            tools=[async_mcp_tool(t, mcp_session) for t in tools_result.tools],
        )
        async for message in runner:
            print(message)

선택 기준:

상황방식
원격 HTTP 서버, Claude가 루프를 돌려주길 원함mcp_servers 파라미터 (A)
로컬 stdio 서버SDK MCP 헬퍼 (B)
도구 호출 승인 게이트·로깅 등 세밀한 제어 필요SDK MCP 헬퍼 (B), 수동 루프
MCP 프롬프트·리소스도 함께 쓰고 싶음SDK 헬퍼 (mcp_message, mcp_resource_to_content)

참고로 Claude API와 별개로 서버사이드 에이전트가 필요하면 Anthropic의 Managed Agents에서도 MCP 서버를 선언하고(에이전트에 mcp_servers), 인증은 vault로 분리해 붙이는 패턴을 쓴다 — 단, 이건 Claude 에이전트 인프라 위에서 돌아가는 별도 제품이다.

리소스와 프롬프트: 도구를 넘어서

대부분의 튜토리얼이 Tool만 다루지만, Resource와 Prompt를 제대로 쓰면 서버의 표현력이 크게 올라간다.

Resources — 모델이 읽는 컨텍스트

Resource는 부수효과 없는 읽기 전용 데이터다. URI로 식별되고, 호스트가 사용자에게 "@첨부" 형태로 노출하거나 모델 컨텍스트에 주입한다.

# 정적 리소스
@mcp.resource("docs://api-guidelines")
def api_guidelines() -> str:
    """사내 API 작성 가이드라인."""
    return open("guidelines.md").read()

# 동적 리소스 — URI 템플릿의 {placeholder}가 함수 인자로
@mcp.resource("db://users/{user_id}/profile")
def user_profile(user_id: str) -> str:
    """특정 사용자의 프로필 JSON."""
    return fetch_user_json(user_id)

클라이언트는 resources/list로 목록을, resources/read로 내용을 가져온다. subscribe 능력을 광고한 서버는 리소스가 바뀔 때 notifications/resources/updated 알림을 보내 클라이언트가 다시 읽게 할 수 있다(예: 로그 파일 tail).

Tool vs Resource 결정 기준:

  • 데이터를 모델이 능동적으로 가져와야 한다 → Tool (query_users(filter))
  • 데이터를 사용자가 골라 첨부하거나 호스트가 컨텍스트로 미리 넣어줌 → Resource

현실적으로 호스트마다 Resource UX 지원 수준이 다르다. Claude Desktop은 Resource를 첨부 가능한 컨텍스트로 노출하지만, 일부 호스트는 Resource를 거의 안 쓴다. "모델이 알아서 가져와야 하는 동적 데이터"는 Tool로도 제공해 두는 게 호환성 면에서 안전하다. 가장 견고한 서버는 같은 데이터를 Resource(사용자 첨부용)와 Tool(모델 자율 조회용) 둘 다로 노출한다.

Prompts — 사용자가 선택하는 워크플로 템플릿

Prompt는 인자를 받아 완성된 메시지(들)를 만드는 재사용 템플릿이다. 호스트는 보통 이를 슬래시 명령이나 메뉴로 노출한다.

@mcp.prompt()
def debug_error(error_log: str, language: str = "python") -> str:
    """에러 로그를 분석하는 프롬프트."""
    return (
        f"다음 {language} 에러를 분석하고 원인과 수정안을 제시하라.\n\n"
        f"```\n{error_log}\n```"
    )

사용자가 Claude Desktop에서 /debug_error를 고르면 호스트가 인자를 받아 이 템플릿을 채우고, 그 결과가 대화에 삽입된다. "우리 팀이 자주 쓰는 정형 작업"을 버튼화하기에 좋다 — 코드 리뷰, 릴리스 노트 생성, 표준 보고서 등.

제어 축을 다시 기억하라: Tool = 모델 제어, Resource = 앱/사용자 제어, Prompt = 사용자 제어. 같은 "GitHub 이슈 가져오기"라도 모델이 자율 판단해 가져오게 하면 Tool, 사용자가 특정 이슈를 첨부하면 Resource, "이 이슈 분류해줘" 워크플로를 노출하면 Prompt다.

원격 배포: Streamable HTTP 서버 운영

팀/조직이 공유하는 서버는 Streamable HTTP로 배포한다. 로컬 stdio 핸들러를 그대로 두고 transport만 HTTP로 바꾼다.

Python (FastMCP): transport만 지정하면 된다.

if __name__ == "__main__":
    # stdio 대신 streamable-http로 실행
    mcp.run(transport="streamable-http")
    # 기본적으로 /mcp 엔드포인트를 띄운다

프로덕션에서는 ASGI 앱으로 노출해 uvicorn/gunicorn 뒤에 두는 게 일반적이다:

# FastMCP를 Starlette/FastAPI에 마운트해 ASGI 앱으로
app = mcp.streamable_http_app()
# uvicorn server:app --host 0.0.0.0 --port 8080

TypeScript: Express(또는 임의 HTTP 프레임워크) 핸들러 안에서 StreamableHTTPServerTransport를 연결한다. 세션 ID로 클라이언트별 연결을 관리하고, POST(요청)와 GET(SSE 스트림 수신)을 같은 엔드포인트에서 처리한다.

배포 시 반드시 챙길 것:

  1. 인증 — 원격 서버는 인터넷에 노출된다. 절대 무인증으로 두지 마라. 최소한 API 키, 제대로 하려면 OAuth(다음 섹션).
  2. 세션 관리 — Streamable HTTP는 세션 ID로 상태를 추적한다. 수평 확장(여러 인스턴스) 시 세션이 인스턴스 간에 일관되게 유지되도록 sticky 라우팅 또는 공유 세션 스토어를 둬라. 무상태 모드로 운영할 수도 있다(매 요청 독립 처리) — 더 단순하지만 SSE 기반 서버→클라이언트 알림은 제한된다.
  3. 타임아웃·스트리밍 — 긴 작업은 SSE로 진행 알림을 보내 연결이 유휴 타임아웃으로 끊기지 않게 하라.
  4. CORS / DNS 리바인딩 방어 — 브라우저 기반 클라이언트를 받는다면 CORS를 명시적으로 설정하고, Origin 헤더를 검증해 DNS rebinding 공격을 막아라. 로컬 바인딩 서버라도 127.0.0.1에만 바인딩하고 0.0.0.0을 피하라.
  5. 레이트 리밋·관찰성 — 표준 HTTP 미들웨어로 레이트 리밋, 요청 로깅, 메트릭을 붙인다. 이게 HTTP 전송을 쓰는 큰 이유다.

배포 대상은 일반 웹 서비스와 동일하다 — 컨테이너(Docker) + 클라우드 런타임(Cloud Run, Fly.io, ECS, Vercel/Cloudflare의 서버리스 등). Cloudflare Workers처럼 MCP 서버 호스팅을 1급으로 지원하는 플랫폼도 있어, 에지에서 OAuth와 함께 배포하는 경로가 점점 표준화되고 있다.

로컬→원격 이행 체크리스트: ① 핸들러 로직은 그대로 ② transport만 HTTP로 ③ 인증 추가 ④ stdout 로그 문제는 HTTP에선 사라지지만 대신 구조화 로깅을 stderr/로그수집기로 ⑤ Inspector를 --transport http로 다시 돌려 원격 엔드포인트를 검증.

인증과 보안: OAuth와 신뢰 경계

stdio 로컬 서버는 인증이 필요 없다(이미 당신 머신, 당신 권한). 하지만 원격 HTTP 서버는 인증이 필수다. MCP 명세는 HTTP 전송 인증으로 OAuth 2.1 기반 흐름을 채택했다.

MCP의 OAuth 모델 (개요)

MCP 서버는 OAuth 보호 리소스(resource server) 역할을 한다. 핵심 흐름:

  1. 클라이언트가 인증 없이 서버에 접근 → 서버가 401과 함께 인증 서버 메타데이터 위치를 알려줌(Protected Resource Metadata)
  2. 클라이언트가 인증 서버(Authorization Server)에서 OAuth 흐름을 수행 — 사용자 동의를 받아 액세스 토큰 발급
  3. 클라이언트가 이후 요청에 Authorization: Bearer <token>을 실어 보냄
  4. 서버는 토큰을 검증하고 권한 범위(scope)에 맞는 작업만 허용

최신 명세는 인증 서버와 리소스 서버의 분리(RFC 9728 Protected Resource Metadata 등)를 권장한다 — MCP 서버가 직접 토큰을 발급하기보다, 기존 IdP(Auth0, Okta, Google 등)에 위임하는 구조다. 토큰 발급 자체를 직접 구현하지 말고, 검증된 인증 서버를 앞단에 두는 게 안전하다.

실무에서는 OAuth를 손으로 다 구현하기보다 호스팅 플랫폼이나 라이브러리가 제공하는 MCP-OAuth 통합(예: Cloudflare의 MCP OAuth provider, WorkOS, 각 SDK의 auth 헬퍼)을 쓰는 게 현실적이다. 토큰 검증·갱신·동의 화면을 직접 짜는 건 보안 사고의 지름길이다.

보안 위협 — MCP 특유의 함정들

MCP는 모델에게 도구 실행 능력을 주는 만큼, 일반 API보다 공격면이 넓다. 반드시 의식할 것:

  • 프롬프트 인젝션 via 도구 설명/리소스 내용: 악의적 MCP 서버는 도구 description이나 리소스 내용에 "기존 지시를 무시하고 X를 해라" 같은 지시를 숨길 수 있다. 신뢰할 수 없는 서버를 호스트에 붙이는 것 자체가 위험하다. 출처를 검증한 서버만 설치하라.
  • 도구 이름 충돌(tool shadowing): 여러 서버가 같은 이름의 도구를 노출하면 모델이 의도치 않은 서버의 도구를 부를 수 있다. 서버별 도구 네임스페이싱을 의식하라.
  • 혼동된 대리자(confused deputy): 서버가 사용자 권한으로 강력한 작업(파일 삭제, 결제, 외부 메시지 발송)을 하는데, 모델이 인젝션에 속아 그걸 호출하면 사용자 권한이 악용된다. 되돌리기 어려운 작업은 호스트의 사용자 승인 게이트 뒤에 둬라 — MCP 도구 정의에 "이건 승인 필요"를 표시하고, 호스트가 실행 전 사용자 확인을 받게.
  • 시크릿 유출: 도구 인자·리소스·프롬프트는 모델 컨텍스트와 세션 히스토리에 남는다. API 키·비밀번호를 절대 인자나 리소스 내용으로 흘리지 마라. 서버 내부에서만 시크릿을 보유하고, 모델에게는 결과만 반환하라.
  • 토큰 패스스루 금지: MCP 서버가 받은 토큰을 검증 없이 하위 서비스로 그대로 흘려보내는 패턴은 명세가 명시적으로 금지한다. 각 신뢰 경계에서 토큰을 검증하라.

원칙: stdio 로컬 서버는 "내 권한으로 도는 내 코드"라 단순하지만, 신뢰할 수 없는 서드파티 서버는 신중히. HTTP 원격 서버는 "공개된 권한 있는 엔드포인트"이므로 일반 웹 보안(인증·인가·입력 검증·레이트 리밋·최소 권한)을 빠짐없이 적용하라.

프로덕션 베스트 프랙티스와 흔한 실수 모음

마지막으로, 실제 운영에서 반복적으로 마주치는 실수와 모범 사례를 한데 모은다.

도구 설계

  • 도구는 적게, 명확하게. 도구가 30개 넘어가면 모델이 선택을 헷갈리고 컨텍스트도 잡아먹는다. 정말 필요한 것만 노출하라. 도구가 많아야 하면 동적 도구 검색(tool search) 패턴을 고려.
  • description에 "언제 호출하는가"를 써라. "무엇을 한다"만으론 부족하다. 트리거 조건을 명시하면 호출 정확도가 오른다. 예: "Call this when the user asks about current prices or recent events."
  • 인자는 타입과 enum으로 좁혀라. 자유 문자열보다 enum이 모델 실수를 줄인다. Zod/타입힌트 + .describe()를 적극 활용.
  • 출력은 모델이 쓰기 좋은 형태로. 거대한 원본 JSON을 그대로 뱉지 말고, 모델이 다음 행동을 결정하는 데 필요한 만큼만 정제해 반환하라. 토큰을 아끼고 정확도를 높인다.

에러 처리

  • 도구 실패는 isError: true로, 프로토콜 위반만 JSON-RPC error로. (3번 섹션 참고) 외부 API가 죽으면 { content: [{type:"text", text:"API timeout, retry later"}], isError: true }를 반환해 모델이 재시도/대안을 찾게 하라.
  • 에러 메시지를 모델이 이해할 수 있게. "Error: ECONNREFUSED"보다 "날씨 API에 연결 실패. 도시 이름이 맞는지 확인하거나 잠시 후 재시도하세요"가 낫다.

stdio 특유의 함정 (재강조)

  • stdout 오염 = 즉사. console.log/print/예상 못 한 라이브러리 출력이 stdout으로 새면 JSON 스트림이 깨진다. 모든 진단 출력은 stderr로. 의존 라이브러리가 stdout에 뭔가 찍는지도 의심하라(일부 SDK가 배너를 찍는다).
  • 절대 경로. 호스트 설정의 command/args는 절대 경로. node/python이 호스트 환경에서 안 잡히면 which로 찾은 풀 경로를.
  • 재시작 잊지 말기. Claude Desktop은 설정 변경 후 완전 재시작해야 반영된다.

HTTP 운영

  • 무인증 노출 금지. 원격 서버에 최소 API 키, 가급적 OAuth.
  • 버전 핀. SDK와 명세 버전을 package.json/requirements에 고정하고, 업그레이드는 의도적으로. MCP는 빠르게 진화한다.
  • 호환성 테스트. 명세 버전을 올릴 땐 Inspector로 회귀 테스트하고, 연결할 호스트들(Claude Desktop/Code/API)에서 실제로 동작하는지 확인. 호스트마다 지원하는 명세 버전·기능(Resource UX, Prompt UX, sampling)이 다르다.

개발 워크플로

  1. stdio + Inspector로 단독 개발 → 도구/리소스/프롬프트를 손으로 검증
  2. 호스트(Claude Code/Desktop)에 붙여 모델이 실제로 호출하는지 확인 (description 튜닝)
  3. 공유가 필요하면 HTTP로 전환 + 인증 추가
  4. 컨테이너화 → 클라우드 배포 → 관찰성·레이트리밋

한 줄 요약 체크리스트

[ ] stdout에 JSON-RPC 외 아무것도 안 찍히는가 (로그는 stderr)
[ ] description에 '언제 호출하는지' 명시했는가
[ ] 도구 실패를 isError로 모델에 전달하는가
[ ] 호스트 설정에 절대 경로를 썼는가
[ ] Inspector로 단독 검증을 먼저 했는가
[ ] 원격 서버라면 인증이 걸려 있는가
[ ] 시크릿이 인자/리소스/프롬프트로 새지 않는가
[ ] 되돌리기 어려운 작업이 승인 게이트 뒤에 있는가
[ ] SDK/명세 버전을 핀했는가

MCP의 가치는 "한 번 만들면 어디서나"다. 이 체크리스트를 지키면, 당신이 만든 서버는 Claude Desktop에서도, Claude Code에서도, 커스텀 에이전트에서도, 그리고 앞으로 나올 MCP 호환 호스트에서도 그대로 작동한다 — 통합을 N번 다시 짤 필요 없이.