* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
(MCP 시리즈 ④) 실전
실무에서 MCP가 빛나는 순간은 이미 있는 시스템을 감쌀 때다. 새로 뭘 만드는 게 아니라, 굴러가던 REST API 앞에 얇은 MCP 껍데기를 씌우는 거다. 예를 들어 주문 조회 API가 있다면 이렇게 감싼다.
import os, httpx
from mcp.server import MCPServer # 공식 파이썬 SDK v2 기준
mcp = MCPServer("orders")
TOKEN = os.environ["INTERNAL_API_TOKEN"] # 키는 환경변수로, 모델엔 안 넘김
@mcp.tool()
def get_order(order_id: str) -> dict:
"""주문 번호로 주문 상태를 조회한다.
사용자가 주문/배송 상태를 물을 때 호출한다."""
r = httpx.get(
f"https://internal.example.com/orders/{order_id}",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=10,
)
r.raise_for_status()
return r.json()
if __name__ == "__main__":
mcp.run(transport="stdio") # 로컬 기본 — 클라이언트가 서브프로세스로 띄운다
버전 주의 — 이 글의 코드는 파이썬 공식 MCP SDK v2(현행 안정판) 기준이다. v1의 FastMCP는 v2에서 MCPServer로 이름이 바뀌었고, 지금 pip install mcp는 v2를 설치한다. 예전 코드를 유지하려면 mcp>=1.28,<2로 고정.
이렇게만 해도 “12345번 주문 어디까지 갔어?”라고 물으면 챗봇이 사내 시스템을 조회해서 답한다. 여기서 설계 감각 몇 가지가 갈린다.
첫째, 도구는 얇게 만든다. 하나의 도구가 여러 일을 하게 하지 말고, 조회는 조회, 취소는 취소로 쪼갠다. 그래야 모델이 언제 뭘 쓸지 헷갈리지 않고, 위험한 작업만 따로 통제할 수 있다.
둘째, 되돌릴 수 없는 작업엔 확인 게이트를 둔다. 주문 취소나 환불처럼 사고 나면 곤란한 건, 도구가 바로 실행하지 말고 “정말 취소할까요?”를 거치게 설계한다. 조회는 자동으로, 변경은 확인 후로 나누는 게 기본이다.
셋째, 출력이 크면 잘라서 준다. 주문 1만 건을 통째로 반환하면 모델 컨텍스트가 터진다. 페이지네이션을 걸거나 요약해서 넘기고, 상세가 필요하면 추가 조회하게 한다. 리소스로 문서나 정책을 노출해두면 모델이 답할 때 근거로 삼기도 좋다.
얇은 껍데기의 원칙 — 있는 걸 그대로 흘리지 마라
가장 흔한 실수가 기존 REST 엔드포인트를 1:1로 그대로 노출하는 것이다. 좋은 래핑은 ‘모델이 쓰기 좋은 형태’로 다듬는다. ① 도구 이름·설명에 ‘언제 쓰는지‘까지 적고(3편), ② 응답에서 모델에 필요 없는 필드는 걷어내 핵심만 남기고, ③ 여러 번 호출해야 끝나는 일은 한 도구로 묶는다. API 문서를 던지는 게 아니라 ‘모델용 인터페이스’를 새로 설계하는 일이다.
이때 잊지 말 것 — 도구 스키마가 곧 프롬프트다. 모델은 이름·설명만 보고 도구를 고른다. 이름은 동사+명사(get_order), 설명은 ‘무엇을’ 다음에 ‘언제‘. 인자엔 형식 예시(“ORD-2024-001” 꼴)를 적고, 헷갈리는 도구 쌍엔 서로의 설명에 경계선(“환불은 get_refund로”)을 긋는다. 모델이 도구를 엉뚱하게 고르면 십중팔구 설명 탓이다.
인증은 서버가 쥔다 — 모델은 토큰을 모른다
핵심 보안 원칙이다. 내부 API 토큰·DB 암호는 MCP 서버가 환경변수로 들고, 모델에겐 절대 넘기지 않는다. 모델은 “주문 12345 조회”라고 도구를 부를 뿐, 그 뒤에서 어떤 토큰으로 인증하는지 알지 못한다. 이렇게 해야 프롬프트나 대화에 키가 샐 일이 없다(5편).
@mcp.tool()
def get_order(order_id: str) -> dict:
"""주문 번호로 주문 상태를 조회한다. 고객이 주문을 물을 때 사용."""
r = httpx.get(
f"https://internal.example.com/orders/{order_id}",
headers={"Authorization": f"Bearer {TOKEN}"}, # 키는 서버만 안다
timeout=10,
)
...
권한은 도구 단위로 좁혀라
사내 API가 읽기·쓰기·삭제를 다 지원해도, MCP로는 필요한 것만 연다. 조회 도구만 노출하고 삭제는 아예 빼거나, 쓰기 도구엔 확인 단계를 둔다. 도구 하나가 곧 모델에게 준 권한이다. 처음부터 전부 감싸지 말고 최소 권한으로 시작하는 게 안전하다.
사내 API의 현실 — 타임아웃, 재시도, 인증 헤더
설계 다음은 현실이다. 사내 API는 낮 시간대만 느려지고, 토큰은 예고 없이 만료되고, 점검 중엔 500만 돌아온다. 기본기는 셋. 타임아웃은 반드시 명시(연결 3초, 전체는 API 성격대로) — 안 걸면 백엔드가 멈춘 날 도구도 같이 무한 대기한다. 재시도는 멱등한 조회에만 — 취소·환불을 기계적으로 재시도하면 이중 취소 사고가 난다. 인증 헤더는 클라이언트 객체에 한 번만(httpx.Client(...)) — 도구마다 복사하면 하나쯤 빠진다. 셋 다 아래 완성본에 그대로 들어 있다.
사용자별 권한이 있는 API라면 서비스 토큰 하나로 뭉개지 말자 — “누가 조회했는지”가 사라진다. stdio면 개인 토큰을 각자 환경변수로, 공유 HTTP 서버면 클라이언트 인증을 검증해 위임한다(보안 편(5편)).
에러도 모델이 읽을 수 있게 되돌린다
API가 404·500을 던질 때 그대로 예외로 터뜨리지 말고, 모델이 이해할 문장으로 바꿔 돌려준다. 그래야 모델이 다음 행동 — 사용자에게 되묻기, 다른 번호로 재시도 — 을 스스로 정한다.
if r.status_code == 404:
return {"error": "주문을 찾을 수 없음. 번호를 확인하세요."}
r.raise_for_status()
d = r.json()
return {"id": d["id"], "status": d["status"], "eta": d.get("eta")} # 필요한 것만
확인 게이트, 구현은 인자 하나
“정말 취소할까요?”를 말로만 두지 말고 도구 계약에 박자. 제일 간단한 구현은 confirm 인자다 — 없이 부르면 실행하지 않고, 무엇이 일어날지 요약해 되돌려준다. 모델은 그 요약을 사용자에게 보여주고, 사용자가 동의하면 confirm=true로 다시 부른다.
@mcp.tool()
def cancel_order(order_id: str, confirm: bool = False) -> dict:
"""주문을 취소한다. 되돌릴 수 없으므로 confirm=True가 필요하다."""
order = fetch(order_id)
if not confirm:
return {"needs_confirm": True,
"preview": f"{order_id}: {order['item']} {order['qty']}건 취소 예정"}
return do_cancel(order_id) # 실제 실행은 여기서만
포인트는 미리보기(preview)다 — 사용자가 승인하는 건 ‘취소’라는 추상이 아니라 무엇이 취소되는지여야 한다. 이 패턴은 5편의 사람 확인(HITL) 원칙을 도구 스스로 구현하는 가장 값싼 방법이다.
직접 해보기 — 설치부터 호출까지, 도는 완성본
여기까지의 조각을 한 파일로 합쳤다. MCP 서버 만들기가 처음이면 이 완성본으로 시작하면 된다 — 설치부터 등록, 실제 호출까지 이어진다. 준비물은 파이썬 3.10+.
pip install "mcp[cli]" httpx # 또는: uv add "mcp[cli]" httpx
export INTERNAL_API_TOKEN="발급받은-토큰" # 키는 환경변수로
서버 파일 전체다. 조회는 자동, 취소는 확인 게이트 — 주소·필드명만 바꾸면 그대로 도는 Model Context Protocol 예제다.
# orders_mcp.py — 사내 주문 API를 감싸는 MCP 서버 완성본
import os, httpx
from mcp.server import MCPServer
mcp = MCPServer("orders")
api = httpx.Client(
base_url="https://internal.example.com",
headers={"Authorization": f"Bearer {os.environ['INTERNAL_API_TOKEN']}"},
timeout=httpx.Timeout(10.0, connect=3.0),
)
@mcp.tool()
def get_order(order_id: str) -> dict:
"""주문 번호로 주문 상태를 조회한다. 취소는 cancel_order를 쓴다."""
try:
r = api.get(f"/orders/{order_id}")
except httpx.TimeoutException:
return {"error": "주문 시스템 응답 지연. 잠시 후 다시 시도하세요."}
if r.status_code == 404:
return {"error": "주문을 찾을 수 없음. 번호를 확인하세요."}
r.raise_for_status()
d = r.json()
return {"id": d["id"], "status": d["status"], "eta": d.get("eta")}
@mcp.tool()
def cancel_order(order_id: str, confirm: bool = False) -> dict:
"""주문을 취소한다. 되돌릴 수 없으므로 confirm=True가 필요하다."""
order = get_order(order_id)
if "error" in order:
return order
if not confirm:
return {"needs_confirm": True,
"preview": f"{order_id}: '{order['status']}' 주문 취소 예정"}
r = api.post(f"/orders/{order_id}/cancel")
r.raise_for_status()
return {"canceled": order_id}
if __name__ == "__main__":
mcp.run(transport="stdio")
stdio 서버는 클라이언트가 서브프로세스로 띄우니 직접 실행하면 조용히 기다린다. 손 테스트는 mcp dev orders_mcp.py — 브라우저 Inspector가 열린다(Node 필요). 등록은 Claude Code 한 줄, 팀 공유는 --scope project(토큰은 각자 환경변수로).
claude mcp add orders --env INTERNAL_API_TOKEN=발급받은-토큰 \
-- python /absolute/path/orders_mcp.py
claude mcp list # 등록 확인
Claude Desktop은 claude_desktop_config.json에 command·args·env로 같은 내용을 적는다 — 예제와 안 뜰 때 점검 순서는 연결 편(3편)에. 실제 대화는 이렇게 흘러간다.
나: 12345번 주문 어디까지 갔어?
Claude: ⚡ get_order {"order_id": "12345"}
→ {"id": "12345", "status": "배송중", "eta": "2026-09-12"}
배송 중이고, 9월 12일 도착 예정입니다.
나: 그거 취소해줘
Claude: ⚡ cancel_order {"order_id": "12345"}
→ {"needs_confirm": true, "preview": "12345: '배송중' 주문 취소 예정"}
배송 중인 주문입니다. 정말 취소할까요?
나: 응, 취소해
Claude: ⚡ cancel_order {"order_id": "12345", "confirm": true}
→ {"canceled": "12345"}
취소했습니다.
- ✅
mcp devInspector에 도구 두 개가 보인다. - ✅ 주문을 물으면 호출 로그가 뜨고, 답이 실제 API 값과 일치한다.
- ✅ 취소는 바로 실행되지 않고 무엇이 취소되는지 보여주며 되묻는다.
- ✅ 토큰 없이 띄우면 즉시 죽는다 — 키가 코드에도 대화에도 없다는 뜻.
전송은 stdio로 시작해 HTTP로 넓힌다
완성본이 stdio인 이유 — 혼자 쓰는 동안은 배포도 열어둘 포트도 없는 stdio가 정답이다. 팀이 같은 서버를 쓰게 되면 mcp.run(transport="streamable-http", host=..., port=...)로 원격으로 올린다. 인증·로그가 한곳에 모이는 대신 호출자 검증이 서버 몫이 된다. 예전 자료의 SSE 전송은 streamable HTTP로 대체되는 흐름. 기준은 하나 — 혼자면 stdio, 여럿이면 streamable HTTP(서버 편(2편)).
실무 도입 순서
- 가장 자주 묻는 조회 하나를 읽기 전용 도구로 감싼다.
- 내부에서 실제 값으로 검증한다(엉뚱한 응답·누락 필드 점검).
- 팀에 붙여보며 도구 설명을 다듬는다 — 모델이 헷갈리면 설명 문제다.
- 쓰기·삭제 도구는 확인 단계와 함께 맨 나중에 연다.
MCP의 진짜 가치는 새 시스템이 아니라 이미 돌아가는 시스템을 얇게 감싸 모델의 손에 쥐여줄 때 나온다. 다음은 이 껍데기를 안전하게 지키는 보안(5편)이다.
자주 묻는 질문 (FAQ)
사내 API를 MCP로 감싸면 뭐가 좋나요?
모델이 사내 시스템을 표준 방식으로 호출할 수 있고, 인증·권한을 서버에서 통제하며, 여러 AI 앱이 같은 서버를 재사용할 수 있습니다.
기존 REST API를 그대로 노출하면 되나요?
권하지 않습니다. 모델이 쓰기 좋게 응답을 다듬고, 필요한 도구만 최소 권한으로 열고, 에러를 모델이 읽을 문장으로 바꾸는 ‘얇은 껍데기’ 설계가 필요합니다. 1:1 노출은 컨텍스트 낭비와 권한 과다로 이어집니다.
MCP 서버 만들기, 어떤 SDK로 시작하나요?
파이썬은 공식 mcp 패키지(v2, MCPServer 클래스), TypeScript/Node는 공식 @modelcontextprotocol/sdk입니다. 어느 쪽이든 타입 달린 함수와 설명이 곧 도구 스키마가 되는 구조라, 이 글의 예제를 다른 언어로 옮기기 쉽습니다.
stdio와 HTTP 전송 중 뭘 써야 하나요?
혼자 로컬이면 stdio, 팀 공유 서버면 streamable HTTP입니다. stdio는 배포 없이 바로 시작하고, HTTP는 인증·로그를 한곳에 모으는 대신 호출자 검증이 필요합니다. SSE 전송은 streamable HTTP로 대체되는 추세입니다.

답글 남기기