← 블로그

MCP 서버 만들기: Tool 스키마부터 Docker 배포까지

2026년 8월 19일

Claude MCP 서버를 직접 설계하고 배포하는 실전 가이드. Tool 스키마부터 Docker 배포까지 전 과정을 다룹니다.

Claude로 MCP 서버를 직접 만들 수 있습니까? 이 역량은 CCA-F 자격증 평가 영역에도 포함되지만, 실제 현장에서는 그보다 먼저 요구됩니다. 팀 내부 데이터나 독자적인 워크플로우를 Claude에 붙이려면, 남이 만든 서버를 연동하는 것으로는 부족합니다. 이 글은 Tool 스키마 설계와 프롬프트 엔지니어링 기법부터 Docker 기반 프로덕션 배포까지, MCP 서버 구축 전 과정을 실전 코드로 안내합니다.

MCP 서버를 직접 구축하려면 공식 SDK를 설치하고(Python: pip install mcp / TypeScript: npm install @modelcontextprotocol/sdk), Tool의 name·description·inputSchema를 정의한 핸들러 두 개를 구현하면 됩니다. description에 호출 트리거 조건을 명시하는 것이 핵심 설계 포인트이며, 로컬 테스트는 stdio, 팀 공유 배포는 SSE 방식을 씁니다.

MCP 서버 '연동'과 '구축', 무엇이 다른가?

Model Context Protocol(MCP)은 Claude 같은 AI 모델이 외부 기능에 표준화된 방식으로 접근하도록 만든 프로토콜입니다. GitHub, Slack, Notion 등 이미 공개된 MCP 서버가 있고, 클로드 코드 설정 파일에 몇 줄만 추가하면 즉시 사용할 수 있습니다. 이것이 "연동"입니다.

"구축"은 다릅니다. 사내 주문 관리 시스템, 전사 코드 검색 도구, 팀 내부 결재 API — 이런 것들은 공개 MCP 서버가 없습니다. Claude가 이 데이터에 접근하려면 해당 시스템을 직접 MCP 서버로 래핑해야 합니다.

  • 연동(소비자): 공개된 MCP 서버를 Claude Code에 등록해 그대로 사용
  • 구축(생산자): 원하는 기능을 Tool로 정의하고 MCP 프로토콜로 직접 구현·배포

팀에서 Claude를 단순 챗봇으로 쓰는 데 그치지 않고, 사내 데이터 기반의 자동화 도구로 활용하려면 MCP 서버 구축이 필수입니다. 이 글은 그 첫 서버를 만드는 과정을 단계별로 안내합니다.

Tool 스키마 설계: 프롬프트 엔지니어링 기법을 어떻게 적용하나?

MCP 서버에서 Claude의 동작을 결정하는 핵심은 Tool 스키마입니다. Claude는 Tool의 스키마를 읽고 언제, 어떤 파라미터로 이 Tool을 써야 할지 스스로 판단합니다. 즉, 스키마 설계가 곧 프롬프트 엔지니어링입니다.

Tool 스키마는 세 가지로 구성됩니다.

  1. name: Tool의 역할이 명확히 드러나는 식별자
  2. description: Claude가 이 Tool을 언제 써야 하는지 알려주는 지침
  3. inputSchema: Tool이 받는 파라미터의 JSON Schema 정의

이 중 description이 가장 중요합니다. "무엇을 하는 도구"인지만 적으면 Claude가 상황을 판단하기 어렵습니다. 언제 이 Tool을 써야 하는지를 명시해야 Claude가 적시에 호출합니다. 이것이 MCP 서버 설계에 직접 적용하는 핵심 프롬프트 엔지니어링 기법입니다.

나쁜 예시 — 기능만 설명:

{
  "name": "search_docs",
  "description": "문서를 검색합니다.",
  "inputSchema": { "..." }
}

좋은 예시 — 트리거 조건 포함:

{
  "name": "search_internal_docs",
  "description": "사내 기술 문서, API 레퍼런스, 운영 가이드를 검색합니다. 사용자가 특정 기능의 동작 방식이나 내부 API 사용법을 물을 때 반드시 이 Tool을 먼저 호출하세요. 외부 공개 정보가 아닌 사내 특화 정보가 필요한 상황에 적합합니다.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "검색어. 한국어 또는 영어 키워드 모두 지원됩니다."
      },
      "category": {
        "type": "string",
        "enum": ["api", "ops", "architecture", "all"],
        "description": "검색 범위. 특정 영역을 모르면 'all'을 사용하세요."
      }
    },
    "required": ["query"]
  }
}

파라미터의 description도 중요합니다. "검색 범위. 특정 영역을 모르면 'all'을 사용하세요."처럼 기본값 힌트를 주면 Claude가 더 안정적으로 파라미터를 채웁니다. 스키마의 설명문 하나하나가 Claude의 판단 기준이 되므로, 이 점을 염두에 두고 설계합니다.

개발 환경과 프로젝트 구조: 처음 시작은 어디서부터?

