AI 배우기

MCP 서버 만들기 완벽 가이드: 초보자도 따라하는 실전 예제

bigempty 2026. 2. 9. 14:01

Claude Code에 나만의 도구를 연결하는 MCP 서버를 처음부터 끝까지 만들어봅니다.

 


MCP란 무엇인가?

MCP(Model Context Protocol)는 AI 모델이 외부 도구, 데이터베이스, API에 접근할 수 있게 해주는 오픈 소스 표준 프로토콜입니다.

쉽게 비유하면 이렇습니다:

스마트폰(Claude) ←→ USB 케이블(MCP) ←→ 외부 장치(도구/API/DB)

스마트폰이 USB 케이블 하나로 카메라, 키보드, 프린터 등 다양한 장치에 연결되듯이, Claude도 MCP 하나로 GitHub, 데이터베이스, 사내 API 등 다양한 도구에 연결됩니다.

MCP 아키텍처 한눈에 보기

┌─────────────────────┐
│   Claude Code       │  ← MCP 클라이언트 (호스트)
│   (또는 Claude Desktop) │
└────────┬────────────┘
         │  MCP 프로토콜 (JSON-RPC)
         │
    ┌────▼────┐  ┌────▼────┐  ┌────▼────┐
    │ MCP     │  │ MCP     │  │ MCP     │
    │ 서버 A  │  │ 서버 B  │  │ 서버 C  │
    │ (GitHub)│  │ (DB)    │  │ (나만의)│
    └────┬────┘  └────┬────┘  └────┬────┘
         │            │            │
    ┌────▼────┐  ┌────▼────┐  ┌────▼────┐
    │ GitHub  │  │ Postgres│  │ 로컬    │
    │ API     │  │         │  │ 파일/API│
    └─────────┘  └─────────┘  └─────────┘

MCP 서버가 제공하는 3가지 기능

기능 설명 예시

Tools (도구) LLM이 호출할 수 있는 함수 날씨 조회, DB 쿼리, 파일 생성
Resources (리소스) 읽을 수 있는 데이터 API 응답, 파일 내용, 설정값
Prompts (프롬프트) 미리 작성된 템플릿 코드 리뷰 양식, 보고서 템플릿

이 가이드에서는 가장 많이 쓰이는 Tools에 집중합니다.


실전 예제: "할 일 관리" MCP 서버 만들기

날씨 API 예제는 이미 많으니, 더 실용적인 할 일(Todo) 관리 MCP 서버를 만들어보겠습니다. 이 서버를 연결하면 Claude Code에서 자연어로 할 일을 관리할 수 있게 됩니다.

"오늘 해야 할 일에 '블로그 글 작성'을 추가해줘"
→ Claude가 MCP 서버의 add_todo 도구를 호출
→ 할 일이 JSON 파일에 저장됨

방법 1: Python으로 MCP 서버 만들기

Step 1: 환경 준비

# uv 설치 (Python 패키지 매니저)
curl -LsSf https://astral.sh/uv/install.sh | sh

# 프로젝트 생성
uv init todo-mcp
cd todo-mcp

# 가상환경 생성 및 활성화
uv venv
source .venv/bin/activate    # macOS/Linux
# .venv\Scripts\activate     # Windows

# MCP SDK 설치
uv add "mcp[cli]"

Step 2: 서버 코드 작성

todo_server.py 파일을 만듭니다:

import json
import os
from datetime import datetime
from mcp.server.fastmcp import FastMCP

# ============================================
# 1. MCP 서버 인스턴스 생성
# ============================================
mcp = FastMCP("todo-manager")

# 할 일 데이터를 저장할 파일 경로
TODO_FILE = os.path.expanduser("~/todos.json")


# ============================================
# 2. 헬퍼 함수: 파일 읽기/쓰기
# ============================================
def load_todos() -> list[dict]:
    """JSON 파일에서 할 일 목록을 불러옵니다."""
    if not os.path.exists(TODO_FILE):
        return []
    with open(TODO_FILE, "r", encoding="utf-8") as f:
        return json.load(f)


def save_todos(todos: list[dict]) -> None:
    """할 일 목록을 JSON 파일에 저장합니다."""
    with open(TODO_FILE, "w", encoding="utf-8") as f:
        json.dump(todos, f, ensure_ascii=False, indent=2)


# ============================================
# 3. MCP 도구(Tool) 정의 — 핵심 부분!
# ============================================

@mcp.tool()
def add_todo(title: str, priority: str = "medium") -> str:
    """할 일을 추가합니다.

    Args:
        title: 할 일 제목 (예: "블로그 글 작성")
        priority: 우선순위 — "high", "medium", "low" 중 하나
    """
    todos = load_todos()

    new_todo = {
        "id": len(todos) + 1,
        "title": title,
        "priority": priority,
        "done": False,
        "created_at": datetime.now().isoformat()
    }

    todos.append(new_todo)
    save_todos(todos)

    return f"✅ 할 일 추가 완료: [{priority}] {title} (ID: {new_todo['id']})"


