MCP 서버 개발
MCP 서버는 AI 클라이언트에게 도구, 리소스, 프롬프트를 제공하는 서비스입니다. Python과 TypeScript SDK를 사용하여 강력하고 재사용 가능한 MCP 서버를 개발하는 방법을 학습합니다.
- Python:
pip install mcp또는uv add mcp - TypeScript:
npm install @modelcontextprotocol/sdk - 서버는 Tools, Resources, Prompts 제공 가능
- stdio 또는 HTTP SSE 전송 지원
Python 서버 개발
설치
# pip 사용
pip install mcp
# uv 사용 (권장)
uv pip install mcp
# 또는 프로젝트 의존성에 추가
uv add mcp
기본 서버 구조
#!/usr/bin/env python3
"""
간단한 MCP 서버 예제
"""
# 권장: FastMCP 사용 (공식 고수준 API)
# from mcp.server.fastmcp import FastMCP
# mcp = FastMCP("my-mcp-server")
#
# 아래는 저수준 SDK 방식입니다 (고급 사용 사례용)
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
# 서버 인스턴스 생성
server = Server("my-mcp-server")
# 도구 등록
@server.tool()
async def hello(name: str = "World") -> str:
"""
인사 메시지를 반환합니다.
Args:
name: 인사할 이름
Returns:
인사 메시지
"""
return f"Hello, {name}!"
# 메인 함수
async def main():
# stdio 전송으로 서버 실행
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
도구 (Tools) 구현
1. 간단한 도구
@server.tool()
async def add(a: float, b: float) -> float:
"""두 숫자를 더합니다."""
return a + b
@server.tool()
async def multiply(a: float, b: float) -> float:
"""두 숫자를 곱합니다."""
return a * b
2. 복잡한 입력/출력
from typing import List, Dict, Any
from mcp.types import TextContent, ImageContent, EmbeddedResource
@server.tool()
async def search_files(
directory: str,
pattern: str,
max_results: int = 10
) -> List[Dict[str, Any]]:
"""
디렉토리에서 파일을 검색합니다.
Args:
directory: 검색할 디렉토리
pattern: 파일명 패턴 (glob)
max_results: 최대 결과 수
Returns:
파일 정보 리스트
"""
import os
import glob
search_path = os.path.join(directory, pattern)
files = glob.glob(search_path, recursive=True)
results = []
for filepath in files[:max_results]:
stat = os.stat(filepath)
results.append({
"path": filepath,
"size": stat.st_size,
"modified": stat.st_mtime
})
return results
3. 파일 작업 도구
import os
from pathlib import Path
@server.tool()
async def read_file(path: str) -> str:
"""파일 내용을 읽습니다."""
with open(path, 'r', encoding='utf-8') as f:
return f.read()
@server.tool()
async def write_file(path: str, content: str) -> str:
"""파일에 내용을 씁니다."""
# 디렉토리가 없으면 생성
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
return f"파일 작성 완료: {path}"
@server.tool()
async def list_directory(path: str) -> List[Dict[str, Any]]:
"""디렉토리 내용을 나열합니다."""
items = []
for entry in os.listdir(path):
full_path = os.path.join(path, entry)
is_dir = os.path.isdir(full_path)
items.append({
"name": entry,
"path": full_path,
"type": "directory" if is_dir else "file"
})
return items
4. 에러 처리
from mcp.server import McpError
@server.tool()
async def safe_read_file(path: str) -> str:
"""안전하게 파일을 읽습니다."""
# 경로 검증
if ".." in path:
raise McpError(
code=-32602,
message="Invalid path: path traversal not allowed"
)
# 파일 존재 확인
if not os.path.exists(path):
raise McpError(
code=-32001,
message="File not found",
data={"path": path}
)
# 읽기 권한 확인
if not os.access(path, os.R_OK):
raise McpError(
code=-32002,
message="Permission denied",
data={"path": path}
)
try:
with open(path, 'r', encoding='utf-8') as f:
return f.read()
except Exception as e:
raise McpError(
code=-32603,
message=f"Failed to read file: {e}"
)
리소스 (Resources) 구현
from mcp.types import Resource, TextContent
# 1. 리소스 목록 핸들러
@server.list_resources()
async def list_resources() -> List[Resource]:
"""사용 가능한 리소스 목록을 반환합니다."""
return [
Resource(
uri="file:///etc/hosts",
name="System hosts file",
description="시스템 호스트 파일",
mimeType="text/plain"
),
Resource(
uri="config://app",
name="Application config",
description="애플리케이션 설정",
mimeType="application/json"
)
]
# 2. 리소스 읽기 핸들러
@server.read_resource()
async def read_resource(uri: str) -> str:
"""리소스 내용을 읽습니다."""
if uri.startswith("file://"):
path = uri[7:] # "file://" 제거
with open(path, 'r') as f:
return f.read()
elif uri == "config://app":
import json
config = {
"version": "1.0.0",
"debug": False
}
return json.dumps(config, indent=2)
else:
raise McpError(
code=-32602,
message=f"Unknown resource URI: {uri}"
)
동적 리소스
@server.list_resources()
async def list_project_files() -> List[Resource]:
"""프로젝트 파일을 동적으로 리소스로 제공합니다."""
resources = []
project_dir = "/path/to/project"
for root, dirs, files in os.walk(project_dir):
for filename in files:
if filename.endswith(('.py', '.js', '.ts')):
filepath = os.path.join(root, filename)
uri = f"file://{filepath}"
resources.append(Resource(
uri=uri,
name=filename,
description=f"Source file: {filepath}",
mimeType="text/plain"
))
return resources
프롬프트 (Prompts) 구현
from mcp.types import Prompt, PromptMessage, TextContent
# 1. 프롬프트 목록
@server.list_prompts()
async def list_prompts() -> List[Prompt]:
"""사용 가능한 프롬프트 목록을 반환합니다."""
return [
Prompt(
name="code-review",
description="코드 리뷰 프롬프트",
arguments=[
{
"name": "language",
"description": "프로그래밍 언어",
"required": True
},
{
"name": "code",
"description": "리뷰할 코드",
"required": True
}
]
),
Prompt(
name="translate",
description="번역 프롬프트",
arguments=[
{
"name": "text",
"description": "번역할 텍스트",
"required": True
},
{
"name": "target_lang",
"description": "목표 언어",
"required": True
}
]
)
]
# 2. 프롬프트 가져오기
@server.get_prompt()
async def get_prompt(name: str, arguments: Dict[str, str]) -> List[PromptMessage]:
"""프롬프트를 생성하여 반환합니다."""
if name == "code-review":
language = arguments.get("language")
code = arguments.get("code")
prompt_text = f"""다음 {language} 코드를 리뷰해주세요:
```{language}
{code}
```
다음 관점에서 검토해주세요:
1. 코드 품질 및 가독성
2. 성능 최적화 가능성
3. 보안 취약점
4. 베스트 프랙티스 준수 여부
5. 개선 제안
각 항목에 대해 구체적인 피드백을 제공해주세요."""
return [
PromptMessage(
role="user",
content=TextContent(type="text", text=prompt_text)
)
]
elif name == "translate":
text = arguments.get("text")
target_lang = arguments.get("target_lang")
prompt_text = f"""다음 텍스트를 {target_lang}로 번역해주세요:
{text}
번역 시 다음을 고려해주세요:
- 자연스러운 표현 사용
- 문화적 맥락 반영
- 전문 용어의 정확한 번역"""
return [
PromptMessage(
role="user",
content=TextContent(type="text", text=prompt_text)
)
]
else:
raise McpError(
code=-32602,
message=f"Unknown prompt: {name}"
)
완전한 Python 서버 예제
#!/usr/bin/env python3
"""
완전한 MCP 파일시스템 서버
"""
# 권장: FastMCP 사용 (공식 고수준 API)
# from mcp.server.fastmcp import FastMCP
# mcp = FastMCP("filesystem-server")
#
# 아래는 저수준 SDK 방식입니다 (고급 사용 사례용)
import os
import json
from typing import List, Dict, Any
from pathlib import Path
from mcp.server import Server, McpError
from mcp.server.stdio import stdio_server
from mcp.types import Resource, Prompt, PromptMessage, TextContent
# 서버 생성
server = Server("filesystem-server")
# 허용된 디렉토리 (보안)
ALLOWED_DIRS = [
str(Path.home() / "Documents"),
str(Path.home() / "Projects")
]
def is_path_allowed(path: str) -> bool:
"""경로가 허용된 디렉토리 내에 있는지 확인합니다."""
abs_path = os.path.abspath(path)
return any(abs_path.startswith(allowed) for allowed in ALLOWED_DIRS)
# ===== 도구 =====
@server.tool()
async def read_file(path: str) -> str:
"""파일 내용을 읽습니다."""
if not is_path_allowed(path):
raise McpError(code=-32602, message="Path not allowed")
try:
with open(path, 'r', encoding='utf-8') as f:
return f.read()
except FileNotFoundError:
raise McpError(code=-32001, message="File not found")
except Exception as e:
raise McpError(code=-32603, message=str(e))
@server.tool()
async def write_file(path: str, content: str) -> str:
"""파일에 내용을 씁니다."""
if not is_path_allowed(path):
raise McpError(code=-32602, message="Path not allowed")
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
return f"Successfully wrote {len(content)} bytes to {path}"
except Exception as e:
raise McpError(code=-32603, message=str(e))
@server.tool()
async def list_directory(path: str) -> List[Dict[str, Any]]:
"""디렉토리 내용을 나열합니다."""
if not is_path_allowed(path):
raise McpError(code=-32602, message="Path not allowed")
try:
items = []
for entry in os.listdir(path):
full_path = os.path.join(path, entry)
stat = os.stat(full_path)
items.append({
"name": entry,
"path": full_path,
"type": "directory" if os.path.isdir(full_path) else "file",
"size": stat.st_size,
"modified": stat.st_mtime
})
return items
except Exception as e:
raise McpError(code=-32603, message=str(e))
@server.tool()
async def search_files(
directory: str,
pattern: str,
max_results: int = 50
) -> List[str]:
"""파일을 검색합니다."""
if not is_path_allowed(directory):
raise McpError(code=-32602, message="Path not allowed")
import glob
search_path = os.path.join(directory, "**", pattern)
results = glob.glob(search_path, recursive=True)
return results[:max_results]
# ===== 리소스 =====
@server.list_resources()
async def list_resources() -> List[Resource]:
"""허용된 디렉토리를 리소스로 나열합니다."""
resources = []
for dir_path in ALLOWED_DIRS:
if os.path.exists(dir_path):
resources.append(Resource(
uri=f"file://{dir_path}",
name=os.path.basename(dir_path),
description=f"Directory: {dir_path}",
mimeType="text/directory"
))
return resources
@server.read_resource()
async def read_resource(uri: str) -> str:
"""리소스를 읽습니다."""
if not uri.startswith("file://"):
raise McpError(code=-32602, message="Only file:// URIs supported")
path = uri[7:]
if not is_path_allowed(path):
raise McpError(code=-32602, message="Path not allowed")
if os.path.isdir(path):
# 디렉토리 내용을 JSON으로 반환
items = await list_directory(path)
return json.dumps(items, indent=2)
else:
# 파일 내용 반환
return await read_file(path)
# ===== 메인 =====
async def main():
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
TypeScript 서버 개발
설치 및 프로젝트 설정
# 프로젝트 초기화
mkdir my-mcp-server
cd my-mcp-server
npm init -y
# 의존성 설치
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node
# TypeScript 설정
npx tsc --init
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
기본 TypeScript 서버
#!/usr/bin/env node
// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool
} from "@modelcontextprotocol/sdk/types.js";
// 서버 생성
const server = new Server(
{
name: "my-mcp-server",
version: "1.0.0"
},
{
capabilities: {
tools: {}
}
}
);
// 도구 목록
const TOOLS: Tool[] = [
{
name: "hello",
description: "인사 메시지를 반환합니다",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "인사할 이름"
}
},
required: []
}
}
];
// 도구 목록 핸들러
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOLS
}));
// 도구 호출 핸들러
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "hello") {
const userName = (args as { name?: string }).name || "World";
return {
content: [
{
type: "text",
text: `Hello, ${userName}!`
}
]
};
}
throw new Error(`Unknown tool: ${name}`);
});
// 서버 실행
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);
TypeScript 도구 구현
import * as fs from "fs/promises";
import * as path from "path";
// 도구 정의
const TOOLS: Tool[] = [
{
name: "read_file",
description: "파일 내용을 읽습니다",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "파일 경로"
}
},
required: ["path"]
}
},
{
name: "write_file",
description: "파일에 내용을 씁니다",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "파일 경로"
},
content: {
type: "string",
description: "파일 내용"
}
},
required: ["path", "content"]
}
},
{
name: "list_directory",
description: "디렉토리 내용을 나열합니다",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "디렉토리 경로"
}
},
required: ["path"]
}
}
];
// 도구 구현
async function readFile(filePath: string): Promise<string> {
const content = await fs.readFile(filePath, "utf-8");
return content;
}
async function writeFile(filePath: string, content: string): Promise<string> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf-8");
return `Successfully wrote ${content.length} bytes to ${filePath}`;
}
async function listDirectory(dirPath: string) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const items = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(dirPath, entry.name);
const stats = await fs.stat(fullPath);
return {
name: entry.name,
path: fullPath,
type: entry.isDirectory() ? "directory" : "file",
size: stats.size,
modified: stats.mtime
};
})
);
return items;
}
// 도구 호출 핸들러
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "read_file": {
const { path } = args as { path: string };
const content = await readFile(path);
return {
content: [{ type: "text", text: content }]
};
}
case "write_file": {
const { path, content } = args as { path: string; content: string };
const result = await writeFile(path, content);
return {
content: [{ type: "text", text: result }]
};
}
case "list_directory": {
const { path } = args as { path: string };
const items = await listDirectory(path);
return {
content: [{ type: "text", text: JSON.stringify(items, null, 2) }]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error: ${errorMessage}` }],
isError: true
};
}
});
TypeScript 리소스 구현
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
Resource
} from "@modelcontextprotocol/sdk/types.js";
// 리소스 목록 핸들러
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const resources: Resource[] = [
{
uri: "file:///etc/hosts",
name: "System hosts",
description: "시스템 호스트 파일",
mimeType: "text/plain"
},
{
uri: "config://app",
name: "App config",
description: "애플리케이션 설정",
mimeType: "application/json"
}
];
return { resources };
});
// 리소스 읽기 핸들러
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri.startsWith("file://")) {
const filePath = uri.slice(7);
const content = await fs.readFile(filePath, "utf-8");
return {
contents: [
{
uri,
mimeType: "text/plain",
text: content
}
]
};
}
if (uri === "config://app") {
const config = {
version: "1.0.0",
debug: false
};
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(config, null, 2)
}
]
};
}
throw new Error(`Unknown resource: ${uri}`);
});
완전한 TypeScript 서버 예제
#!/usr/bin/env node
// src/index.ts - 완전한 파일시스템 MCP 서버
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
Tool,
Resource
} from "@modelcontextprotocol/sdk/types.js";
import * as fs from "fs/promises";
import * as path from "path";
import { glob } from "glob";
// 허용된 디렉토리
const ALLOWED_DIRS = [
path.join(process.env.HOME || "", "Documents"),
path.join(process.env.HOME || "", "Projects")
];
function isPathAllowed(filePath: string): boolean {
const absolute = path.resolve(filePath);
return ALLOWED_DIRS.some((dir) => absolute.startsWith(dir));
}
// 서버 생성
const server = new Server(
{
name: "filesystem-server",
version: "1.0.0"
},
{
capabilities: {
tools: {},
resources: {}
}
}
);
// 도구 정의
const TOOLS: Tool[] = [
{
name: "read_file",
description: "파일 내용을 읽습니다",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "파일 경로" }
},
required: ["path"]
}
},
{
name: "write_file",
description: "파일에 내용을 씁니다",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "파일 경로" },
content: { type: "string", description: "파일 내용" }
},
required: ["path", "content"]
}
},
{
name: "list_directory",
description: "디렉토리 내용을 나열합니다",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "디렉토리 경로" }
},
required: ["path"]
}
},
{
name: "search_files",
description: "파일을 검색합니다",
inputSchema: {
type: "object",
properties: {
directory: { type: "string", description: "검색 디렉토리" },
pattern: { type: "string", description: "파일명 패턴 (glob)" }
},
required: ["directory", "pattern"]
}
}
];
// 핸들러 등록
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "read_file": {
const { path: filePath } = args as { path: string };
if (!isPathAllowed(filePath)) {
throw new Error("Path not allowed");
}
const content = await fs.readFile(filePath, "utf-8");
return { content: [{ type: "text", text: content }] };
}
case "write_file": {
const { path: filePath, content } = args as { path: string; content: string };
if (!isPathAllowed(filePath)) {
throw new Error("Path not allowed");
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf-8");
return { content: [{ type: "text", text: `Wrote to ${filePath}` }] };
}
case "list_directory": {
const { path: dirPath } = args as { path: string };
if (!isPathAllowed(dirPath)) {
throw new Error("Path not allowed");
}
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const items = entries.map((e) => ({
name: e.name,
type: e.isDirectory() ? "directory" : "file"
}));
return { content: [{ type: "text", text: JSON.stringify(items, null, 2) }] };
}
case "search_files": {
const { directory, pattern } = args as { directory: string; pattern: string };
if (!isPathAllowed(directory)) {
throw new Error("Path not allowed");
}
const files = await glob(pattern, { cwd: directory });
return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] };
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${error}` }],
isError: true
};
}
});
// 리소스 핸들러
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const resources: Resource[] = ALLOWED_DIRS
.filter((dir) => fs.access(dir).then(() => true).catch(() => false))
.map((dir) => ({
uri: `file://${dir}`,
name: path.basename(dir),
description: `Directory: ${dir}`,
mimeType: "text/directory"
}));
return { resources };
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (!uri.startsWith("file://")) {
throw new Error("Only file:// URIs supported");
}
const filePath = uri.slice(7);
if (!isPathAllowed(filePath)) {
throw new Error("Path not allowed");
}
const content = await fs.readFile(filePath, "utf-8");
return {
contents: [{
uri,
mimeType: "text/plain",
text: content
}]
};
});
// 서버 실행
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);
패키징 및 배포
Python 패키징
pyproject.toml
[project]
name = "mcp-server-filesystem"
version = "1.0.0"
description = "MCP filesystem server"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10"
dependencies = [
"mcp>=0.1.0"
]
[project.scripts]
mcp-server-filesystem = "mcp_server_filesystem:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
설치 및 실행
# 개발 모드 설치
uv pip install -e .
# 또는 pip
pip install -e .
# 서버 실행
mcp-server-filesystem
TypeScript 패키징
package.json
{
"name": "@myorg/mcp-server-filesystem",
"version": "1.0.0",
"description": "MCP filesystem server",
"type": "module",
"bin": {
"mcp-server-filesystem": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"prepare": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"@types/node": "^20.0.0"
}
}
빌드 및 실행
# 빌드
npm run build
# 글로벌 설치
npm install -g .
# 서버 실행
mcp-server-filesystem
배포
# Python (PyPI)
uv build
uv publish
# TypeScript (npm)
npm publish
테스트
수동 테스트
# MCP Inspector 사용
npx @modelcontextprotocol/inspector python server.py
# 또는 TypeScript 서버
npx @modelcontextprotocol/inspector node dist/index.js
브라우저에서 http://localhost:5173을 열어 서버를 테스트할 수 있습니다.
자동화 테스트 (Python)
import pytest
from mcp.client import Client
from mcp.client.stdio import StdioClientTransport
@pytest.mark.asyncio
async def test_server():
client = Client({"name": "test", "version": "1.0.0"}, {"capabilities": {"tools": {}}})
transport = StdioClientTransport({
"command": "python",
"args": ["server.py"]
})
await client.connect(transport)
# 도구 목록 테스트
tools = await client.list_tools()
assert len(tools) > 0
# 도구 호출 테스트
result = await client.call_tool("hello", {"name": "Test"})
assert "Hello, Test!" in result
await client.close()
모범 사례
- 보안: 경로 검증, 권한 확인, 입력 검증 필수
- 에러 처리: 명확한 에러 메시지와 적절한 에러 코드 사용
- 문서화: 도구 설명과 입력 스키마를 상세히 작성
- 성능: 비동기 작업 사용, 대용량 데이터 스트리밍 고려
- 로깅: 디버깅을 위한 적절한 로깅 구현
- 테스트: 자동화 테스트 작성
보안 고려사항
| 위험 | 대응 |
|---|---|
| 경로 탐색 (Path Traversal) | 허용 목록 사용, .. 검증 |
| 명령 주입 | 입력 검증, 이스케이핑 |
| 리소스 소진 | 크기 제한, 타임아웃 설정 |
| 민감 정보 노출 | 환경 변수, 시크릿 파일 제외 |
다음 단계
MCP 서버 만들기 완전 가이드
이 섹션에서는 MCP 서버를 처음부터 끝까지 만드는 과정을 단계별로 상세히 안내합니다. Python과 TypeScript 두 가지 언어로 완전한 서버를 구축하고, FastMCP를 활용한 빠른 개발 방법도 다룹니다.
처음부터 만드는 MCP 서버 (Python)
Python으로 MCP 서버를 처음부터 만드는 10단계 과정입니다. 각 단계마다 실행 가능한 코드와 함께 설명합니다.
1단계: 프로젝트 구조 설정
먼저 프로젝트 디렉토리 구조를 만듭니다. 깔끔한 구조는 유지보수와 배포에 필수적입니다.
# 프로젝트 디렉토리 생성
mkdir my-mcp-server
cd my-mcp-server
# 기본 구조 생성
mkdir -p src/my_mcp_server
mkdir -p tests
touch src/my_mcp_server/__init__.py
touch src/my_mcp_server/server.py
touch src/my_mcp_server/tools.py
touch src/my_mcp_server/resources.py
touch tests/__init__.py
touch tests/test_server.py
최종 디렉토리 구조는 다음과 같습니다:
my-mcp-server/
├── pyproject.toml
├── README.md
├── src/
│ └── my_mcp_server/
│ ├── __init__.py
│ ├── server.py # 메인 서버 진입점
│ ├── tools.py # 도구 정의
│ └── resources.py # 리소스 정의
└── tests/
├── __init__.py
└── test_server.py
pyproject.toml 파일을 작성합니다. 이 파일은 프로젝트 메타데이터와 의존성을 관리합니다:
[project]
name = "my-mcp-server"
version = "0.1.0"
description = "내 첫 MCP 서버"
requires-python = ">=3.10"
dependencies = [
"mcp>=1.0.0",
"httpx>=0.25.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project.scripts]
my-mcp-server = "my_mcp_server.server:main"
2단계: uv로 의존성 설치
uv는 Python의 빠른 패키지 관리자입니다. pip 대비 10~100배 빠른 설치 속도를 제공합니다.
# uv 설치 (아직 없는 경우)
curl -LsSf https://astral.sh/uv/install.sh | sh
# 가상 환경 생성 및 의존성 설치
uv venv
source .venv/bin/activate
# pyproject.toml 기반 의존성 설치
uv pip install -e ".[dev]"
# 또는 uv 프로젝트 매니저로 직접 추가
uv add mcp
uv add httpx
uv add --dev pytest pytest-asyncio
3단계: 서버 뼈대 코드 작성
MCP 서버의 핵심 뼈대를 작성합니다. Server 클래스를 생성하고 stdio 전송을 설정합니다.
"""MCP 서버 패키지"""
__version__ = "0.1.0"
"""MCP 서버 메인 모듈"""
import asyncio
import logging
from mcp.server import Server
from mcp.server.stdio import stdio_server
# 로거 설정
logger = logging.getLogger(__name__)
# 서버 인스턴스 생성
server = Server("my-mcp-server")
# 도구와 리소스를 별도 모듈에서 임포트하여 등록
from my_mcp_server import tools # noqa: E402, F401
from my_mcp_server import resources # noqa: E402, F401
async def run_server():
"""서버를 실행합니다."""
logger.info("MCP 서버 시작 중...")
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
def main():
"""CLI 진입점"""
logging.basicConfig(level=logging.INFO)
asyncio.run(run_server())
if __name__ == "__main__":
main()
4단계: 도구(Tool) 등록 - inputSchema 상세 작성법
도구는 MCP 서버의 핵심 기능입니다. AI 클라이언트가 호출할 수 있는 함수를 정의합니다. 각 도구에는 이름, 설명, 입력 스키마(inputSchema)가 필요합니다.
"""MCP 서버 도구 정의"""
import json
import httpx
from typing import Any
from mcp.types import Tool, TextContent
from my_mcp_server.server import server
# --- 도구 목록 핸들러 ---
@server.list_tools()
async def list_tools() -> list[Tool]:
"""사용 가능한 도구 목록을 반환합니다."""
return [
Tool(
name="get_weather",
description="지정된 도시의 현재 날씨 정보를 조회합니다. "
"도시명은 한글 또는 영문으로 입력할 수 있습니다.",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "날씨를 조회할 도시명 (예: 서울, Tokyo, New York)"
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "온도 단위 (기본값: celsius)",
"default": "celsius"
}
},
"required": ["city"]
}
),
Tool(
name="calculate",
description="수학 계산을 수행합니다. 사칙연산과 거듭제곱을 지원합니다.",
inputSchema={
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["add", "subtract", "multiply", "divide", "power"],
"description": "수행할 연산 종류"
},
"a": {
"type": "number",
"description": "첫 번째 피연산자"
},
"b": {
"type": "number",
"description": "두 번째 피연산자"
}
},
"required": ["operation", "a", "b"]
}
),
Tool(
name="search_web",
description="웹 검색을 수행하여 최신 정보를 조회합니다.",
inputSchema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "검색 쿼리"
},
"max_results": {
"type": "integer",
"description": "최대 결과 수 (1~20)",
"minimum": 1,
"maximum": 20,
"default": 5
},
"language": {
"type": "string",
"description": "검색 결과 언어 코드 (예: ko, en, ja)",
"default": "ko"
}
},
"required": ["query"]
}
)
]
# --- 도구 실행 핸들러 ---
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
"""도구 호출을 처리합니다."""
if name == "get_weather":
city = arguments["city"]
units = arguments.get("units", "celsius")
# 실제로는 날씨 API를 호출합니다
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.weatherapi.com/v1/current.json",
params={"key": "YOUR_API_KEY", "q": city}
)
data = response.json()
return [TextContent(
type="text",
text=json.dumps(data, ensure_ascii=False, indent=2)
)]
elif name == "calculate":
op = arguments["operation"]
a = arguments["a"]
b = arguments["b"]
operations = {
"add": lambda: a + b,
"subtract": lambda: a - b,
"multiply": lambda: a * b,
"divide": lambda: a / b if b != 0 else "오류: 0으로 나눌 수 없습니다",
"power": lambda: a ** b,
}
result = operations[op]()
return [TextContent(type="text", text=str(result))]
else:
raise ValueError(f"알 수 없는 도구: {name}")
5단계: 리소스(Resource) 등록 - URI 체계 설명
리소스는 AI 클라이언트에게 데이터를 제공하는 방법입니다. 각 리소스는 고유한 URI로 식별됩니다.
file:///path/to/file- 파일 시스템 리소스config://app-name- 애플리케이션 설정db://database/table- 데이터베이스 리소스api://service/endpoint- API 엔드포인트 데이터
"""MCP 서버 리소스 정의"""
import json
import os
from datetime import datetime
from mcp.types import Resource, TextContent
from my_mcp_server.server import server
# --- 리소스 목록 핸들러 ---
@server.list_resources()
async def list_resources() -> list[Resource]:
"""사용 가능한 리소스 목록을 반환합니다."""
return [
Resource(
uri="config://app",
name="앱 설정",
description="현재 애플리케이션의 설정 정보",
mimeType="application/json"
),
Resource(
uri="status://server",
name="서버 상태",
description="서버의 현재 상태 정보",
mimeType="application/json"
),
Resource(
uri="file:///var/log/app.log",
name="앱 로그",
description="애플리케이션 최근 로그",
mimeType="text/plain"
)
]
# --- 리소스 읽기 핸들러 ---
@server.read_resource()
async def read_resource(uri: str) -> str:
"""리소스 내용을 읽습니다."""
if uri == "config://app":
config = {
"app_name": "My MCP Server",
"version": "0.1.0",
"debug": os.environ.get("DEBUG", "false"),
"log_level": os.environ.get("LOG_LEVEL", "INFO"),
}
return json.dumps(config, indent=2, ensure_ascii=False)
elif uri == "status://server":
status = {
"status": "running",
"uptime": "정상",
"timestamp": datetime.now().isoformat(),
}
return json.dumps(status, indent=2, ensure_ascii=False)
elif uri.startswith("file://"):
path = uri[7:]
if not os.path.exists(path):
return f"파일을 찾을 수 없습니다: {path}"
with open(path, "r", encoding="utf-8") as f:
return f.read()
raise ValueError(f"알 수 없는 리소스: {uri}")
6단계: 프롬프트(Prompt) 등록 - 템플릿 패턴
프롬프트는 재사용 가능한 프롬프트 템플릿을 정의합니다. AI 클라이언트가 특정 작업에 최적화된 프롬프트를 요청할 수 있습니다.
"""MCP 서버 프롬프트 정의"""
from mcp.types import Prompt, PromptArgument, PromptMessage, TextContent
from my_mcp_server.server import server
@server.list_prompts()
async def list_prompts() -> list[Prompt]:
"""사용 가능한 프롬프트 템플릿 목록"""
return [
Prompt(
name="code-review",
description="코드 리뷰를 수행하는 프롬프트",
arguments=[
PromptArgument(
name="code",
description="리뷰할 코드",
required=True
),
PromptArgument(
name="language",
description="프로그래밍 언어 (예: python, javascript)",
required=False
)
]
),
Prompt(
name="explain-error",
description="에러 메시지를 분석하고 해결책을 제안합니다",
arguments=[
PromptArgument(
name="error",
description="에러 메시지 또는 스택 트레이스",
required=True
),
PromptArgument(
name="context",
description="에러가 발생한 상황 설명",
required=False
)
]
)
]
@server.get_prompt()
async def get_prompt(name: str, arguments: dict | None = None) -> list[PromptMessage]:
"""프롬프트 템플릿을 렌더링합니다."""
args = arguments or {}
if name == "code-review":
code = args.get("code", "")
language = args.get("language", "알 수 없음")
return [
PromptMessage(
role="user",
content=TextContent(
type="text",
text=f"""다음 {language} 코드를 리뷰해주세요.
검토 항목:
1. 코드 품질 및 가독성
2. 잠재적 버그나 에러
3. 성능 개선 가능성
4. 보안 취약점
5. 모범 사례 준수 여부
코드:
```
{code}
```"""
)
)
]
elif name == "explain-error":
error = args.get("error", "")
context = args.get("context", "추가 컨텍스트 없음")
return [
PromptMessage(
role="user",
content=TextContent(
type="text",
text=f"""다음 에러를 분석하고 해결책을 제안해주세요.
에러 메시지:
```
{error}
```
상황: {context}
다음을 포함하여 답변해주세요:
1. 에러의 원인
2. 구체적인 해결 방법
3. 향후 예방 방법"""
)
)
]
raise ValueError(f"알 수 없는 프롬프트: {name}")
7단계: 에러 처리 패턴
MCP 서버에서 발생할 수 있는 다양한 에러를 체계적으로 처리하는 패턴입니다.
import logging
import traceback
from functools import wraps
from mcp.types import TextContent, INTERNAL_ERROR, INVALID_PARAMS
logger = logging.getLogger(__name__)
# MCP 표준 에러 코드
ERROR_CODES = {
"PARSE_ERROR": -32700,
"INVALID_REQUEST": -32600,
"METHOD_NOT_FOUND": -32601,
"INVALID_PARAMS": -32602,
"INTERNAL_ERROR": -32603,
}
def safe_tool(func):
"""도구 함수에 에러 처리를 추가하는 데코레이터"""
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except ValueError as e:
logger.warning(f"잘못된 입력: {e}")
return [TextContent(
type="text",
text=f"입력 오류: {e}"
)]
except FileNotFoundError as e:
logger.warning(f"파일 없음: {e}")
return [TextContent(
type="text",
text=f"파일을 찾을 수 없습니다: {e}"
)]
except PermissionError as e:
logger.error(f"권한 오류: {e}")
return [TextContent(
type="text",
text=f"권한이 없습니다: {e}"
)]
except Exception as e:
logger.error(f"예기치 못한 오류: {traceback.format_exc()}")
return [TextContent(
type="text",
text=f"내부 오류가 발생했습니다: {type(e).__name__}: {e}"
)]
return wrapper
8단계: 로깅 구현
MCP 서버의 디버깅과 모니터링을 위한 로깅 설정입니다. 중요: MCP 서버는 stdout을 JSON-RPC 통신에 사용하므로, 로그는 반드시 stderr로 출력해야 합니다.
print() 함수로 stdout에 출력하면 MCP 프로토콜 통신이 깨집니다. 반드시 logging 모듈을 사용하고 stderr 핸들러를 설정하세요.
import logging
import sys
def setup_logging(level: str = "INFO") -> logging.Logger:
"""MCP 서버용 로깅을 설정합니다.
중요: MCP는 stdout을 JSON-RPC에 사용하므로
로그는 반드시 stderr로 출력합니다.
"""
logger = logging.getLogger("my-mcp-server")
logger.setLevel(getattr(logging, level.upper()))
# stderr 핸들러 (stdout 사용 금지!)
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(getattr(logging, level.upper()))
# 포맷 설정
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
# 사용 예시
logger = setup_logging("DEBUG")
logger.debug("디버그 메시지")
logger.info("서버가 시작되었습니다")
logger.warning("경고: 설정 파일이 없습니다")
logger.error("에러 발생")
9단계: 서버 실행 및 테스트 (MCP Inspector)
작성한 서버를 실행하고 MCP Inspector로 테스트합니다.
# 직접 실행
python -m my_mcp_server.server
# 또는 pyproject.toml에 정의한 스크립트로 실행
my-mcp-server
# MCP Inspector로 테스트 (웹 UI 제공)
npx @modelcontextprotocol/inspector python -m my_mcp_server.server
# MCP Inspector가 브라우저를 열고 대화형 테스트 UI를 제공합니다
# 기본 주소: http://localhost:5173
10단계: Claude Desktop에 연결하여 테스트
개발한 MCP 서버를 Claude Desktop에 등록하여 실제 AI와 함께 테스트합니다.
{
"mcpServers": {
"my-mcp-server": {
"command": "python",
"args": ["-m", "my_mcp_server.server"],
"cwd": "/path/to/my-mcp-server",
"env": {
"DEBUG": "true",
"LOG_LEVEL": "DEBUG"
}
}
}
}
설정 파일 위치:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
"uv"로, args를 ["run", "my-mcp-server"]로 설정하면 가상 환경을 자동으로 활성화합니다.
처음부터 만드는 MCP 서버 (TypeScript)
TypeScript로 MCP 서버를 만드는 단계별 가이드입니다. 타입 안전성과 IDE 지원이 뛰어나며, Node.js 생태계를 활용할 수 있습니다.
1단계: npm init 및 package.json 설정
# 프로젝트 생성
mkdir my-mcp-server-ts
cd my-mcp-server-ts
npm init -y
# 디렉토리 구조 생성
mkdir -p src
mkdir -p tests
{
"name": "my-mcp-server-ts",
"version": "0.1.0",
"description": "TypeScript MCP 서버",
"type": "module",
"main": "dist/index.js",
"bin": {
"my-mcp-server-ts": "dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
"test": "vitest"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"@types/node": "^20.0.0",
"vitest": "^1.0.0"
}
}
2단계: TypeScript 설정
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
3단계: SDK 설치
# 핵심 의존성 설치
npm install @modelcontextprotocol/sdk
# 개발 의존성 설치
npm install -D typescript @types/node vitest
4단계: 서버 뼈대 (setRequestHandler 패턴)
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// 서버 인스턴스 생성
const server = new Server(
{
name: "my-mcp-server-ts",
version: "0.1.0",
},
{
capabilities: {
tools: {},
resources: {},
},
}
);
// 도구 목록 핸들러
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "get_weather",
description: "지정된 도시의 현재 날씨 정보를 조회합니다",
inputSchema: {
type: "object" as const,
properties: {
city: {
type: "string",
description: "날씨를 조회할 도시명",
},
units: {
type: "string",
enum: ["celsius", "fahrenheit"],
description: "온도 단위",
},
},
required: ["city"],
},
},
{
name: "calculate",
description: "수학 계산을 수행합니다",
inputSchema: {
type: "object" as const,
properties: {
operation: {
type: "string",
enum: ["add", "subtract", "multiply", "divide"],
description: "수행할 연산",
},
a: { type: "number", description: "첫 번째 피연산자" },
b: { type: "number", description: "두 번째 피연산자" },
},
required: ["operation", "a", "b"],
},
},
],
};
});
// 도구 실행 핸들러
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "get_weather": {
const city = args?.city as string;
// 실제로는 날씨 API를 호출합니다
return {
content: [
{
type: "text",
text: `${city}의 현재 날씨: 맑음, 22도`,
},
],
};
}
case "calculate": {
const { operation, a, b } = args as {
operation: string;
a: number;
b: number;
};
let result: number;
switch (operation) {
case "add": result = a + b; break;
case "subtract": result = a - b; break;
case "multiply": result = a * b; break;
case "divide":
if (b === 0) throw new Error("0으로 나눌 수 없습니다");
result = a / b;
break;
default: throw new Error(`알 수 없는 연산: ${operation}`);
}
return {
content: [{ type: "text", text: String(result) }],
};
}
default:
throw new Error(`알 수 없는 도구: ${name}`);
}
});
// 리소스 목록 핸들러
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "config://app",
name: "앱 설정",
description: "현재 애플리케이션 설정",
mimeType: "application/json",
},
],
};
});
// 리소스 읽기 핸들러
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === "config://app") {
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(
{ appName: "My Server", version: "0.1.0", debug: true },
null,
2
),
},
],
};
}
throw new Error(`알 수 없는 리소스: ${uri}`);
});
// 서버 실행
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP 서버가 stdio에서 실행 중입니다");
}
main().catch(console.error);
5단계: 빌드 및 실행
# TypeScript 빌드
npm run build
# 서버 실행
npm start
# MCP Inspector로 테스트
npm run inspect
# Claude Desktop 설정 (claude_desktop_config.json)
# {
# "mcpServers": {
# "my-ts-server": {
# "command": "node",
# "args": ["/path/to/my-mcp-server-ts/dist/index.js"]
# }
# }
# }
FastMCP로 빠르게 만들기
FastMCP는 Python MCP 서버 개발을 획기적으로 단순화하는 고수준 프레임워크입니다. Flask처럼 데코레이터 기반으로 도구, 리소스, 프롬프트를 등록하며, 타입 힌트에서 자동으로 JSON Schema를 생성합니다.
# FastMCP 설치
pip install fastmcp
# 또는
uv add fastmcp
기본 서버 만들기
from fastmcp import FastMCP
# 서버 인스턴스 생성 (이름과 설정)
mcp = FastMCP(
"My Fast Server",
dependencies=["httpx", "beautifulsoup4"]
)
# --- 도구 등록 (데코레이터로 간단하게) ---
@mcp.tool()
def add(a: int, b: int) -> int:
"""두 수를 더합니다.
Args:
a: 첫 번째 숫자
b: 두 번째 숫자
Returns:
두 수의 합
"""
return a + b
@mcp.tool()
def multiply(a: float, b: float) -> float:
"""두 수를 곱합니다."""
return a * b
@mcp.tool()
async def fetch_url(url: str, timeout: int = 30) -> str:
"""URL에서 웹 페이지 내용을 가져옵니다.
Args:
url: 가져올 웹 페이지 URL
timeout: 요청 타임아웃 (초, 기본값: 30)
"""
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=timeout)
return response.text
# --- 리소스 등록 ---
@mcp.resource("config://app")
def get_config() -> str:
"""앱 설정을 반환합니다."""
import json
config = {"name": "My App", "version": "1.0", "debug": True}
return json.dumps(config, indent=2)
@mcp.resource("status://server")
def get_status() -> str:
"""서버 상태를 반환합니다."""
return "서버 정상 운영 중"
# --- 프롬프트 등록 ---
@mcp.prompt()
def review_prompt(code: str) -> str:
"""코드 리뷰 프롬프트를 생성합니다."""
return f"다음 코드를 리뷰해주세요:\n\n```\n{code}\n```"
@mcp.prompt()
def debug_prompt(error: str, context: str = "") -> str:
"""디버깅 프롬프트를 생성합니다."""
msg = f"다음 에러를 분석해주세요:\n\n{error}"
if context:
msg += f"\n\n상황: {context}"
return msg
FastMCP 자동 스키마 생성 원리
FastMCP가 타입 힌트에서 어떻게 inputSchema를 생성하는지 비교합니다:
# FastMCP에서 이 함수를 등록하면:
@mcp.tool()
def search(
query: str,
max_results: int = 10,
include_archived: bool = False
) -> list[str]:
"""데이터를 검색합니다.
Args:
query: 검색 쿼리 문자열
max_results: 최대 결과 수
include_archived: 보관된 항목 포함 여부
"""
...
# FastMCP가 자동 생성하는 inputSchema:
# {
# "type": "object",
# "properties": {
# "query": {
# "type": "string",
# "description": "검색 쿼리 문자열"
# },
# "max_results": {
# "type": "integer",
# "default": 10,
# "description": "최대 결과 수"
# },
# "include_archived": {
# "type": "boolean",
# "default": false,
# "description": "보관된 항목 포함 여부"
# }
# },
# "required": ["query"]
# }
FastMCP로 서버 실행
# 직접 실행 (stdio 모드)
fastmcp run server.py
# MCP Inspector로 테스트
fastmcp dev server.py
# Claude Desktop에 설치
fastmcp install server.py --name "My Fast Server"
저수준 SDK vs FastMCP 비교
| 항목 | 저수준 SDK (mcp) | FastMCP |
|---|---|---|
| inputSchema 작성 | JSON Schema 수동 작성 | 타입 힌트에서 자동 생성 |
| 도구 등록 | list_tools + call_tool 분리 | @mcp.tool() 데코레이터 하나 |
| 리소스 등록 | list_resources + read_resource 분리 | @mcp.resource(uri) 하나 |
| 서버 실행 코드 | asyncio + stdio_server 설정 필요 | fastmcp run 명령어 |
| 코드량 | 많음 (100~200줄) | 적음 (30~50줄) |
| 유연성 | 높음 (완전한 제어) | 보통 (프레임워크 규칙 준수) |
| 학습 난이도 | 중간~높음 | 낮음 |
inputSchema 작성 가이드
inputSchema는 MCP 도구의 입력 매개변수를 정의하는 JSON Schema입니다. AI 모델은 이 스키마를 읽고 올바른 형식의 인자를 생성합니다. 스키마를 명확하고 상세하게 작성할수록 AI의 도구 사용 정확도가 높아집니다.
기본 타입
{
"type": "object",
"properties": {
// 문자열 타입
"name": {
"type": "string",
"description": "사용자 이름"
},
// 숫자 타입 (정수)
"age": {
"type": "integer",
"description": "나이",
"minimum": 0,
"maximum": 150
},
// 숫자 타입 (실수)
"score": {
"type": "number",
"description": "점수 (0.0 ~ 100.0)",
"minimum": 0.0,
"maximum": 100.0
},
// 불리언 타입
"active": {
"type": "boolean",
"description": "활성화 여부",
"default": true
}
},
"required": ["name", "age"]
}
열거형(Enum) 제약
enum을 사용하면 허용되는 값을 제한할 수 있습니다. AI 모델이 정확한 값을 선택하도록 돕습니다.
{
"type": "object",
"properties": {
"language": {
"type": "string",
"enum": ["python", "javascript", "typescript", "rust", "go"],
"description": "프로그래밍 언어"
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "critical"],
"description": "우선순위 수준",
"default": "medium"
},
"format": {
"type": "string",
"enum": ["json", "csv", "xml", "yaml"],
"description": "출력 형식"
}
},
"required": ["language"]
}
중첩 객체
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "검색 쿼리"
},
"filters": {
"type": "object",
"description": "검색 필터 조건",
"properties": {
"date_range": {
"type": "object",
"properties": {
"start": {
"type": "string",
"description": "시작 날짜 (YYYY-MM-DD)"
},
"end": {
"type": "string",
"description": "종료 날짜 (YYYY-MM-DD)"
}
}
},
"category": {
"type": "string",
"description": "카테고리 필터"
},
"min_score": {
"type": "number",
"description": "최소 점수 필터",
"minimum": 0
}
}
},
"options": {
"type": "object",
"description": "검색 옵션",
"properties": {
"page": {
"type": "integer",
"default": 1,
"minimum": 1,
"description": "페이지 번호"
},
"per_page": {
"type": "integer",
"default": 20,
"minimum": 1,
"maximum": 100,
"description": "페이지당 결과 수"
}
}
}
},
"required": ["query"]
}
배열 타입
{
"type": "object",
"properties": {
// 문자열 배열
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "태그 목록",
"minItems": 1,
"maxItems": 10
},
// 객체 배열
"items": {
"type": "array",
"description": "항목 목록",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "항목 이름"
},
"quantity": {
"type": "integer",
"minimum": 1,
"description": "수량"
}
},
"required": ["name", "quantity"]
}
},
// 숫자 배열
"scores": {
"type": "array",
"items": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"description": "점수 배열 (0~100)"
}
},
"required": ["tags"]
}
description 작성 모범 사례
description 필드는 AI 모델이 매개변수를 이해하는 핵심 정보입니다. 명확하고 구체적으로 작성하세요.
| 나쁜 예 | 좋은 예 | 이유 |
|---|---|---|
"path" |
"파일 경로 (절대 경로, 예: /home/user/doc.txt)" |
형식과 예시를 포함 |
"날짜" |
"시작 날짜 (YYYY-MM-DD 형식, 예: 2024-01-15)" |
날짜 형식을 명시 |
"숫자" |
"페이지당 결과 수 (1~100, 기본값: 20)" |
범위와 기본값을 명시 |
"타입" |
"파일 유형 필터 (예: 'image', 'document', 'all')" |
허용값을 예시로 제공 |
복합 스키마 실전 예제
실제 프로덕션 MCP 서버에서 사용하는 복합적인 inputSchema 예제입니다:
{
"type": "object",
"properties": {
"table": {
"type": "string",
"description": "쿼리할 테이블명 (예: users, orders, products)"
},
"columns": {
"type": "array",
"items": { "type": "string" },
"description": "조회할 컬럼 목록 (빈 배열이면 전체 컬럼)"
},
"where": {
"type": "array",
"description": "WHERE 조건 목록",
"items": {
"type": "object",
"properties": {
"column": { "type": "string", "description": "컬럼명" },
"operator": {
"type": "string",
"enum": ["=", "!=", ">", "<", ">=", "<=", "LIKE", "IN"],
"description": "비교 연산자"
},
"value": {
"description": "비교 값 (문자열, 숫자, 또는 배열)"
}
},
"required": ["column", "operator", "value"]
}
},
"order_by": {
"type": "object",
"description": "정렬 조건",
"properties": {
"column": { "type": "string", "description": "정렬 컬럼" },
"direction": {
"type": "string",
"enum": ["ASC", "DESC"],
"default": "ASC",
"description": "정렬 방향"
}
}
},
"limit": {
"type": "integer",
"default": 50,
"minimum": 1,
"maximum": 1000,
"description": "최대 결과 수 (기본값: 50, 최대: 1000)"
}
},
"required": ["table"]
}
MCP 서버 아키텍처 패턴
MCP 서버는 용도에 따라 다양한 아키텍처 패턴으로 설계할 수 있습니다. 아래에서 5가지 주요 패턴을 살펴보겠습니다.
패턴 1: 단일 도구 서버
가장 단순한 형태로, 특정 기능을 수행하는 도구만 제공합니다. 계산기, 변환기, 단순 API 래퍼 등에 적합합니다.
from fastmcp import FastMCP
mcp = FastMCP("Unit Converter")
@mcp.tool()
def celsius_to_fahrenheit(celsius: float) -> float:
"""섭씨를 화씨로 변환합니다."""
return celsius * 9 / 5 + 32
@mcp.tool()
def kg_to_pounds(kg: float) -> float:
"""킬로그램을 파운드로 변환합니다."""
return kg * 2.20462
@mcp.tool()
def km_to_miles(km: float) -> float:
"""킬로미터를 마일로 변환합니다."""
return km * 0.621371
패턴 4: 프록시 서버 (외부 API 래핑)
외부 REST API를 MCP 도구로 래핑하여 AI가 사용할 수 있게 합니다. 인증, 에러 처리, 응답 형식 변환을 담당합니다.
import os
import httpx
from fastmcp import FastMCP
mcp = FastMCP("GitHub API Proxy")
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
async def github_request(endpoint: str) -> dict:
"""GitHub API 요청 헬퍼"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.github.com{endpoint}",
headers={
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json"
}
)
response.raise_for_status()
return response.json()
@mcp.tool()
async def list_repos(username: str, sort: str = "updated") -> str:
"""GitHub 사용자의 레포지토리 목록을 조회합니다.
Args:
username: GitHub 사용자명
sort: 정렬 기준 (created, updated, pushed, full_name)
"""
data = await github_request(
f"/users/{username}/repos?sort={sort}"
)
repos = [{"name": r["name"], "stars": r["stargazers_count"],
"description": r["description"]} for r in data[:10]]
import json
return json.dumps(repos, ensure_ascii=False, indent=2)
@mcp.tool()
async def get_repo_issues(
owner: str,
repo: str,
state: str = "open"
) -> str:
"""레포지토리의 이슈 목록을 조회합니다.
Args:
owner: 레포지토리 소유자
repo: 레포지토리 이름
state: 이슈 상태 (open, closed, all)
"""
data = await github_request(
f"/repos/{owner}/{repo}/issues?state={state}"
)
issues = [{"number": i["number"], "title": i["title"],
"state": i["state"]} for i in data[:20]]
import json
return json.dumps(issues, ensure_ascii=False, indent=2)
패턴 5: 집계 서버
여러 데이터 소스를 하나의 MCP 서버로 통합합니다. 대시보드, 모니터링, 종합 분석 등에 활용합니다.
from fastmcp import FastMCP
import json
mcp = FastMCP("System Monitor")
@mcp.tool()
async def system_overview() -> str:
"""시스템 전체 상태를 집계합니다.
CPU, 메모리, 디스크 정보를 통합하여 반환합니다."""
import psutil
overview = {
"cpu": {
"percent": psutil.cpu_percent(interval=1),
"count": psutil.cpu_count(),
},
"memory": {
"total_gb": round(psutil.virtual_memory().total / (1024**3), 2),
"used_percent": psutil.virtual_memory().percent,
},
"disk": {
"total_gb": round(psutil.disk_usage("/").total / (1024**3), 2),
"used_percent": psutil.disk_usage("/").percent,
},
}
return json.dumps(overview, indent=2)
@mcp.resource("monitor://dashboard")
def dashboard_data() -> str:
"""대시보드용 집계 데이터를 반환합니다."""
import psutil
data = {
"cpu_percent": psutil.cpu_percent(),
"memory_percent": psutil.virtual_memory().percent,
"disk_percent": psutil.disk_usage("/").percent,
"process_count": len(psutil.pids()),
}
return json.dumps(data, indent=2)
MCP 서버 테스트 전략
MCP 서버의 안정성과 신뢰성을 보장하기 위한 체계적인 테스트 전략입니다. MCP Inspector를 이용한 대화형 테스트부터 CI/CD 자동화까지 다룹니다.
MCP Inspector로 대화형 테스트
MCP Inspector는 MCP 서버를 시각적으로 테스트할 수 있는 웹 기반 도구입니다. 등록된 도구, 리소스, 프롬프트를 즉시 확인하고 호출할 수 있습니다.
# Python 서버 테스트
npx @modelcontextprotocol/inspector python -m my_mcp_server.server
# FastMCP 서버 테스트
fastmcp dev server.py
# TypeScript 서버 테스트
npx @modelcontextprotocol/inspector node dist/index.js
# 환경 변수 전달
GITHUB_TOKEN=xxx npx @modelcontextprotocol/inspector python server.py
# Inspector가 http://localhost:5173에서 실행됩니다
# - Tools 탭: 도구 목록 확인 및 호출 테스트
# - Resources 탭: 리소스 목록 확인 및 읽기 테스트
# - Prompts 탭: 프롬프트 목록 확인 및 렌더링 테스트
pytest를 사용한 단위 테스트
MCP 서버의 각 도구와 리소스를 단위 테스트하는 완전한 예제입니다.
"""MCP 서버 단위 테스트"""
import pytest
import json
from unittest.mock import AsyncMock, patch
from mcp.types import TextContent
# 서버 모듈에서 핸들러를 가져옵니다
from my_mcp_server.tools import call_tool, list_tools
# --- 도구 목록 테스트 ---
@pytest.mark.asyncio
async def test_list_tools():
"""도구 목록이 올바르게 반환되는지 테스트"""
tools = await list_tools()
# 도구 수 확인
assert len(tools) >= 2
# 도구 이름 확인
tool_names = [t.name for t in tools]
assert "get_weather" in tool_names
assert "calculate" in tool_names
# inputSchema 검증
calc_tool = next(t for t in tools if t.name == "calculate")
schema = calc_tool.inputSchema
assert schema["type"] == "object"
assert "operation" in schema["properties"]
assert "operation" in schema["required"]
# --- 계산 도구 테스트 ---
@pytest.mark.asyncio
async def test_calculate_add():
"""덧셈 연산 테스트"""
result = await call_tool(
"calculate",
{"operation": "add", "a": 5, "b": 3}
)
assert len(result) == 1
assert result[0].text == "8"
@pytest.mark.asyncio
async def test_calculate_divide_by_zero():
"""0으로 나누기 에러 처리 테스트"""
result = await call_tool(
"calculate",
{"operation": "divide", "a": 10, "b": 0}
)
assert "0으로 나눌 수 없습니다" in result[0].text
@pytest.mark.asyncio
async def test_unknown_tool():
"""알 수 없는 도구 호출 시 에러 테스트"""
with pytest.raises(ValueError, match="알 수 없는 도구"):
await call_tool("nonexistent", {})
# --- 리소스 테스트 ---
@pytest.mark.asyncio
async def test_read_config_resource():
"""config 리소스 읽기 테스트"""
from my_mcp_server.resources import read_resource
result = await read_resource("config://app")
config = json.loads(result)
assert "app_name" in config
assert "version" in config
@pytest.mark.asyncio
async def test_unknown_resource():
"""알 수 없는 리소스 URI 테스트"""
from my_mcp_server.resources import read_resource
with pytest.raises(ValueError):
await read_resource("unknown://test")
# 전체 테스트 실행
pytest tests/ -v
# 특정 테스트만 실행
pytest tests/test_server.py::test_calculate_add -v
# 커버리지 포함
pytest tests/ --cov=my_mcp_server --cov-report=html
통합 테스트 패턴
실제 MCP 프로토콜을 통해 서버를 테스트하는 통합 테스트 패턴입니다.
"""MCP 서버 통합 테스트"""
import pytest
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
@pytest.fixture
async def mcp_client():
"""MCP 클라이언트를 생성하여 서버에 연결합니다."""
server_params = StdioServerParameters(
command="python",
args=["-m", "my_mcp_server.server"],
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
yield session
@pytest.mark.asyncio
async def test_server_initialization(mcp_client):
"""서버 초기화 테스트"""
# 세션이 정상적으로 초기화되면 성공
assert mcp_client is not None
@pytest.mark.asyncio
async def test_list_tools_via_protocol(mcp_client):
"""프로토콜을 통한 도구 목록 조회 테스트"""
result = await mcp_client.list_tools()
assert len(result.tools) > 0
tool_names = [t.name for t in result.tools]
assert "calculate" in tool_names
@pytest.mark.asyncio
async def test_call_tool_via_protocol(mcp_client):
"""프로토콜을 통한 도구 호출 테스트"""
result = await mcp_client.call_tool(
"calculate",
{"operation": "multiply", "a": 6, "b": 7}
)
assert result.content[0].text == "42"
@pytest.mark.asyncio
async def test_list_resources_via_protocol(mcp_client):
"""프로토콜을 통한 리소스 목록 조회 테스트"""
result = await mcp_client.list_resources()
assert len(result.resources) > 0
uris = [r.uri for r in result.resources]
assert "config://app" in uris
CI/CD 자동화 테스트
name: MCP Server Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: uv pip install -e ".[dev]"
- name: Run unit tests
run: pytest tests/ -v --tb=short
- name: Run tests with coverage
run: pytest tests/ --cov=my_mcp_server --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: coverage.xml
MCP 서버 배포 가이드
개발한 MCP 서버를 다른 사용자가 쉽게 설치하고 사용할 수 있도록 패키징하고 배포하는 방법입니다.
pip/PyPI 패키지로 배포 (Python)
Python MCP 서버를 PyPI에 배포하면 pip install 또는 uvx 명령으로 설치할 수 있습니다.
[project]
name = "my-mcp-server"
version = "1.0.0"
description = "MCP 서버 - 유용한 도구 모음"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
authors = [
{ name = "개발자", email = "dev@example.com" },
]
keywords = ["mcp", "ai", "claude", "tools"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
]
dependencies = [
"mcp>=1.0.0",
"httpx>=0.25.0",
]
[project.scripts]
my-mcp-server = "my_mcp_server.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# 빌드 도구 설치
pip install build twine
# 패키지 빌드
python -m build
# 빌드 결과 확인 (dist/ 디렉토리)
ls dist/
# my_mcp_server-1.0.0-py3-none-any.whl
# my_mcp_server-1.0.0.tar.gz
# TestPyPI에 먼저 테스트 업로드
twine upload --repository testpypi dist/*
# 실제 PyPI에 업로드
twine upload dist/*
# 사용자는 이제 이렇게 설치할 수 있습니다
pip install my-mcp-server
# 또는
uvx my-mcp-server
npm 패키지로 배포 (TypeScript)
{
"name": "@myorg/mcp-server",
"version": "1.0.0",
"description": "MCP 서버 - 유용한 도구 모음",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"my-mcp-server": "dist/index.js"
},
"files": ["dist"],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"keywords": ["mcp", "ai", "claude"],
"license": "MIT"
}
# 빌드
npm run build
# dist/index.js 파일 상단에 shebang 추가
# #!/usr/bin/env node
# npm 로그인
npm login
# 배포
npm publish
# 스코프 패키지의 경우 public 접근 설정
npm publish --access public
# 사용자는 이렇게 실행할 수 있습니다
npx @myorg/mcp-server
Docker 이미지로 배포
Docker를 사용하면 환경 의존성 문제 없이 서버를 배포할 수 있습니다.
FROM python:3.12-slim
WORKDIR /app
# 의존성 먼저 설치 (캐시 활용)
COPY pyproject.toml .
RUN pip install --no-cache-dir .
# 소스 코드 복사
COPY src/ src/
# MCP 서버는 stdio 통신이므로
# ENTRYPOINT로 서버 명령 설정
ENTRYPOINT ["python", "-m", "my_mcp_server.server"]
# 이미지 빌드
docker build -t my-mcp-server .
# Claude Desktop 설정에서 Docker로 실행
# claude_desktop_config.json:
# {
# "mcpServers": {
# "my-server": {
# "command": "docker",
# "args": ["run", "-i", "--rm", "my-mcp-server"]
# }
# }
# }
-i(interactive) 플래그를 사용해야 합니다. -t(tty) 플래그는 사용하지 마세요. TTY 모드는 바이너리 프로토콜 통신을 방해합니다.
Smithery 레지스트리 등록
Smithery는 MCP 서버 전용 레지스트리입니다. 등록하면 다른 사용자가 쉽게 검색하고 설치할 수 있습니다.
{
"name": "my-mcp-server",
"description": "유용한 도구를 제공하는 MCP 서버",
"version": "1.0.0",
"runtime": "python",
"entrypoint": "my_mcp_server.server",
"capabilities": {
"tools": true,
"resources": true,
"prompts": true
},
"author": "개발자",
"license": "MIT",
"repository": "https://github.com/user/my-mcp-server"
}
# Smithery CLI 설치
npx @smithery/cli login
# 서버 등록
npx @smithery/cli publish
# 사용자가 Smithery에서 설치
npx @smithery/cli install my-mcp-server
MCP 서버 디버깅 가이드
MCP 서버 개발 중 발생하는 문제를 효율적으로 진단하고 해결하는 방법입니다.
MCP Inspector 사용법 상세
MCP Inspector는 서버 디버깅의 핵심 도구입니다. 웹 UI를 통해 서버와 직접 상호작용하며 문제를 진단합니다.
# 기본 실행
npx @modelcontextprotocol/inspector python -m my_mcp_server.server
# 환경 변수 전달
DEBUG=true LOG_LEVEL=DEBUG \
npx @modelcontextprotocol/inspector python -m my_mcp_server.server
# Inspector에서 확인할 항목:
# 1. Connection 탭: 서버 연결 상태, 초기화 메시지
# 2. Tools 탭:
# - 등록된 도구 목록과 inputSchema 확인
# - 각 도구를 직접 호출하고 결과 확인
# - 잘못된 인자를 전달하여 에러 처리 검증
# 3. Resources 탭:
# - 리소스 URI 목록 확인
# - 각 리소스의 내용을 읽어보기
# 4. Prompts 탭:
# - 프롬프트 템플릿 목록 확인
# - 인자를 전달하여 렌더링 결과 미리보기
로그 레벨 설정
상황에 따라 적절한 로그 레벨을 설정하여 필요한 정보를 확인합니다.
import logging
import sys
import os
def configure_logging():
"""환경에 따른 로깅 설정"""
level = os.environ.get("LOG_LEVEL", "INFO").upper()
# 루트 로거 설정 (stderr로 출력)
logging.basicConfig(
level=getattr(logging, level),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
stream=sys.stderr, # 반드시 stderr!
)
# 개발 모드: 상세 로깅
if os.environ.get("DEBUG") == "true":
logging.getLogger("my-mcp-server").setLevel(logging.DEBUG)
logging.getLogger("mcp").setLevel(logging.DEBUG)
# 프로덕션 모드: 경고 이상만
if os.environ.get("ENV") == "production":
logging.getLogger().setLevel(logging.WARNING)
# 도구에서 로깅 활용
logger = logging.getLogger("my-mcp-server.tools")
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list:
logger.info(f"도구 호출: {name}")
logger.debug(f"인자: {arguments}")
try:
result = await _execute_tool(name, arguments)
logger.info(f"도구 {name} 실행 성공")
logger.debug(f"결과: {result}")
return result
except Exception as e:
logger.error(f"도구 {name} 실행 실패: {e}", exc_info=True)
raise
프로토콜 메시지 덤프
MCP 서버와 클라이언트 간의 JSON-RPC 메시지를 직접 확인하여 프로토콜 수준의 문제를 진단합니다.
import logging
import json
import sys
# MCP 내부 로거를 DEBUG로 설정하면
# 프로토콜 메시지를 볼 수 있습니다
logging.basicConfig(
level=logging.DEBUG,
stream=sys.stderr,
format="%(name)s: %(message)s"
)
# MCP SDK의 내부 로거 활성화
logging.getLogger("mcp.server").setLevel(logging.DEBUG)
logging.getLogger("mcp.server.stdio").setLevel(logging.DEBUG)
# 출력 예시 (stderr):
# mcp.server: Received request: {"jsonrpc":"2.0","id":1,"method":"tools/list"}
# mcp.server: Sending response: {"jsonrpc":"2.0","id":1,"result":{...}}
일반적인 오류와 해결 방법
| 오류 | 원인 | 해결 방법 |
|---|---|---|
Connection refused |
서버가 실행되지 않았거나 경로가 잘못됨 | command와 args 경로를 절대 경로로 확인 |
Server disconnected |
서버 프로세스가 비정상 종료됨 | stderr 로그를 확인하여 크래시 원인 파악 |
Tool not found |
도구가 등록되지 않았거나 이름 불일치 | list_tools 응답에서 등록된 이름 확인 |
Invalid params |
inputSchema와 맞지 않는 인자 | Inspector에서 스키마를 확인하고 필수 필드 검증 |
stdout에 로그 출력 |
print() 사용으로 프로토콜 오염 |
모든 로그를 logging(stderr)으로 변경 |
ModuleNotFoundError |
패키지가 설치되지 않음 | cwd와 가상 환경 경로 확인, uv run 사용 |
JSON decode error |
서버 응답이 올바른 JSON이 아님 | stdout에 비 JSON 텍스트가 출력되는지 확인 |
Timeout |
도구 실행이 너무 오래 걸림 | 비동기 처리, 타임아웃 설정, 진행 상황 보고 |
Permission denied |
파일/네트워크 접근 권한 부족 | 서버 실행 환경의 권한 확인, Docker 볼륨 마운트 |
Schema validation failed |
inputSchema가 잘못 정의됨 | JSON Schema 유효성 검사기로 스키마 검증 |
- 서버를 단독으로 실행하여 시작 에러가 없는지 확인
- MCP Inspector로 도구/리소스 목록이 정상 반환되는지 확인
- Inspector에서 각 도구를 직접 호출하여 결과 확인
- 에러 상황(잘못된 인자, 없는 파일 등)에서의 응답 확인
- Claude Desktop 로그(
~/Library/Logs/Claude/mcp*.log)에서 연결 문제 확인
FastMCP로 빠른 서버 개발
FastMCP는 MCP 서버 개발을 극적으로 간소화하는 고수준 프레임워크입니다. 데코레이터 기반 API로 보일러플레이트 코드 없이 서버를 구축할 수 있습니다.
- FastMCP: 데코레이터 기반, 자동 스키마 생성, 빠른 프로토타이핑에 최적
- 저수준 SDK: 세밀한 제어, 커스텀 전송 계층, 대규모 프로덕션 서버에 적합
FastMCP 설치
# pip 사용
pip install fastmcp
# uv 사용 (권장)
uv add fastmcp
# 개발 의존성 포함
uv add fastmcp[dev]
FastMCP 기본 서버
#!/usr/bin/env python3
"""FastMCP 기본 서버 예제"""
from fastmcp import FastMCP
# 서버 인스턴스 생성 — 이름만 지정하면 끝
mcp = FastMCP("my-server")
# 도구 등록: 데코레이터 + 타입 힌트 = 자동 inputSchema 생성
@mcp.tool()
def add(a: int, b: int) -> int:
"""두 정수를 더합니다."""
return a + b
@mcp.tool()
def greet(name: str, language: str = "ko") -> str:
"""이름에 맞는 인사를 반환합니다.
Args:
name: 인사할 사람 이름
language: 인사 언어 (ko, en, es)
"""
greetings = {
"ko": f"안녕하세요, {name}님!",
"en": f"Hello, {name}!",
"es": f"Hola, {name}!",
}
return greetings.get(language, greetings["ko"])
# 리소스 등록
@mcp.resource("config://app")
def get_config() -> str:
"""애플리케이션 설정을 반환합니다."""
import json
return json.dumps({"version": "2.0", "debug": False})
# 프롬프트 등록
@mcp.prompt()
def review_code(code: str, lang: str = "python") -> str:
"""코드 리뷰 프롬프트를 생성합니다."""
return f"""다음 {lang} 코드를 리뷰해주세요:
```{lang}
{code}
```
품질, 보안, 성능 관점에서 분석해주세요."""
# 서버 실행
if __name__ == "__main__":
mcp.run()
Python 타입 힌트와 docstring에서 inputSchema가 자동 생성됩니다. 위의 greet 도구는 다음 JSON Schema를 자동으로 만듭니다:
{
"type": "object",
"properties": {
"name": {"type": "string", "description": "인사할 사람 이름"},
"language": {"type": "string", "default": "ko", "description": "인사 언어 (ko, en, ja)"}
},
"required": ["name"]
}
Context 객체 활용
FastMCP의 Context 객체는 로깅, 진행 상황 보고, 리소스 접근 등 런타임 기능을 제공합니다.
from fastmcp import FastMCP, Context
mcp = FastMCP("context-demo")
@mcp.tool()
async def process_files(
directory: str,
ctx: Context
) -> str:
"""디렉토리의 파일들을 처리합니다."""
import os
files = os.listdir(directory)
total = len(files)
# 로깅
await ctx.info(f"처리할 파일 수: {total}")
results = []
for i, filename in enumerate(files):
# 진행 상황 보고
await ctx.report_progress(i, total)
filepath = os.path.join(directory, filename)
if os.path.isfile(filepath):
size = os.path.getsize(filepath)
results.append(f"{filename}: {size} bytes")
await ctx.report_progress(total, total)
return "\n".join(results)
Pydantic 모델과 FastMCP
from pydantic import BaseModel, Field
from typing import Optional, List
from fastmcp import FastMCP
mcp = FastMCP("structured-server")
# Pydantic 모델로 복잡한 입력 정의
class SearchQuery(BaseModel):
"""파일 검색 쿼리"""
directory: str = Field(description="검색 대상 디렉토리")
pattern: str = Field(description="파일명 패턴 (glob)")
max_results: int = Field(default=20, ge=1, le=100,
description="최대 결과 수")
include_hidden: bool = Field(default=False,
description="숨김 파일 포함 여부")
class SearchResult(BaseModel):
"""검색 결과"""
path: str
size: int
modified: float
@mcp.tool()
def search_files(query: SearchQuery) -> List[SearchResult]:
"""파일을 검색합니다. Pydantic 모델로 구조화된 입력을 받습니다."""
import os, glob
search_path = os.path.join(query.directory, query.pattern)
files = glob.glob(search_path, recursive=True)
results = []
for f in files[:query.max_results]:
if not query.include_hidden and os.path.basename(f).startswith("."):
continue
stat = os.stat(f)
results.append(SearchResult(
path=f, size=stat.st_size, modified=stat.st_mtime
))
return results
동적 리소스 템플릿
@mcp.resource("db://users/{user_id}")
def get_user(user_id: int) -> str:
"""사용자 정보를 반환합니다."""
import json
# 실제로는 DB에서 조회
user = {"id": user_id, "name": "사용자", "role": "admin"}
return json.dumps(user, ensure_ascii=False)
@mcp.resource("log://app/{date}")
def get_logs(date: str) -> str:
"""특정 날짜의 로그를 반환합니다."""
log_path = f"/var/log/app/{date}.log"
try:
with open(log_path) as f:
return f.read()
except FileNotFoundError:
return f"로그 없음: {date}"
inputSchema 설계 가이드
MCP 도구의 inputSchema는 AI 클라이언트가 도구를 올바르게 호출할 수 있도록 하는 JSON Schema입니다. 잘 설계된 스키마는 AI의 도구 선택 정확도와 매개변수 전달 품질을 크게 향상시킵니다.
기본 스키마 구조
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "검색할 키워드 또는 구문"
},
"max_results": {
"type": "integer",
"description": "반환할 최대 결과 수",
"default": 10,
"minimum": 1,
"maximum": 100
},
"format": {
"type": "string",
"description": "출력 형식",
"enum": ["json", "text", "csv"]
}
},
"required": ["query"]
}
JSON Schema 타입별 매핑
| JSON Schema 타입 | Python 타입 | TypeScript 타입 | 사용 예시 |
|---|---|---|---|
"string" |
str |
string |
파일 경로, 검색어, 이름 |
"integer" |
int |
number |
개수, ID, 인덱스 |
"number" |
float |
number |
좌표, 비율, 임계값 |
"boolean" |
bool |
boolean |
플래그, 옵션 토글 |
"array" |
List[T] |
T[] |
태그 목록, 경로 배열 |
"object" |
Dict[str, Any] |
Record<string, any> |
설정 객체, 메타데이터 |
고급 스키마 패턴
// 중첩 객체, 배열, enum을 활용한 복잡한 스키마
const deployTool: Tool = {
name: "deploy_service",
description: "서비스를 지정된 환경에 배포합니다",
inputSchema: {
type: "object",
properties: {
service: {
type: "string",
description: "배포할 서비스 이름"
},
environment: {
type: "string",
enum: ["dev", "staging", "production"],
description: "배포 대상 환경"
},
config: {
type: "object",
description: "배포 설정",
properties: {
replicas: {
type: "integer",
minimum: 1,
maximum: 10,
description: "인스턴스 수"
},
env_vars: {
type: "object",
additionalProperties: { type: "string" },
description: "환경 변수 (키-값 쌍)"
}
}
},
tags: {
type: "array",
items: { type: "string" },
description: "배포 태그 목록"
},
dry_run: {
type: "boolean",
default: false,
description: "실제 배포 없이 검증만 수행"
}
},
required: ["service", "environment"]
}
};
description필드를 모든 property에 작성하세요 -- AI가 매개변수 의미를 파악하는 핵심 단서입니다required배열에 필수 매개변수만 포함하세요 -- 선택 매개변수에는default값을 지정하세요enum을 적극 활용하세요 -- 유효 값을 제한하면 AI가 올바른 값을 선택합니다- 도구의
description에 사용 시나리오를 명시하세요 -- AI가 언제 이 도구를 선택할지 판단합니다
MCP 서버 아키텍처 패턴
MCP 서버는 다양한 아키텍처 패턴으로 구성할 수 있습니다. 서버의 규모와 용도에 따라 적절한 패턴을 선택합니다.
서버 내부 구조
모듈화 패턴
규모가 큰 서버는 도구를 모듈별로 분리하여 관리합니다.
#!/usr/bin/env python3
"""모듈화된 MCP 서버 구조"""
from fastmcp import FastMCP
# 메인 서버
app = FastMCP("modular-server")
# ===== 파일 관련 도구 모듈 =====
import os
from pathlib import Path
ALLOWED_BASE = Path.home() / "Projects"
def validate_path(path: str) -> Path:
"""경로를 검증하고 절대 경로를 반환합니다."""
resolved = Path(path).resolve()
if not str(resolved).startswith(str(ALLOWED_BASE)):
raise ValueError("허용되지 않은 경로입니다")
return resolved
@app.tool()
def read_project_file(path: str) -> str:
"""프로젝트 파일을 읽습니다."""
validated = validate_path(path)
return validated.read_text(encoding="utf-8")
@app.tool()
def list_project_files(
directory: str,
extension: str = "*"
) -> list[str]:
"""프로젝트 디렉토리 내 파일을 나열합니다."""
validated = validate_path(directory)
pattern = f"*.{extension}" if extension != "*" else "*"
return [str(f) for f in validated.glob(pattern)]
# ===== Git 관련 도구 모듈 =====
import subprocess
@app.tool()
def git_status(repo_path: str) -> str:
"""Git 저장소 상태를 반환합니다."""
validated = validate_path(repo_path)
result = subprocess.run(
["git", "status", "--porcelain"],
cwd=str(validated),
capture_output=True, text=True
)
return result.stdout or "작업 디렉토리가 깨끗합니다"
@app.tool()
def git_log(
repo_path: str,
count: int = 10
) -> str:
"""최근 Git 커밋 이력을 반환합니다."""
validated = validate_path(repo_path)
result = subprocess.run(
["git", "log", f"--oneline", f"-{count}"],
cwd=str(validated),
capture_output=True, text=True
)
return result.stdout
if __name__ == "__main__":
app.run()
테스트 및 디버깅 심화
MCP 서버 개발에서 테스트와 디버깅은 특히 중요합니다. stdio 기반 통신이므로 일반적인 HTTP 서버와 다른 접근 방식이 필요합니다.
MCP Inspector 활용
MCP Inspector는 서버를 시각적으로 테스트할 수 있는 공식 개발 도구입니다.
# Python 서버 Inspector로 실행
npx @modelcontextprotocol/inspector python server.py
# FastMCP 서버
npx @modelcontextprotocol/inspector uv run server.py
# TypeScript 서버
npx @modelcontextprotocol/inspector node dist/index.js
# 환경 변수 전달
API_KEY=xxx npx @modelcontextprotocol/inspector python server.py
- 도구/리소스/프롬프트 목록 조회
- 도구를 매개변수와 함께 직접 호출하고 결과 확인
- JSON-RPC 메시지 로그 실시간 확인
- 서버 능력(capabilities) 검사
http://localhost:5173에서 웹 UI 접근
단위 테스트 작성
Python pytest 기반 테스트
import pytest
import json
from unittest.mock import patch, MagicMock
# 서버 모듈에서 도구 함수를 직접 임포트하여 테스트
from server import read_project_file, list_project_files
class TestFileTools:
"""파일 관련 도구 테스트"""
def test_read_file_success(self, tmp_path):
"""파일 읽기 성공 테스트"""
test_file = tmp_path / "test.txt"
test_file.write_text("테스트 내용")
# 경로 검증을 임시로 우회
with patch("server.validate_path",
return_value=test_file):
result = read_project_file(str(test_file))
assert result == "테스트 내용"
def test_read_file_not_found(self):
"""존재하지 않는 파일 읽기 테스트"""
with pytest.raises(FileNotFoundError):
read_project_file("/nonexistent/file.txt")
def test_path_traversal_blocked(self):
"""경로 탐색 공격 차단 테스트"""
with pytest.raises(ValueError, match="허용되지 않은"):
read_project_file("../../etc/passwd")
TypeScript Jest 테스트
import { describe, it, expect } from "@jest/globals";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from
"@modelcontextprotocol/sdk/client/stdio.js";
describe("MCP Server", () => {
let client: Client;
beforeAll(async () => {
const transport = new StdioClientTransport({
command: "node",
args: ["dist/index.js"]
});
client = new Client(
{ name: "test-client", version: "1.0.0" },
{ capabilities: {} }
);
await client.connect(transport);
});
afterAll(async () => {
await client.close();
});
it("도구 목록을 반환한다", async () => {
const result = await client.listTools();
expect(result.tools.length).toBeGreaterThan(0);
});
it("hello 도구를 호출한다", async () => {
const result = await client.callTool({
name: "hello",
arguments: { name: "테스트" }
});
expect(result.content[0].text).toContain("테스트");
});
});
디버깅 기법
stderr 로깅 패턴
MCP 서버는 stdout을 JSON-RPC 통신에 사용하므로, 디버그 출력은 반드시 stderr로 보내야 합니다.
import sys
import logging
# stderr로 로깅 설정 (stdout은 MCP 프로토콜 전용)
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(message)s",
stream=sys.stderr # 핵심: stderr로 출력
)
logger = logging.getLogger("mcp-server")
@mcp.tool()
def process_data(data: str) -> str:
"""데이터를 처리합니다."""
logger.debug(f"입력 데이터: {data[:100]}...")
try:
result = do_processing(data)
logger.info(f"처리 완료: {len(result)} bytes")
return result
except Exception as e:
logger.error(f"처리 실패: {e}", exc_info=True)
raise
MCP 서버에서 print()를 사용하면 stdout에 출력되어 JSON-RPC 통신이 깨집니다. 디버깅 시 반드시 logging 모듈로 stderr에 출력하거나, print(..., file=sys.stderr)를 사용하세요.
Claude Desktop 로그 확인
# macOS - Claude Desktop MCP 로그 위치
tail -f ~/Library/Logs/Claude/mcp*.log
# 특정 서버 로그만 필터링
tail -f ~/Library/Logs/Claude/mcp*.log | grep "my-server"
# Windows
# %APPDATA%\Claude\logs\mcp*.log
자주 발생하는 오류와 해결
| 오류 | 원인 | 해결 방법 |
|---|---|---|
Connection refused |
서버 프로세스 시작 실패 | 명령어 경로 확인, 의존성 설치 확인 |
Invalid JSON |
stdout에 디버그 출력 혼입 | print()를 logging으로 교체 |
Tool not found |
도구 등록 누락 또는 이름 불일치 | tools/list 응답 확인 |
Schema validation error |
입력이 inputSchema와 불일치 | 스키마의 required, type 필드 확인 |
Timeout |
도구 실행이 너무 오래 걸림 | 비동기 처리, 타임아웃 설정 추가 |
Permission denied |
파일/디렉토리 접근 권한 부족 | 서버 실행 사용자 권한 확인 |
JSON-RPC 메시지 추적
#!/usr/bin/env python3
"""JSON-RPC 메시지를 로깅하는 디버그 래퍼"""
import sys
import json
import subprocess
import threading
def log_stream(name: str, stream, output):
"""스트림의 내용을 로깅하면서 전달합니다."""
for line in stream:
# stderr에 로깅
print(
f"[{name}] {line.rstrip()}",
file=sys.stderr
)
# 원래 스트림으로 전달
output.write(line)
output.flush()
# 실제 서버를 서브프로세스로 실행
proc = subprocess.Popen(
["python", "server.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# stdin 로깅 (클라이언트 -> 서버)
t_in = threading.Thread(
target=log_stream,
args=("REQ", sys.stdin, proc.stdin)
)
# stdout 로깅 (서버 -> 클라이언트)
t_out = threading.Thread(
target=log_stream,
args=("RES", proc.stdout, sys.stdout)
)
t_in.start()
t_out.start()
t_in.join()
t_out.join()
핵심 정리
- MCP 서버 개발의 핵심 개념과 흐름을 정리합니다.
- Python 서버 개발를 단계별로 이해합니다.
- 실전 적용 시 기준과 주의점을 확인합니다.
실무 팁
- 입력/출력 예시를 고정해 재현성을 확보하세요.
- MCP 서버 개발 범위를 작게 잡고 단계적으로 확장하세요.
- Python 서버 개발 조건을 문서화해 대응 시간을 줄이세요.