MCP 서버는 Python과 TypeScript 두 언어로 구현할 수 있습니다. Anthropic이 두 언어의 공식 SDK를 모두 제공합니다.

Python SDK 설치:

pip install mcp

TypeScript SDK 설치:

npm install @modelcontextprotocol/sdk

권장 프로젝트 구조는 다음과 같습니다.

my-mcp-server/
├── src/
│   ├── __init__.py
│   ├── server.py        # MCP 서버 진입점 (stdio 전송, 로컬용)
│   ├── server_sse.py    # 원격 배포용 (SSE 전송)
│   └── tools/
│       ├── __init__.py
│       ├── search.py    # 검색 Tool 핸들러
│       └── orders.py    # 주문 조회 Tool 핸들러
├── Dockerfile
├── pyproject.toml
└── README.md

Tool별로 파일을 분리하면 새 Tool을 추가할 때 기존 코드 수정 없이 파일 하나만 추가하면 됩니다. 팀 규모가 커지거나 Tool 수가 늘어나도 구조가 유지됩니다.

Python과 TypeScript로 MCP Tool 핸들러를 어떻게 구현하나?

사내 데이터베이스에서 주문 정보를 조회하는 Tool을 예시로 살펴봅니다.

Python 구현 (src/server.py):

import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
import mcp.types as types

server = Server("order-lookup-server")

@server.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="get_order_status",
            description=(
                "주문 번호로 현재 배송 상태와 예상 도착일을 조회합니다. "
                "사용자가 주문 현황, 배송 추적, 도착 예정일을 물으면 이 Tool을 호출하세요. "
                "사내 주문 관리 시스템 데이터를 반환하며, "
                "외부 배송업체 실시간 정보는 포함되지 않습니다."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "order_id": {
                        "type": "string",
                        "description": "주문 번호 (예: ORD-20240115-001)"
                    }
                },
                "required": ["order_id"]
            }
        )
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    if name == "get_order_status":
        order_id = arguments["order_id"]
        result = await fetch_order_from_db(order_id)  # 실제 DB 조회 로직
        return [types.TextContent(type="text", text=result)]
    raise ValueError(f"알 수 없는 Tool: {name}")

async def main():
    async with stdio_server() as (read, write):
        await server.run(read, write, server.create_initialization_options())

if __name__ == "__main__":
    asyncio.run(main())

TypeScript 구현 (src/server.ts):

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
  { name: "order-lookup-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "get_order_status",
      description:
        "주문 번호로 현재 배송 상태와 예상 도착일을 조회합니다. " +
        "사용자가 주문 현황을 물으면 이 Tool을 호출하세요.",
      inputSchema: {
        type: "object",
        properties: {
          order_id: {
            type: "string",
            description: "주문 번호 (예: ORD-20240115-001)",
          },
        },
        required: ["order_id"],
      },
    },
  ],
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  if (name === "get_order_status") {
    const result = await fetchOrderFromDb(args?.order_id as string);
    return { content: [{ type: "text", text: result }] };
  }
  throw new Error(`알 수 없는 Tool: ${name}`);
});

const transport = new StdioServerTransport();
await server.connect(transport);

구조는 단순합니다. Tool 목록을 반환하는 핸들러Tool 호출을 처리하는 핸들러, 두 부분이 전부입니다. 실제 DB 조회나 내부 API 호출 로직을 이 두 핸들러 사이에 연결하면 서버가 완성됩니다.

로컬 MCP 서버를 Claude Code에 어떻게 연결하고 디버깅하나?

로컬 서버는 stdio 전송 방식으로 연결합니다. 프로젝트 루트의 .claude/settings.json 파일에 다음을 추가합니다.

{
  "mcpServers": {
    "order-lookup": {
      "command": "python",
      "args": ["-m", "src.server"],
      "env": {
        "DATABASE_URL": "postgresql://localhost:5432/mydb"
      }
    }
  }
}

TypeScript 서버라면 "command": "node", "args": ["dist/server.js"]로 바꿉니다. 설정 후 클로드 코드를 재시작하면 자동으로 서버에 연결됩니다.

연결 확인은 간단합니다. 채팅창에 "사용 가능한 Tool 목록을 알려줘"라고 입력하면 Claude가 등록된 Tool 이름과 설명을 나열합니다. 여기서 description이 의도한 대로 보이는지 확인하는 것이 디버깅의 첫 단계입니다.

자주 겪는 문제와 해결법:

  • 서버 로그는 stderr로 출력합니다. Python은 print(..., file=sys.stderr) 또는 logging 모듈을 사용합니다.
  • Tool이 목록에 보이지 않으면 settings.json 경로와 JSON 문법을 먼저 확인합니다.
  • Claude가 엉뚱한 파라미터를 넘긴다면 description에 트리거 조건을 더 구체적으로 명시합니다.
  • 환경 변수가 적용되지 않으면 env 블록에 직접 값을 지정하거나 서버 코드에 .env 파일 로딩 로직을 추가합니다.

MCP 서버를 Docker로 프로덕션에 어떻게 배포하나?

