Claude MCP 서버를 직접 구현하면서 배운 것들 — 도구 정의, 핸들러 라우팅, 스키마 검증까지
처음 MCP 서버를 직접 만들어보겠다고 결심했을 때, 솔직히 공식 문서만 보고선 전체 그림이 잘 안 그려졌습니다. "JSON-RPC 2.0 기반 프로토콜"이라는 설명은 맞는데, 정작 내 코드가 Claude Desktop 안에서 어떻게 작동하는지를 체감하기까지 시간이 좀 걸렸거든요. 이 글은 그 과정에서 파악한 것들 — 도구를 어떻게 정의하고, 요청을 어떻게 라우팅하며, 스키마 검증은 어디서 일어나는지 — 을 최대한 실제 흐름에 맞게 정리한 것입니다.
Claude API로 에이전트 워크플로를 이미 구축해본 분이라면, MCP는 그 워크플로를 표준화된 인터페이스 뒤로 포장하는 방식이라고 보면 됩니다. Claude가 직접 함수를 호출하는 대신, Claude Desktop이나 Claude Code가 MCP 서버에 tools/list로 "뭘 할 수 있어?"라고 물어보고, tools/call로 실제 작업을 요청하는 구조입니다. 2024년 11월 Anthropic이 공개한 이후 OpenAI와 Google DeepMind도 채택하면서 사실상 업계 표준이 되었고, 지금 배워두면 Claude 외 클라이언트에도 동일한 서버를 재사용할 수 있다는 게 가장 큰 이점입니다.
MCP가 내부에서 어떻게 동작하는지 먼저 보기
Claude Desktop이 MCP 서버와 통신하는 흐름을 시퀀스로 보면 이해가 훨씬 빠릅니다.
중요한 점은 모든 메시지가 JSON-RPC 2.0 형식이라는 겁니다. Claude Desktop은 그저 JSON을 주고받는 클라이언트고, 서버는 그 요청에 응답하는 프로세스일 뿐입니다. 로컬 환경에서는 stdio(표준 입출력)로 이 채널이 열리고, 원격 환경에서는 Streamable HTTP가 쓰입니다.
트랜스포트 선택: stdio vs Streamable HTTP
저도 처음엔 "그냥 stdio 쓰면 되는 거 아닌가?"라고 생각했는데, 용도에 따라 선택이 달라집니다.
| 트랜스포트 | 용도 | 특징 |
|---|---|---|
stdio |
Claude Desktop과의 로컬 연결 | 프로세스를 직접 실행, 설정 간단 |
Streamable HTTP |
원격 서버, 멀티클라이언트 | 2025-03-26 스펙에서 도입, 기존 HTTP+SSE는 deprecated |
로컬 Claude Desktop 연동이 목적이라면 stdio가 여전히 가장 빠른 선택입니다. 원격 배포 시나리오라면 Streamable HTTP로 가야 하고요.
도구 정의: Claude가 뭘 할 수 있는지 알려주는 방식
MCP에서 도구 정의는 세 가지로 구성됩니다: name, description, 그리고 입력 파라미터를 기술하는 JSON Schema. 이 세 가지가 tools/list 응답으로 클라이언트에 전달되고, Claude는 이걸 보고 어떤 도구를 언제 쓸지 결정합니다.
TypeScript SDK로 도구 정의하기
npm install @modelcontextprotocol/sdk zod pg아래는 개념적 예시입니다. 실제 서비스에서는 커넥션 풀 초기화, 에러 처리, 로깅 등이 더 붙습니다.
// index.ts (package.json에 "type": "module" 필요, tsconfig의 module은 ES2022 이상)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { Pool } from "pg";
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const server = new McpServer({
name: "my-db-server",
version: "1.0.0",
});
server.tool(
"query_database",
"PostgreSQL 데이터베이스에서 사전 정의된 형태의 조회를 수행합니다",
{
table: z.enum(["users", "orders", "products"]).describe("조회 대상 테이블"),
limit: z.number().int().min(1).max(100).default(10),
},
async ({ table, limit }) => {
const result = await db.query(
`SELECT * FROM ${table} LIMIT $1`,
[limit]
);
return {
content: [{ type: "text", text: JSON.stringify(result.rows, null, 2) }],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);여기서 두 가지가 중요합니다. 첫째, 파일 최상위에서 await를 쓰려면 프로젝트가 ESM으로 설정되어 있어야 합니다(package.json의 "type": "module" 또는 .mjs 확장자). CommonJS 환경이라면 async main() 함수로 감싸야 합니다. 둘째, SQL 인젝션 방지의 핵심은 값 바인딩($1, $2 플레이스홀더 + 배열 인자)입니다. 임의의 SQL 문자열을 사용자 입력으로 받는 대신, 테이블명은 z.enum()으로 화이트리스트를 두고 값만 파라미터로 넘기는 형태를 권장합니다.
Python SDK (FastMCP)로 도구 정의하기
Python을 선호한다면 @mcp.tool() 데코레이터 패턴이 훨씬 간결합니다.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-db-server")
@mcp.tool()
async def query_table(table: str, limit: int = 10) -> str:
"""사전 정의된 테이블에서 데이터를 조회합니다.
Args:
table: 조회 대상 테이블 (users, orders, products 중 하나)
limit: 최대 결과 수 (1-100)
"""
allowed = {"users", "orders", "products"}
if table not in allowed:
raise ValueError(f"허용되지 않은 테이블: {table}")
result = await run_query(f"SELECT * FROM {table} LIMIT $1", [limit])
return str(result)FastMCP는 타입 힌트와 docstring에서 JSON Schema를 자동으로 생성합니다. Pydantic이 런타임 검증을 담당하고요.
핸들러 라우팅: 요청이 어떻게 코드에 닿는지
tools/call 요청이 들어오면 SDK가 name 필드를 보고 등록된 핸들러로 디스패치합니다. 여러 도구를 관리할 때는 도구를 별도 모듈로 분리하는 게 유지보수에 좋습니다.
// tools/database.ts
import { z } from "zod";
import type { Pool } from "pg";
export function makeDatabaseTools(db: Pool) {
return {
query_table: {
description: "허용된 테이블의 데이터 조회",
schema: {
table: z.enum(["users", "orders", "products"]),
limit: z.number().int().min(1).max(100).default(10),
},
handler: async ({ table, limit }: { table: "users" | "orders" | "products"; limit: number }) => {
const result = await db.query(`SELECT * FROM ${table} LIMIT $1`, [limit]);
return {
content: [{ type: "text" as const, text: JSON.stringify(result.rows) }],
};
},
},
list_tables: {
description: "public 스키마의 테이블 목록 반환",
schema: {},
handler: async () => {
const result = await db.query(
"SELECT tablename FROM pg_tables WHERE schemaname = $1",
["public"]
);
return {
content: [{ type: "text" as const, text: JSON.stringify(result.rows) }],
};
},
},
};
}// index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Pool } from "pg";
import { makeDatabaseTools } from "./tools/database.js";
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const server = new McpServer({ name: "my-db-server", version: "1.0.0" });
const tools = makeDatabaseTools(db);
for (const [name, tool] of Object.entries(tools)) {
server.tool(name, tool.description, tool.schema, tool.handler);
}
const transport = new StdioServerTransport();
await server.connect(transport);핸들러가 호출되기까지의 흐름은 이렇게 됩니다.
스키마 검증: Zod와 Pydantic이 하는 일
MCP 서버 내부에서 인수 검증은 SDK가 자동으로 처리합니다. TypeScript SDK에서 server.tool()에 넘긴 Zod 스키마는 SDK가 요청을 받아 핸들러로 넘기기 직전에 파싱합니다. 그래서 핸들러 안에서 다시 parse()를 호출할 필요가 없고, 오히려 중복 파싱은 독자에게 "SDK 검증이 미덥지 못한가?"라는 잘못된 인상을 줍니다.
const inputShape = {
sql: z
.string()
.min(1)
.refine(
(s) => s.trim().toUpperCase().startsWith("SELECT"),
"SELECT로 시작하는 쿼리만 허용됩니다 (기초 필터)"
),
limit: z.number().int().min(1).max(100).default(10),
} as const;
server.tool(
"run_select",
"임시 SELECT 쿼리 실행 (개념 예시, 프로덕션 부적합)",
inputShape,
async ({ sql, limit }) => {
// 여기 도달했다면 sql, limit은 이미 SDK가 검증한 값
// ...
}
);여기서 짚어둘 게 있습니다. startsWith("SELECT") 같은 문자열 검사는 입력 형태에 대한 기초 필터일 뿐, "읽기 전용"을 보장하지 않습니다. SELECT pg_read_file(...)처럼 시스템 함수를 호출하는 SELECT, SELECT ... INTO OUTFILE(MySQL) 같은 파일 쓰기, 앞에 /* comment */를 붙여 문자열 시작 검사를 우회하는 벡터 등이 여전히 남습니다. 진짜 읽기 전용을 원한다면 DB 계정 자체를 READ ONLY 트랜잭션 또는 SELECT 권한만 있는 롤로 제한하고, 애플리케이션 레벨에서는 자유 텍스트 SQL 대신 앞서 보여드린 화이트리스트 방식(테이블/컬럼을 enum으로 고정)으로 좁히는 편이 안전합니다.
Python 쪽에서는 FastMCP가 타입 힌트에서 Pydantic 모델을 자동 생성하고, 더 정교한 검증이 필요하면 명시적으로 모델을 정의할 수 있습니다.
from pydantic import BaseModel, field_validator
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-db-server")
class QueryInput(BaseModel):
sql: str
limit: int = 10
@field_validator("sql")
@classmethod
def basic_select_filter(cls, v: str) -> str:
if not v.strip().upper().startswith("SELECT"):
raise ValueError("SELECT로 시작해야 합니다 (기초 필터)")
return v
@field_validator("limit")
@classmethod
def limit_range(cls, v: int) -> int:
if not 1 <= v <= 100:
raise ValueError("limit는 1-100 사이여야 합니다")
return v
@mcp.tool()
async def query_database(input: QueryInput) -> str:
"""개념 예시. 실제로는 DB 롤 권한 + 화이트리스트로 좁히세요."""
result = await run_query(input.sql, input.limit)
return str(result)Claude Desktop에 연결하기
서버를 만들었으면 Claude Desktop에 등록해야 합니다. 설정 파일 경로는 OS마다 다릅니다.
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"my-db-server": {
"command": "node",
"args": ["/absolute/path/to/server/index.js"],
"env": {
"DATABASE_URL": "postgresql://user:pass@localhost:5432/mydb"
}
}
}
}Python 서버라면 이렇게 씁니다.
{
"mcpServers": {
"my-db-server": {
"command": "python",
"args": ["/absolute/path/to/server.py"],
"env": {
"DATABASE_URL": "postgresql://user:pass@localhost:5432/mydb"
}
}
}
}여기서 헷갈리기 쉬운 점 하나. 지금까지 이야기한 스키마 검증(Zod/Pydantic)은 MCP 서버 프로세스 내부의 도구 인수 검증입니다. 반면 위 JSON 파일은 Claude Desktop 자체가 로드하는 설정 파일로, 애플리케이션이 자기 규칙에 따라 파싱하고 실행합니다. 둘은 완전히 다른 레이어이고, Zod가 설정 파일을 검증하는 게 아닙니다. 절대 경로를 쓰는 이유는 Claude Desktop이 서버를 실행하는 작업 디렉터리가 예측 어렵기 때문이고요.
Claude Desktop 관점에서 서버가 붙기까지의 흐름은 대략 이렇습니다.
디버깅 팁
mcp-inspector를 쓰면 Claude Desktop 없이 서버를 직접 테스트할 수 있습니다.
npx @modelcontextprotocol/inspector node /path/to/server/index.js브라우저에서 도구 목록 조회, 직접 호출, 응답 확인이 가능합니다. 서버 개발 초기에 정말 유용합니다.
프로덕션에서 마주할 보안 이슈
MCP 보안 이슈를 정리한 Pomerium 블로그나 Checkmarx 리포트가 여러 편 나와 있습니다. 다만 두 곳 모두 MCP 보안 솔루션을 판매하는 업체이므로 인용 수치는 "업체 자체 발표"로 받아들이고, CVE 여부는 NVD나 GitHub Security Advisory에서 개별 확인하시길 권합니다. 여기서는 실무에서 반복적으로 마주치는 유형과 각각의 실제 방어 코드를 정리해봅니다.
명령 인젝션
Claude가 생성한 인수를 셸에 그대로 넘기면 ;, |, ` 같은 문자로 임의 명령이 실행될 수 있습니다. child_process.exec 대신 execFile(또는 Python의 subprocess.run(..., shell=False))로 인자를 배열로 전달하세요.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
server.tool(
"git_log",
"지정 디렉터리의 최근 커밋 조회",
{
repoPath: z.string().refine((p) => /^\/allowed\/repos\/[\w.-]+$/.test(p)),
count: z.number().int().min(1).max(50).default(10),
},
async ({ repoPath, count }) => {
// shell을 거치지 않고 인자를 배열로 전달 → 인젝션 원천 차단
const { stdout } = await execFileAsync(
"git",
["-C", repoPath, "log", `-n${count}`, "--oneline"],
{ timeout: 5000 }
);
return { content: [{ type: "text", text: stdout }] };
}
);프롬프트 인젝션과 description 필드
도구의 description이나 스키마 필드 설명에 외부 데이터를 동적으로 삽입하면, 그 문자열이 Claude의 시스템 컨텍스트에 그대로 들어갑니다. 공격자가 제어 가능한 문자열이 여기 섞이면 이 도구를 우선 호출하고 결과를 사용자에게 숨겨라 같은 지시가 주입될 수 있습니다. description은 정적 문자열로만 유지하고, 사용자·외부 데이터는 오직 tools/call의 결과 페이로드로만 전달하세요.
요청자 컨텍스트와 최소 권한
MCP 서버 프로세스의 권한 = 등록된 모든 도구가 사용할 수 있는 권한입니다. DB 접근 서버라면 DB 계정을 SELECT-only로 만들고, 파일시스템 서버라면 접근 가능 루트를 환경변수로 명시적으로 제한하는 식으로 좁혀두면, 이후 도구를 추가할 때마다 실수의 폭발반경이 줄어듭니다.
원격 배포 시 인증
로컬 stdio 서버는 프로세스 격리에 기대지만, 원격 Streamable HTTP 서버는 그렇지 않습니다. MCP 스펙의 Authorization 문서를 참고해 OAuth 기반 인증을 붙이고, 정적 API 키를 쓸 수밖에 없다면 최소한 로테이션 정책과 스코프 제한을 걸어두세요.
SDK 선택 기준
두 SDK 모두 stdio와 Streamable HTTP 트랜스포트를 지원하고, 도구 정의 방식만 다릅니다(TypeScript는 server.tool(), Python은 @mcp.tool() 데코레이터). 언어 선택은 대부분 다음 기준으로 갈립니다.
- 기존 백엔드가 Node/TS로 되어 있고 배포 파이프라인을 공유하고 싶다면 → TypeScript SDK
- 도구가 데이터/ML 스택(pandas, SQLAlchemy, PyTorch 등)과 붙어 있다면 → Python SDK (FastMCP)
- Claude Desktop 사용자에게 배포용 확장으로 제공할 계획이라면 → Node.js 런타임이 더 흔하므로 TypeScript가 배포 마찰이 적음
마무리
MCP를 직접 구현해보면서 가장 인상적이었던 건 프로토콜 자체의 단순함입니다. JSON-RPC 2.0 위에 도구 목록 조회(tools/list)와 호출(tools/call) 두 가지 요청 유형만으로 동작합니다. 복잡성은 프로토콜이 아니라 서버 내부 — 어떤 도구를 노출할지, 인수를 어떻게 좁힐지, 외부 시스템과 어떤 권한으로 붙을지 — 에서 나옵니다.
한 번 만들어두면 Claude Desktop뿐 아니라 다른 MCP 호환 클라이언트에서도 그대로 재사용됩니다. 처음에는 도구 하나짜리 서버로 시작해 mcp-inspector로 왕복을 관찰해보시는 걸 권합니다. 그 다음에 도구를 늘리면서 이 글에서 다룬 라우팅·검증·보안 패턴을 하나씩 적용해가면 됩니다.
참고 자료
- Model Context Protocol 공식 문서
- MCP 스펙 (2025-03-26) — Streamable HTTP 도입 및 HTTP+SSE deprecation
- Anthropic — Introducing the Model Context Protocol
- Anthropic — Claude Desktop Extensions
- MCP TypeScript SDK (GitHub)
- MCP Python SDK / FastMCP (GitHub)
- MCP Inspector
- Pomerium — MCP Server Security Risks (업체 발표, 참고용)
- Checkmarx — MCP Security Risks (업체 발표, 참고용)
- NVD — CVE 검색 (MCP 관련 취약점 개별 확인용)