@mcp.tool()
def list_todos(show_done: bool = False) -> str:
    """현재 할 일 목록을 보여줍니다.

    Args:
        show_done: True이면 완료된 항목도 표시합니다
    """
    todos = load_todos()

    if not todos:
        return "📋 할 일이 없습니다. 새로운 할 일을 추가해보세요!"

    if not show_done:
        todos = [t for t in todos if not t["done"]]

    if not todos:
        return "🎉 모든 할 일을 완료했습니다!"

    lines = ["📋 할 일 목록:", ""]
    for t in todos:
        status = "✅" if t["done"] else "⬜"
        priority_emoji = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(
            t["priority"], "⚪"
        )
        lines.append(
            f"  {status} [{t['id']}] {priority_emoji} {t['title']}"
        )

    return "\n".join(lines)


@mcp.tool()
def complete_todo(todo_id: int) -> str:
    """할 일을 완료 처리합니다.

    Args:
        todo_id: 완료할 할 일의 ID 번호
    """
    todos = load_todos()

    for t in todos:
        if t["id"] == todo_id:
            if t["done"]:
                return f"이미 완료된 할 일입니다: {t['title']}"
            t["done"] = True
            save_todos(todos)
            return f"✅ 완료: {t['title']}"

    return f"❌ ID {todo_id}에 해당하는 할 일을 찾을 수 없습니다."


@mcp.tool()
def delete_todo(todo_id: int) -> str:
    """할 일을 삭제합니다.

    Args:
        todo_id: 삭제할 할 일의 ID 번호
    """
    todos = load_todos()
    original_count = len(todos)

    todos = [t for t in todos if t["id"] != todo_id]

    if len(todos) == original_count:
        return f"❌ ID {todo_id}에 해당하는 할 일을 찾을 수 없습니다."

    save_todos(todos)
    return f"🗑️ 할 일이 삭제되었습니다. (ID: {todo_id})"


# ============================================
# 4. 서버 실행
# ============================================
def main():
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()

핵심 포인트 해설

코드에서 가장 중요한 부분은 @mcp.tool() 데코레이터입니다:

@mcp.tool()
def add_todo(title: str, priority: str = "medium") -> str:
    """할 일을 추가합니다.                    ← Claude가 읽는 도구 설명

    Args:
        title: 할 일 제목                     ← 파라미터 설명 (자동으로 스키마 생성)
        priority: 우선순위 — "high", "medium", "low"
    """

FastMCP는 함수의 타입 힌트와 독스트링을 읽어서 자동으로 도구 정의(JSON Schema)를 만들어줍니다. Claude는 이 정보를 보고 "아, 할 일을 추가하려면 add_todo 도구에 title을 넣어 호출하면 되겠구나"라고 판단합니다.

Step 3: Claude Code에 연결

# Claude Code에 MCP 서버 등록
claude mcp add todo-manager -- uv --directory /절대/경로/todo-mcp run todo_server.py

각 부분의 의미:

부분 의미

claude mcp add MCP 서버를 추가하는 명령
todo-manager 서버 이름 (원하는 이름)
-- 이후는 실제 실행 명령어
uv --directory ... run todo_server.py 서버를 실행하는 명령

Step 4: 사용해보기

Claude Code를 실행하고 자연어로 요청합니다:

> 오늘 할 일에 "MCP 블로그 글 작성"을 높은 우선순위로 추가해줘

✅ 할 일 추가 완료: [high] MCP 블로그 글 작성 (ID: 1)

> "저녁 운동하기"도 추가해줘

✅ 할 일 추가 완료: [medium] 저녁 운동하기 (ID: 2)

> 현재 할 일 목록 보여줘

📋 할 일 목록:

  ⬜ [1] 🔴 MCP 블로그 글 작성
  ⬜ [2] 🟡 저녁 운동하기

> 1번 완료 처리해줘

✅ 완료: MCP 블로그 글 작성

방법 2: TypeScript로 MCP 서버 만들기

TypeScript를 선호한다면 이 방법을 사용하세요.

Step 1: 환경 준비

# 프로젝트 생성
mkdir todo-mcp-ts && cd todo-mcp-ts
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D @types/node typescript

# 소스 디렉터리 생성
mkdir src

tsconfig.json 생성:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

package.json에 추가:

{
  "type": "module",
  "scripts": {
    "build": "tsc && chmod 755 build/index.js"
  }
}

Step 2: 서버 코드 작성

src/index.ts:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";

