[MCP] 4. 실전 : 사내 API를 MCP로 감싸기

MCP 직접 만들기 4편 - 사내 API를 MCP로 감싸기

작성자

카테고리:

* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

(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 dev Inspector에 도구 두 개가 보인다.
  • ✅ 주문을 물으면 호출 로그가 뜨고, 답이 실제 API 값과 일치한다.
  • ✅ 취소는 바로 실행되지 않고 무엇이 취소되는지 보여주며 되묻는다.
  • ✅ 토큰 없이 띄우면 즉시 죽는다 — 키가 코드에도 대화에도 없다는 뜻.

전송은 stdio로 시작해 HTTP로 넓힌다

완성본이 stdio인 이유 — 혼자 쓰는 동안은 배포도 열어둘 포트도 없는 stdio가 정답이다. 팀이 같은 서버를 쓰게 되면 mcp.run(transport="streamable-http", host=..., port=...)로 원격으로 올린다. 인증·로그가 한곳에 모이는 대신 호출자 검증이 서버 몫이 된다. 예전 자료의 SSE 전송은 streamable HTTP로 대체되는 흐름. 기준은 하나 — 혼자면 stdio, 여럿이면 streamable HTTP(서버 편(2편)).

실무 도입 순서

  1. 가장 자주 묻는 조회 하나를 읽기 전용 도구로 감싼다.
  2. 내부에서 실제 값으로 검증한다(엉뚱한 응답·누락 필드 점검).
  3. 팀에 붙여보며 도구 설명을 다듬는다 — 모델이 헷갈리면 설명 문제다.
  4. 쓰기·삭제 도구는 확인 단계와 함께 맨 나중에 연다.

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로 대체되는 추세입니다.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다