왜 2026년에 MCP 서버를 직접 만들어야 할까?
멀티에이전트 오케스트레이션이 프로덕션의 새로운 기준이 된 2026년, AI 에이전트의 진짜 능력은 "얼마나 똑똑한가"가 아니라 "무엇에 접근할 수 있는가"로 결정됩니다. 솔직히, Claude에게 아무리 정교한 프롬프트를 작성해도 회사 내부 데이터베이스를 조회하거나 사내 API를 호출할 수 없다면 실질적인 가치는 제한적이죠.
바로 이 문제를 해결하는 것이 MCP(Model Context Protocol)입니다.
Anthropic이 주도하고 OpenAI, Google, Microsoft가 모두 채택한 MCP는 LLM과 외부 도구 사이의 USB 같은 표준 인터페이스라고 보면 됩니다. 2026년 3월 현재 593개 이상의 MCP 서버가 생태계에 등록되어 있고, FastMCP로 구축된 서버가 전체의 약 70%를 차지할 정도로 Python 개발자에게 특히 친숙한 환경이 갖춰져 있습니다.
이 글은 멀티에이전트 오케스트레이션 가이드의 후속편으로, 오케스트레이션 레이어의 핵심 빌딩 블록인 MCP 서버를 직접 구축하는 방법을 처음부터 프로덕션 배포까지 다룹니다. FastMCP 3.0의 최신 기능을 활용한 실전 코드 예제를 중심으로, 데이터베이스 연동, 외부 API 통합, 인증, 그리고 Streamable HTTP 트랜스포트까지 하나씩 살펴볼게요.
MCP 핵심 개념: 도구, 리소스, 프롬프트
MCP 서버를 만들기 전에, 서버가 클라이언트(Claude Desktop, Claude Code, Cursor 등)에게 제공하는 세 가지 핵심 기능부터 짚고 넘어갑시다.
도구(Tools) — LLM이 호출하는 함수
도구는 REST API의 POST 엔드포인트와 비슷합니다. LLM이 사용자의 질문을 처리하다가 "이건 직접 할 수 없으니 외부 함수를 호출해야겠다"고 판단하면 도구를 호출하죠. 데이터베이스 쿼리, API 호출, 파일 쓰기 등 부수 효과(side effect)가 있는 작업에 사용됩니다.
리소스(Resources) — LLM에 컨텍스트를 제공하는 데이터
리소스는 REST API의 GET 엔드포인트와 비슷합니다. 클라이언트가 LLM의 컨텍스트에 로드할 데이터를 읽어오는 용도인데, 설정 파일, 문서, 데이터베이스 스키마 등 읽기 전용 정보를 제공할 때 사용합니다.
프롬프트(Prompts) — 재사용 가능한 상호작용 템플릿
프롬프트는 특정 작업을 수행하기 위한 미리 정의된 지시사항입니다. 단순한 텍스트 템플릿이 아니라, 도구 호출 순서와 전략까지 포함할 수 있는 "작업 계획서"라고 생각하면 이해하기 쉽습니다.
| 구분 | 도구 (Tool) | 리소스 (Resource) | 프롬프트 (Prompt) |
|---|---|---|---|
| 유사 개념 | POST 엔드포인트 | GET 엔드포인트 | API 문서/가이드 |
| 호출 주체 | LLM (자동) | 클라이언트 앱 | 사용자 (수동 선택) |
| 부수 효과 | 있음 (DB 쓰기, API 호출 등) | 없음 (읽기 전용) | 없음 |
| 사용 예시 | 주문 조회, 이메일 발송 | 설정값, 스키마 정보 | 코드 리뷰 템플릿 |
FastMCP 환경 설정
FastMCP 3.0은 2026년 1월에 정식 출시되었고, 하루 100만 건 이상 다운로드되는 사실상의 표준 Python MCP 프레임워크입니다. 그럼 설치부터 해봅시다.
설치
# uv 사용 (권장)
uv add fastmcp
# pip 사용
pip install fastmcp
# CLI 도구 포함 설치 (MCP Inspector 디버깅 지원)
uv add "fastmcp[cli]"
FastMCP 3.0부터는 Python 3.10 이상이 필요합니다. uv를 패키지 매니저로 사용하는 걸 공식적으로 권장하고 있으니, 아직 uv를 안 쓰고 있다면 이번 기회에 한번 써보는 것도 나쁘지 않습니다.
프로젝트 구조
my-mcp-server/
├── pyproject.toml
├── src/
│ └── my_server/
│ ├── __init__.py
│ ├── server.py # MCP 서버 정의
│ ├── tools.py # 도구 함수들
│ ├── resources.py # 리소스 정의
│ └── prompts.py # 프롬프트 템플릿
└── tests/
└── test_tools.py
첫 번째 MCP 서버 만들기
자, 가장 간단한 MCP 서버부터 만들어 봅시다. FastMCP의 설계 철학은 "Python 함수를 쓰면 MCP 서버가 된다"인데요, 정말 그런지 직접 확인해 보겠습니다.
# server.py
from fastmcp import FastMCP
# MCP 서버 인스턴스 생성
mcp = FastMCP("내 첫 MCP 서버")
# === 도구 정의 ===
@mcp.tool
def calculate_bmi(weight_kg: float, height_cm: float) -> dict:
"""체질량지수(BMI)를 계산합니다.
Args:
weight_kg: 체중 (kg)
height_cm: 키 (cm)
"""
height_m = height_cm / 100
bmi = weight_kg / (height_m ** 2)
if bmi < 18.5:
category = "저체중"
elif bmi < 25:
category = "정상"
elif bmi < 30:
category = "과체중"
else:
category = "비만"
return {
"bmi": round(bmi, 1),
"category": category,
"message": f"BMI {round(bmi, 1)} — {category}"
}
# === 리소스 정의 ===
@mcp.resource("config://bmi-standards")
def get_bmi_standards() -> str:
"""WHO BMI 기준표를 반환합니다."""
return """
WHO BMI 기준:
- 저체중: 18.5 미만
- 정상: 18.5 ~ 24.9
- 과체중: 25.0 ~ 29.9
- 비만: 30.0 이상
"""
# === 프롬프트 정의 ===
@mcp.prompt
def health_check(weight: float, height: float) -> str:
"""건강 상태 종합 분석 프롬프트"""
return f"""사용자의 건강 상태를 분석해 주세요.
체중: {weight}kg, 키: {height}cm
1. calculate_bmi 도구로 BMI를 계산하세요.
2. 결과를 바탕으로 건강 조언을 제공하세요.
3. 한국인 평균과 비교해서 설명하세요."""
if __name__ == "__main__":
mcp.run(transport="stdio")
보시다시피, 일반 Python 함수에 데코레이터 하나 붙이면 끝입니다. FastMCP가 함수의 타입 힌트와 독스트링을 읽어서 JSON Schema를 자동 생성해주기 때문에, 별도의 스키마 정의가 필요 없어요.
개인적으로 가장 마음에 드는 점은, FastMCP 3.0에서 데코레이터를 붙여도 원래 함수가 그대로 유지된다는 겁니다. 덕분에 유닛 테스트도 일반 함수 테스트하듯 작성할 수 있죠.
MCP Inspector로 디버깅
# MCP Inspector 실행 — 브라우저에서 도구/리소스/프롬프트를 테스트할 수 있습니다
fastmcp dev server.py
fastmcp dev 명령을 실행하면 브라우저에서 MCP Inspector가 열리고, 서버의 모든 도구, 리소스, 프롬프트를 직접 테스트해 볼 수 있습니다. 프로덕션 배포 전에 반드시 Inspector로 한 번 돌려보는 습관을 들이세요. 나중에 고생을 줄여줍니다.
실전 예제: 데이터베이스 연동 MCP 서버
이제 진짜로 쓸 수 있는 걸 만들어 봅시다. 회사의 고객 데이터베이스를 Claude가 직접 조회할 수 있게 해주는 서버입니다.
이 서버가 있으면 "지난달 매출 상위 10개 고객을 알려줘" 같은 자연어 질문에 AI가 데이터를 직접 찾아서 대답할 수 있게 됩니다. 꽤 멋지지 않나요?
# db_server.py
import sqlite3
from contextlib import asynccontextmanager
from fastmcp import FastMCP, Context
# Lifespan으로 DB 연결 관리
@asynccontextmanager
async def app_lifespan(server: FastMCP):
"""서버 시작 시 DB 연결, 종료 시 정리"""
db = sqlite3.connect("customers.db")
db.row_factory = sqlite3.Row
try:
yield {"db": db}
finally:
db.close()
mcp = FastMCP(
"고객 관리 서버",
lifespan=app_lifespan
)
@mcp.tool
def search_customers(
ctx: Context,
keyword: str,
limit: int = 10
) -> list[dict]:
"""고객을 이름 또는 이메일로 검색합니다.
Args:
keyword: 검색 키워드 (이름 또는 이메일)
limit: 최대 결과 수 (기본값: 10)
"""
db = ctx.request_context.lifespan_context["db"]
cursor = db.execute(
"""SELECT id, name, email, phone, created_at
FROM customers
WHERE name LIKE ? OR email LIKE ?
ORDER BY created_at DESC
LIMIT ?""",
(f"%{keyword}%", f"%{keyword}%", limit)
)
return [dict(row) for row in cursor.fetchall()]
@mcp.tool
def get_customer_orders(
ctx: Context,
customer_id: int,
status: str | None = None
) -> list[dict]:
"""특정 고객의 주문 내역을 조회합니다.
Args:
customer_id: 고객 ID
status: 주문 상태 필터 (pending, completed, cancelled). 없으면 전체 조회.
"""
db = ctx.request_context.lifespan_context["db"]
query = """SELECT o.id, o.total_amount, o.status, o.created_at,
GROUP_CONCAT(p.name) as products
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.customer_id = ?"""
params: list = [customer_id]
if status:
query += " AND o.status = ?"
params.append(status)
query += " GROUP BY o.id ORDER BY o.created_at DESC"
cursor = db.execute(query, params)
return [dict(row) for row in cursor.fetchall()]
@mcp.tool
def get_monthly_revenue(ctx: Context, year: int, month: int) -> dict:
"""특정 월의 매출 통계를 조회합니다.
Args:
year: 연도 (예: 2026)
month: 월 (1~12)
"""
db = ctx.request_context.lifespan_context["db"]
cursor = db.execute(
"""SELECT COUNT(*) as order_count,
SUM(total_amount) as total_revenue,
AVG(total_amount) as avg_order_value
FROM orders
WHERE strftime('%Y', created_at) = ?
AND strftime('%m', created_at) = ?
AND status = 'completed'""",
(str(year), f"{month:02d}")
)
row = cursor.fetchone()
return {
"year": year,
"month": month,
"order_count": row["order_count"],
"total_revenue": row["total_revenue"],
"avg_order_value": round(row["avg_order_value"], 2) if row["avg_order_value"] else 0
}
# 리소스: DB 스키마 정보 제공
@mcp.resource("schema://customers-db")
def get_db_schema(ctx: Context) -> str:
"""고객 데이터베이스의 테이블 스키마를 반환합니다."""
db = ctx.request_context.lifespan_context["db"]
cursor = db.execute(
"SELECT sql FROM sqlite_master WHERE type='table'"
)
schemas = [row["sql"] for row in cursor.fetchall()]
return "\n\n".join(schemas)
if __name__ == "__main__":
mcp.run(transport="stdio")
여기서 주목할 점은 lifespan 패턴입니다. 데이터베이스 연결을 서버 시작 시 한 번만 생성하고, 종료 시 자동으로 정리해주는 구조죠. Context 객체를 통해 각 도구 함수에서 DB 연결에 접근하는 방식이 FastMCP의 표준 패턴입니다.
get_db_schema 리소스도 눈여겨볼 만합니다. Claude가 이 스키마를 먼저 읽으면, 어떤 테이블에 어떤 컬럼이 있는지 파악한 상태에서 도구를 훨씬 더 정확하게 호출할 수 있거든요. 사소해 보이지만 결과 품질에 꽤 큰 차이를 만듭니다.
실전 예제: 외부 API 통합 MCP 서버
다음은 외부 REST API와 통합하는 예제입니다. 날씨 API와 환율 API를 연동해서, Claude가 실시간 데이터를 조회할 수 있게 만들어 봅시다.
# api_server.py
import httpx
from fastmcp import FastMCP, Context
from contextlib import asynccontextmanager
@asynccontextmanager
async def app_lifespan(server: FastMCP):
"""HTTP 클라이언트를 서버 수명 동안 유지"""
async with httpx.AsyncClient(timeout=30.0) as client:
yield {"http_client": client}
mcp = FastMCP("외부 API 서버", lifespan=app_lifespan)
@mcp.tool
async def get_weather(ctx: Context, city: str) -> dict:
"""지정한 도시의 현재 날씨를 조회합니다.
Args:
city: 도시 이름 (예: Seoul, Tokyo, New York)
"""
client = ctx.request_context.lifespan_context["http_client"]
# 1단계: 도시 좌표 검색
geocode_url = "https://geocoding-api.open-meteo.com/v1/search"
geo_resp = await client.get(geocode_url, params={"name": city, "count": 1})
geo_data = geo_resp.json()
if not geo_data.get("results"):
return {"error": f"도시 '{city}'를 찾을 수 없습니다."}
location = geo_data["results"][0]
lat, lon = location["latitude"], location["longitude"]
# 2단계: 날씨 데이터 조회
weather_url = "https://api.open-meteo.com/v1/forecast"
weather_resp = await client.get(weather_url, params={
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code",
"timezone": "auto"
})
weather = weather_resp.json()["current"]
return {
"city": location["name"],
"country": location.get("country", ""),
"temperature_celsius": weather["temperature_2m"],
"humidity_percent": weather["relative_humidity_2m"],
"wind_speed_kmh": weather["wind_speed_10m"],
"weather_code": weather["weather_code"]
}
@mcp.tool
async def convert_currency(
ctx: Context,
amount: float,
from_currency: str,
to_currency: str
) -> dict:
"""환율을 변환합니다.
Args:
amount: 변환할 금액
from_currency: 원본 통화 코드 (예: USD, KRW, EUR)
to_currency: 대상 통화 코드 (예: USD, KRW, EUR)
"""
client = ctx.request_context.lifespan_context["http_client"]
from_code = from_currency.upper()
to_code = to_currency.upper()
url = f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{from_code.lower()}.json"
resp = await client.get(url)
data = resp.json()
rate = data[from_code.lower()].get(to_code.lower())
if rate is None:
return {"error": f"환율 정보를 찾을 수 없습니다: {from_code} -> {to_code}"}
converted = round(amount * rate, 2)
return {
"from": f"{amount} {from_code}",
"to": f"{converted} {to_code}",
"rate": rate,
"message": f"{amount} {from_code} = {converted} {to_code}"
}
if __name__ == "__main__":
mcp.run(transport="stdio")
외부 API를 호출하는 도구는 반드시 async로 정의하는 게 좋습니다. FastMCP 3.0이 동기 함수도 자동으로 스레드풀에서 돌려주긴 하지만, 네트워크 I/O가 많은 경우엔 async가 확실히 효율적이에요.
httpx.AsyncClient를 lifespan에서 한 번 만들어두고 재사용하면 연결 풀링(connection pooling) 효과를 얻을 수 있습니다. 매 요청마다 새 연결을 만드는 것보다 체감할 수 있을 정도로 빠르니, 이 패턴은 꼭 기억해 두세요.
Claude Desktop 및 Claude Code 연동
MCP 서버를 만들었으면, 이제 Claude에서 실제로 쓸 수 있게 연결해야겠죠. 두 가지 방법이 있습니다.
방법 1: Claude Desktop 연동
Claude Desktop의 설정 파일을 편집합니다.
# macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
# Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"customer-db": {
"command": "uv",
"args": ["run", "--directory", "/path/to/my-mcp-server", "python", "db_server.py"],
"env": {
"DATABASE_URL": "sqlite:///path/to/customers.db"
}
},
"external-apis": {
"command": "uv",
"args": ["run", "--directory", "/path/to/my-mcp-server", "python", "api_server.py"]
}
}
}
설정 파일을 저장한 후 Claude Desktop을 재시작하면, 채팅 입력란 하단에 도구 아이콘이 나타납니다. 이제 "김철수 고객의 주문 내역 알려줘"라고 자연어로 물어보면, Claude가 search_customers와 get_customer_orders 도구를 자동으로 호출해서 답변을 만들어 줍니다.
처음 연결되는 순간이 꽤 감동적입니다. (진지하게요.)
방법 2: Claude Code 연동
# Claude Code에서 MCP 서버 추가
claude mcp add customer-db -- uv run --directory /path/to/my-mcp-server python db_server.py
# 또는 FastMCP CLI로 한 번에 설치
fastmcp install db_server.py --name "고객DB" --with httpx
fastmcp install 명령은 Claude Desktop, Cursor, Goose 등의 설정 파일을 자동으로 업데이트해 줍니다. 매번 JSON을 수동으로 편집할 필요가 없어서 정말 편해요.
Streamable HTTP 트랜스포트: 원격 서버 배포
지금까지는 stdio 트랜스포트(로컬 프로세스 통신)를 사용했습니다. 개인 로컬 환경에서는 이게 가장 간단하지만, 팀 전체가 같은 MCP 서버를 공유하거나 클라우드에 배포하려면 이야기가 달라집니다. 이때 필요한 게 Streamable HTTP 트랜스포트입니다.
Streamable HTTP는 2025년 3월 MCP 스펙 업데이트에서 도입된 방식으로, 기존의 SSE(Server-Sent Events)를 대체하는 공식 원격 통신 규격입니다. 표준 HTTPS를 쓰기 때문에 기업 방화벽도 문제없이 통과하고, 세션 관리와 연결 재개 기능까지 지원합니다.
# remote_server.py
from fastmcp import FastMCP
mcp = FastMCP(
"원격 고객 서버",
host="0.0.0.0",
port=8000,
)
@mcp.tool
def ping() -> str:
"""서버 상태를 확인합니다."""
return "pong! 서버가 정상 동작 중입니다."
# ... 이전 예제의 도구들 추가 ...
if __name__ == "__main__":
# Streamable HTTP로 서버 실행
mcp.run(transport="streamable-http")
# 서버 실행
python remote_server.py
# -> INFO: MCP server listening on http://0.0.0.0:8000/mcp
# Docker로 배포
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install fastmcp httpx
COPY src/ ./src/
EXPOSE 8000
CMD ["python", "src/remote_server.py"]
클라이언트 쪽에서는 URL만 지정하면 바로 연결됩니다:
# Claude Desktop 설정 (원격 서버)
{
"mcpServers": {
"remote-customer-db": {
"url": "https://mcp.your-company.com/mcp",
"headers": {
"Authorization": "Bearer your-api-key"
}
}
}
}
FastMCP 3.0 프로덕션 기능 활용
FastMCP 3.0은 실험적인 도구가 아닙니다. 프로덕션 배포를 염두에 두고 설계된 프레임워크예요. 핵심 기능 세 가지를 살펴보겠습니다.
인증 및 권한 관리
프로덕션 MCP 서버는 반드시 인증이 필요합니다. 당연한 말이지만, 실제로 인증 없이 배포하는 경우를 의외로 많이 봤습니다. FastMCP 3.0은 개별 도구 수준의 세밀한 권한 제어를 지원합니다.
from fastmcp import FastMCP
from fastmcp.server.auth import BearerAuthProvider
# API 키 기반 인증
auth = BearerAuthProvider(
tokens={"admin-key-123": "admin", "reader-key-456": "reader"}
)
mcp = FastMCP("보안 서버", auth=auth)
@mcp.tool(require_auth=True)
def delete_customer(ctx: Context, customer_id: int) -> dict:
"""고객 레코드를 삭제합니다. 관리자 권한이 필요합니다."""
user_role = ctx.request_context.auth_context.role
if user_role != "admin":
return {"error": "관리자 권한이 필요합니다."}
# ... 삭제 로직 ...
return {"status": "deleted", "customer_id": customer_id}
OAuth 2.1 인증 플로우도 지원하며, JWT 유효성 검사, Azure AD 통합 등 기업 환경에서 필요한 인증 방식을 유연하게 적용할 수 있습니다.
OpenTelemetry 계측
프로덕션에서 MCP 서버의 성능을 모니터링하려면 관측 가능성(observability)이 필수입니다. FastMCP 3.0은 OpenTelemetry를 네이티브로 지원해요.
from fastmcp import FastMCP
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# OpenTelemetry 설정
provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://jaeger:4317"))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
mcp = FastMCP("관측 가능한 서버")
@mcp.tool
def slow_query(ctx: Context, query: str) -> dict:
"""복잡한 쿼리를 실행합니다."""
# 이 도구 호출은 자동으로 트레이싱됩니다:
# - 도구 이름, 실행 시간, 세션 ID, 인증 컨텍스트
# - Jaeger/Grafana Tempo에서 시각화 가능
pass
OpenTelemetry 설정만 추가하면, 모든 도구 호출, 리소스 읽기, 프롬프트 렌더링이 자동으로 트레이싱됩니다. Jaeger나 Grafana Tempo에서 각 요청의 실행 경로와 지연 시간을 한눈에 볼 수 있어서, 병목 지점을 빠르게 찾아낼 수 있죠.
백그라운드 태스크
일반적인 MCP 도구 호출은 몇 초 안에 완료되어야 합니다. 그런데 대용량 데이터 처리나 보고서 생성처럼 수 분이 걸리는 작업은 어떻게 할까요?
FastMCP 3.0은 SEP-1686 스펙 확장을 기반으로 백그라운드 태스크를 지원합니다.
@mcp.tool
async def generate_report(ctx: Context, month: int, year: int) -> dict:
"""월간 매출 보고서를 생성합니다. 수 분이 걸릴 수 있습니다."""
# 태스크를 백그라운드 큐에 등록
task_id = await ctx.submit_background_task(
_build_report, month=month, year=year
)
return {
"status": "processing",
"task_id": task_id,
"message": f"{year}년 {month}월 보고서 생성을 시작했습니다. 잠시 후 결과를 확인하세요."
}
백그라운드 태스크는 SQLite 또는 PostgreSQL 기반의 영속적 작업 큐에서 관리됩니다. 서버가 재시작되더라도 작업이 유실되지 않으니 안심하고 사용할 수 있어요.
보안 베스트 프랙티스
MCP 서버는 LLM에게 실제 시스템 접근 권한을 부여하는 것이므로, 보안에 각별히 주의해야 합니다. 실제로 2026년 1월 Anthropic의 공식 Git MCP 서버에서 심각한 취약점이 발견된 적도 있었죠.
- 최소 권한 원칙: MCP 서버에 필요한 최소한의 접근 권한만 부여하세요. 데이터베이스 연동 서버라면 읽기 전용 계정을 사용하고, 쓰기가 꼭 필요한 도구만 별도 인증을 요구하는 게 좋습니다.
- 환경 변수 사용: API 키, DB 비밀번호 같은 시크릿을 절대 코드에 하드코딩하지 마세요.
env설정이나 시크릿 매니저를 통해 주입해야 합니다. - 입력 검증: LLM이 생성한 파라미터를 SQL 쿼리에 그대로 넣으면 안 됩니다. 위 예제처럼
?플레이스홀더를 사용한 파라미터화된 쿼리를 반드시 사용하세요. - 파일 시스템 범위 제한: 파일 관련 도구를 만들 때는 접근 가능한 디렉토리를 명시적으로 제한하세요.
- Origin 헤더 검증: HTTP 트랜스포트 사용 시 DNS 리바인딩 공격을 방지하기 위해 Origin 헤더를 반드시 검증해야 합니다.
실전 팁과 트러블슈팅
MCP 서버를 처음 만들다 보면 이런저런 문제에 부딪히게 됩니다. 자주 보이는 실수들을 정리해 봤습니다.
- STDIO 서버에서 stdout을 사용하지 마세요:
print()문은 JSON-RPC 메시지를 오염시켜 서버가 즉시 중단됩니다. 이거 처음에 진짜 원인 찾기 어렵습니다. 디버깅할 때는 반드시logging모듈을stderr로 출력하도록 설정하세요. - 도구 설명은 상세하게 작성하세요: LLM은 독스트링을 읽고 도구 사용 여부를 판단합니다. "데이터를 가져옵니다"보다는 "고객 ID를 기반으로 최근 30일간의 주문 내역을 시간순으로 조회합니다"가 훨씬 효과적이에요.
- 도구 수를 적절히 유지하세요: 하나의 서버에 도구가 20개 이상이면 LLM의 도구 선택 정확도가 눈에 띄게 떨어집니다. 도메인별로 서버를 분리하는 게 낫습니다.
- 에러를 문자열로 반환하세요: 도구에서 예외를 그냥 던지지 말고, 에러 메시지를 딕셔너리로 반환하면 LLM이 에러를 이해하고 사용자에게 알아듣기 쉬운 설명을 해줄 수 있습니다.
자주 묻는 질문 (FAQ)
MCP와 Function Calling(도구 사용)의 차이점은 무엇인가요?
Function Calling은 특정 LLM API(Claude, GPT 등)에 종속된 도구 호출 방식입니다. 반면 MCP는 어떤 LLM이든 동일한 도구에 접근할 수 있게 해주는 표준 프로토콜이에요. MCP 서버를 한 번 만들어두면 Claude, ChatGPT, Gemini, Cursor 등 MCP를 지원하는 모든 클라이언트에서 재사용할 수 있다는 것이 가장 큰 차이점입니다.
MCP 서버를 만들려면 어떤 언어를 써야 하나요?
Python과 TypeScript가 가장 잘 지원됩니다. Python은 FastMCP, TypeScript는 공식 @modelcontextprotocol/sdk를 사용하면 됩니다. Go, Rust, Java 등의 커뮤니티 SDK도 있긴 한데, 솔직히 성숙도는 아직 Python/TypeScript에 비하면 좀 부족합니다. 데이터 분석이나 ML 워크로드와 통합할 계획이라면 Python이 자연스러운 선택이겠죠.
MCP 서버가 Claude Desktop에서 인식되지 않을 때는?
가장 흔한 원인 세 가지를 꼽자면요. 첫째, claude_desktop_config.json의 JSON 문법 오류입니다. 쉼표 하나 빠져도 안 됩니다. 둘째, command에 지정한 실행 파일(uv, python 등)의 전체 경로가 필요한 경우가 있습니다. 셋째, Claude Desktop 재시작할 때 시스템 트레이에서 완전히 종료한 후 다시 시작해야 합니다. 뭔가 안 되면 먼저 fastmcp dev server.py로 서버가 단독으로 정상 동작하는지 확인해 보세요.
하나의 MCP 서버에 도구를 몇 개까지 추가하면 좋을까요?
경험상 10~15개 이내가 적당합니다. 도구가 너무 많으면 LLM이 어떤 걸 써야 할지 헷갈려 하면서, 엉뚱한 도구를 선택하는 빈도가 올라갑니다. 도메인이 다른 도구들은 별도의 MCP 서버로 분리하고, 필요한 서버만 클라이언트에 등록하는 방식을 추천합니다.
stdio와 HTTP, 프로덕션에선 뭘 써야 하나요?
혼자 로컬에서 쓸 거라면 stdio가 가장 간단합니다. 설정도 쉽고 네트워크 구성이 필요 없으니까요. 하지만 팀 전체가 공유하거나 클라우드에 배포해야 한다면 Streamable HTTP를 선택하세요. HTTPS 위에서 동작하므로 보안이 기본 제공되고, 세션 관리와 인증 기능까지 활용할 수 있습니다.