// ============================================
// 1. 서버 인스턴스 생성
// ============================================
const server = new McpServer({
  name: "todo-manager",
  version: "1.0.0",
});

const TODO_FILE = path.join(os.homedir(), "todos.json");

// ============================================
// 2. 헬퍼 함수
// ============================================
interface Todo {
  id: number;
  title: string;
  priority: string;
  done: boolean;
  created_at: string;
}

function loadTodos(): Todo[] {
  if (!fs.existsSync(TODO_FILE)) return [];
  return JSON.parse(fs.readFileSync(TODO_FILE, "utf-8"));
}

function saveTodos(todos: Todo[]): void {
  fs.writeFileSync(TODO_FILE, JSON.stringify(todos, null, 2), "utf-8");
}

// ============================================
// 3. 도구 등록 — 핵심 부분!
// ============================================

// 할 일 추가
server.registerTool(
  "add_todo",
  {
    description: "할 일을 추가합니다",
    inputSchema: {
      title: z.string().describe("할 일 제목 (예: '블로그 글 작성')"),
      priority: z
        .enum(["high", "medium", "low"])
        .default("medium")
        .describe("우선순위"),
    },
  },
  async ({ title, priority }) => {
    const todos = loadTodos();
    const newTodo: Todo = {
      id: todos.length + 1,
      title,
      priority,
      done: false,
      created_at: new Date().toISOString(),
    };
    todos.push(newTodo);
    saveTodos(todos);

    return {
      content: [
        {
          type: "text" as const,
          text: `✅ 할 일 추가 완료: [${priority}] ${title} (ID: ${newTodo.id})`,
        },
      ],
    };
  }
);

// 할 일 목록 조회
server.registerTool(
  "list_todos",
  {
    description: "현재 할 일 목록을 보여줍니다",
    inputSchema: {
      show_done: z
        .boolean()
        .default(false)
        .describe("완료된 항목도 표시할지 여부"),
    },
  },
  async ({ show_done }) => {
    let todos = loadTodos();

    if (!todos.length) {
      return {
        content: [{ type: "text" as const, text: "📋 할 일이 없습니다." }],
      };
    }

    if (!show_done) {
      todos = todos.filter((t) => !t.done);
    }

    const priorityEmoji: Record<string, string> = {
      high: "🔴",
      medium: "🟡",
      low: "🟢",
    };

    const lines = todos.map(
      (t) =>
        `  ${t.done ? "✅" : "⬜"} [${t.id}] ${priorityEmoji[t.priority] || "⚪"} ${t.title}`
    );

    return {
      content: [
        {
          type: "text" as const,
          text: `📋 할 일 목록:\n\n${lines.join("\n")}`,
        },
      ],
    };
  }
);

// 할 일 완료
server.registerTool(
  "complete_todo",
  {
    description: "할 일을 완료 처리합니다",
    inputSchema: {
      todo_id: z.number().describe("완료할 할 일의 ID 번호"),
    },
  },
  async ({ todo_id }) => {
    const todos = loadTodos();
    const todo = todos.find((t) => t.id === todo_id);

    if (!todo) {
      return {
        content: [
          {
            type: "text" as const,
            text: `❌ ID ${todo_id}에 해당하는 할 일을 찾을 수 없습니다.`,
          },
        ],
      };
    }

    todo.done = true;
    saveTodos(todos);

    return {
      content: [
        { type: "text" as const, text: `✅ 완료: ${todo.title}` },
      ],
    };
  }
);

// ============================================
// 4. 서버 실행
// ============================================
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Todo MCP Server running on stdio");
}

main().catch(console.error);

Step 3: 빌드 및 Claude Code에 연결

# 빌드
npm run build

# Claude Code에 등록
claude mcp add todo-manager -- node /절대/경로/todo-mcp-ts/build/index.js

Claude Code에서 MCP 서버 관리하기

서버 등록 (3가지 범위)

# 현재 프로젝트에서만 나만 사용 (기본값)
claude mcp add my-server -- node /path/to/server.js

# 모든 프로젝트에서 나만 사용
claude mcp add my-server -s user -- node /path/to/server.js

# 프로젝트 팀 전체가 공유 (.mcp.json에 저장)
claude mcp add my-server -s project -- node /path/to/server.js

범위 저장 위치 공유 범위

local (기본) 프로젝트별 사용자 설정 나만 사용, 현재 프로젝트만
user 전역 사용자 설정 나만 사용, 모든 프로젝트
project .mcp.json (Git에 포함) 팀 전체 공유

서버 상태 확인 및 관리

# 등록된 서버 목록 확인
claude mcp list

# 특정 서버 상세 정보
claude mcp get todo-manager

# 서버 제거
claude mcp remove todo-manager