로컬 테스트가 끝나면 팀 전체가 쓸 수 있도록 배포합니다. Docker로 컨테이너화한 뒤 SSE(Server-Sent Events) 전송 방식을 사용하면 원격에서도 URL 하나로 연결할 수 있습니다.

Dockerfile (Python 예시):

FROM python:3.11-slim

WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir .
COPY src/ ./src/

EXPOSE 8080
CMD ["python", "-m", "src.server_sse"]

SSE 전송을 지원하도록 server_sse.py를 작성합니다.

from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route
import uvicorn

sse = SseServerTransport("/messages/")

async def handle_sse(request):
    async with sse.connect_sse(
        request.scope, request.receive, request._send
    ) as streams:
        await server.run(
            streams[0], streams[1],
            server.create_initialization_options()
        )

app = Starlette(routes=[Route("/sse", endpoint=handle_sse)])

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8080)

배포 후 팀원들은 settings.json에서 URL만 지정하면 됩니다.

{
  "mcpServers": {
    "order-lookup": {
      "url": "https://mcp.internal.company.com/sse"
    }
  }
}

보안 필수 체크리스트:

  • 내부망 배포라도 인증 헤더(API 키 또는 Bearer 토큰)를 반드시 추가합니다.
  • DB 접속 정보는 환경 변수로 주입하고 Docker 이미지에 하드코딩하지 않습니다.
  • MCP Tool이 실행하는 DB 쿼리는 읽기 전용 계정으로 제한합니다.
  • Claude가 호출 가능한 Tool의 범위를 업무에 필요한 최소한으로 설계합니다.

이렇게 배포한 MCP 서버는 팀원 누구나 클로드 코드 설정 파일 한 줄로 연결할 수 있습니다. 사내 데이터에 Claude를 붙이는 가장 표준적인 방법입니다.

MCP 서버 구축 역량은 국내 AI 실무 현장에서 빠르게 기본 요건이 되고 있습니다. 이 글에서 다룬 Tool 스키마 설계, Python·TypeScript 구현, Docker 배포 흐름은 CCA-F 실기가 평가하는 핵심 기술 영역과도 겹칩니다. 이 내용을 체계적으로 준비하고 싶다면 Plinth Prep의 CCA-F 학습 자료를 참고할 수 있습니다. Plinth Prep은 Anthropic과 제휴·후원·인증 관계가 없는 독립 학습 사이트입니다.

자주 묻는 질문

MCP 서버를 직접 만들려면 어디서 시작해야 하나요?
MCP 서버 구축은 Tool 스키마 정의부터 시작합니다. Python은 pip install mcp, TypeScript는 npm install @modelcontextprotocol/sdk로 공식 SDK를 설치한 뒤, Tool의 name·description·inputSchema를 정의합니다. description에 이 Tool을 언제 써야 하는지 명시하는 것이 핵심이며, Tool 핸들러에 DB 조회 로직을 연결하면 기본 서버가 완성됩니다.
MCP Tool의 description을 효과적으로 쓰는 방법은 무엇인가요?
Tool description은 Claude가 이 도구를 언제 써야 하는지 판단하는 기준입니다. 단순히 기능만 설명하는 대신, 사용 상황을 구체적으로 명시하는 것이 핵심 프롬프트 엔지니어링 기법입니다. '데이터를 조회합니다' 대신 '사용자가 주문 현황을 물을 때 반드시 이 Tool을 먼저 호출하세요'처럼 트리거 조건을 포함하고, 파라미터별 description에도 예시값과 기본값 힌트를 넣으면 Claude의 판단이 훨씬 정확해집니다.
MCP 서버를 Docker로 프로덕션 배포할 때 주의할 점은 무엇인가요?
프로덕션 배포 시 stdio 대신 SSE(Server-Sent Events) 전송 방식으로 전환해야 팀 전체가 URL 하나로 접속할 수 있습니다. Dockerfile에는 DB 접속 정보나 API 키를 하드코딩하지 말고 환경 변수로 주입하는 것이 기본입니다. MCP Tool이 실행하는 DB 쿼리는 읽기 전용 계정으로 제한하고, 내부망 배포라도 인증 헤더를 반드시 추가해 무단 접근을 차단해야 합니다.
CCA-F에서 MCP 서버 구축 역량이 평가 항목인가요?
CCA-F(Claude Certified Architect — Foundations)는 Claude 플랫폼의 핵심 기능과 아키텍처 설계 역량을 평가하는 자격증입니다. Tool 스키마 설계, 프롬프트 엔지니어링 기법 적용, Claude와 외부 시스템을 연결하는 MCP 아키텍처 설계 능력이 평가 영역에 포함됩니다. 정확한 출제 범위는 공식 시험 안내를 확인하세요. Plinth Prep은 Anthropic과 무관한 독립 사이트입니다.

이 글 공유하기

Plinth Prep는 독립적인 학습 자료이며 Anthropic과 제휴, 보증, 후원 관계가 없습니다. 연습 문제는 Plinth Prep가 직접 제작한 것이며 실제 시험 문제를 옮긴 것이 아닙니다.