# Claude Code 세션 안에서 상태 확인
/mcp

환경 변수가 필요한 경우

API 키 등 환경 변수를 전달하려면 --env 플래그를 사용합니다:

claude mcp add github-server -e GITHUB_TOKEN=ghp_xxxx -- node /path/to/github-mcp.js

JSON으로 직접 추가

이미 설정 JSON이 있다면 add-json 명령을 사용합니다:

claude mcp add-json my-server '{
  "type": "stdio",
  "command": "node",
  "args": ["/path/to/server.js"],
  "env": {"API_KEY": "abc123"}
}'

이미 만들어진 인기 MCP 서버 활용하기

직접 만들기 전에, 이미 만들어진 MCP 서버를 먼저 연결해 써보는 것도 좋습니다.

GitHub MCP 서버

claude mcp add -s user --transport http github \
  https://api.githubcopilot.com/mcp \
  -H "Authorization: Bearer YOUR_GITHUB_PAT"

Playwright (브라우저 자동화)

claude mcp add playwright -- npx @playwright/mcp@latest

Context7 (라이브러리 최신 문서 조회)

claude mcp add context7 -- npx -y @upstash/context7-mcp@latest

Sequential Thinking (체계적 사고)

claude mcp add sequential-thinking -- npx -y @modelcontextprotocol/server-sequential-thinking

자주 하는 실수와 해결법

1. STDIO 서버에서 stdout 사용

# ❌ 절대 하면 안 됨 — JSON-RPC 메시지가 깨집니다
print("서버 시작됨")

# ✅ stderr로 보내거나 logging 사용
import logging
logging.info("서버 시작됨")
// ❌ 절대 하면 안 됨
console.log("서버 시작됨");

// ✅ stderr는 안전합니다
console.error("서버 시작됨");

STDIO 기반 MCP 서버에서 print()나 console.log()를 사용하면 stdout으로 출력되어 JSON-RPC 메시지와 섞여 서버가 작동하지 않습니다.

2. 상대 경로 사용

# ❌ 상대 경로 → Claude Code가 실행 위치를 모름
claude mcp add my-server -- node ./server.js

# ✅ 절대 경로 사용
claude mcp add my-server -- node /Users/me/projects/my-mcp/build/index.js

3. Windows에서 npx 실행 오류

# ❌ Windows에서 직접 npx 실행 → "Connection closed" 에러
claude mcp add my-server -- npx -y @some/package

# ✅ cmd /c 래퍼 사용
claude mcp add my-server -- cmd /c npx -y @some/package

4. 도구 설명을 빈약하게 작성

# ❌ Claude가 언제 이 도구를 쓸지 판단하기 어려움
@mcp.tool()
def do_thing(x: str) -> str:
    """작업을 수행합니다."""

# ✅ 구체적이고 명확한 설명
@mcp.tool()
def add_todo(title: str, priority: str = "medium") -> str:
    """할 일 목록에 새 항목을 추가합니다.

    Args:
        title: 할 일 제목 (예: "주간 보고서 작성", "코드 리뷰")
        priority: 우선순위 — "high"(긴급), "medium"(보통), "low"(여유)
    """

Claude는 도구의 설명과 파라미터 정보를 읽고 적절한 도구를 선택합니다. 설명이 명확할수록 Claude가 정확하게 도구를 활용합니다.


MCP 서버 개발 흐름 요약

1. 환경 준비
   └─ Python: uv + mcp[cli]
   └─ TypeScript: npm + @modelcontextprotocol/sdk + zod

2. 서버 코드 작성
   └─ FastMCP 인스턴스 생성 (Python) 또는 McpServer (TS)
   └─ @mcp.tool() 또는 server.registerTool()로 도구 정의
   └─ 함수 구현 + 명확한 독스트링/설명 작성
   └─ mcp.run(transport="stdio")로 실행

3. Claude Code에 연결
   └─ claude mcp add <이름> -- <실행 명령>

4. 테스트
   └─ Claude Code에서 자연어로 요청
   └─ /mcp 명령으로 서버 상태 확인

5. 문제 해결
   └─ claude doctor로 설치 진단
   └─ stdout 출력 제거 확인
   └─ 절대 경로 사용 확인

마무리

MCP 서버를 만드는 것은 생각보다 간단합니다. Python이라면 @mcp.tool() 데코레이터 하나로 함수를 도구로 노출할 수 있고, TypeScript라면 server.registerTool()로 같은 일을 합니다. 핵심은 함수의 설명을 명확하게 작성하여 Claude가 도구를 잘 이해하도록 하는 것입니다.

이 가이드의 Todo 예제를 기반으로, 자신만의 API 연동, 데이터베이스 조회, 파일 관리 등 다양한 MCP 서버를 만들어보세요.

참고 자료: