MCP 서버 개발 실전 가이드 — 사내 API와 DB를 도메인 전문 에이전트로 전환하기
이 글을 다 읽고 나면, 사내 REST API 또는 DB 스키마를 MCP 서버로 노출해 범용 LLM을 즉시 도메인 전문 에이전트로 전환하는 코드를 직접 실행할 수 있다.
범용 LLM은 강력하지만 치명적인 한계가 있다. 우리 회사의 내부 API 구조, 비공개 데이터베이스 스키마, 사내 업무 용어를 모른다는 것이다. 이 간극을 메우기 위해 지금까지는 긴 시스템 프롬프트를 손으로 작성하거나, RAG 파이프라인을 별도로 구성하거나, Fine-tuning을 시도해왔다. 하지만 이 모든 방법은 유지보수 부담이 크고, 실시간 데이터에 취약하다.
Model Context Protocol(MCP)은 LLM을 건드리지 않고, 에이전트가 실행 시점에 외부 컨텍스트를 가져올 수 있는 표준화된 서버-클라이언트 프로토콜이다. 직접 서버를 만드는 순간, 범용 LLM이 우리 조직의 전문 에이전트로 탈바꿈한다. 이 글에서는 핵심 개념부터 Python/TypeScript SDK를 활용한 실전 구현까지 단계별로 다룬다.
핵심 개념
MCP 아키텍처: Client-Server-Transport
MCP는 세 레이어로 구성된다.
[LLM Host / MCP Client] ←→ [MCP Server] ←→ [Domain Data / APIs]
(Claude, Cursor 등) (당신이 만드는 것) (사내 DB, REST API 등)| 레이어 | 역할 | 예시 |
|---|---|---|
| MCP Client | LLM 호스트에 내장, 서버와 통신 | Claude Desktop, Cursor, 커스텀 에이전트 |
| MCP Server | 도메인 데이터를 표준 인터페이스로 노출 | 직접 구축하는 부분 |
| Transport | 클라이언트-서버 통신 방식 | stdio(로컬), SSE(원격 HTTP) |
내부 통신 프로토콜은 JSON-RPC 2.0이다. 모든 요청/응답이 JSON 형태로 직렬화되어 Transport 위에서 흐른다.
3가지 핵심 프리미티브
MCP 서버가 클라이언트에 노출할 수 있는 기본 단위는 세 가지다.
| 프리미티브 | 설명 | 주도권 | 예시 |
|---|---|---|---|
| Tools | LLM이 호출할 수 있는 함수 | LLM (모델이 판단해서 호출) | API 조회, DB 쿼리, 파일 저장 |
| Resources | 컨텍스트로 제공되는 데이터 | 클라이언트 (사용자 또는 앱이 요청) | DB 스키마, 설정 파일, 문서 |
| Prompts | 재사용 가능한 프롬프트 템플릿 | 사용자 | 슬래시 커맨드, 워크플로 시작점 |
Tools vs Resources의 차이: Tools는 LLM이 "지금 이 함수를 써야겠다"고 판단해서 능동적으로 호출한다. Resources는 컨텍스트를 미리 제공하는 수동적인 데이터소스에 가깝다. 도메인 데이터가 자주 바뀌면 Resource, 행위(action)가 필요하면 Tool로 설계한다.
Transport 선택: stdio vs SSE
# stdio 방식 (로컬 프로세스)
claude-desktop ──stdin/stdout──> python mcp_server.py
# SSE 방식 (원격 HTTP)
agent ──HTTP GET /sse──> https://mcp.yourcompany.com| 방식 | 장점 | 단점 | 적합한 케이스 |
|---|---|---|---|
| stdio | 설정 간단, 추가 서버 불필요 | 단일 클라이언트, 로컬 한정 | 개발 환경, 개인 도구 |
| SSE | 다수 클라이언트, 원격 접근 | HTTPS·인증 필요, 복잡도 증가 | 팀 공유, 프로덕션 배포 |
인증/인가
MCP 스펙은 2025년 업데이트에서 OAuth 2.1 기반 인가 프레임워크를 추가했다. SSE(원격) 방식에서는 이 메커니즘을 활용하거나, Bearer 토큰·mTLS를 직접 구현해 보안 경계를 명확히 해야 한다. stdio(로컬) 방식은 OS 프로세스 격리에 의존하므로, 개발 환경에서는 추가 인증 없이 운영하는 경우가 일반적이다.
실전 적용
Python: 사내 REST API를 Tool로 등록하기
팀 내 이슈 트래커 API를 LLM이 직접 조회할 수 있도록 Tool로 등록하는 예시다.
Python 비동기 패턴 안내: 아래 코드는
asyncio기반 비동기 Python을 사용한다.async def,await,asyncio.run()이 낯설다면 Python asyncio 공식 문서를 먼저 확인하자.
pip install "mcp[cli]" httpx# server.py
import asyncio
import os
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
import httpx
app = Server("company-issue-tracker")
BASE_URL = "https://issues.yourcompany.com/api/v1"
def _auth_headers() -> dict:
token = os.environ["ISSUE_TRACKER_TOKEN"] # 환경 변수에서 토큰 로드
return {"Authorization": f"Bearer {token}"}
@app.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="get_issue",
description="이슈 트래커에서 특정 이슈 상세 정보를 조회합니다",
inputSchema={
"type": "object",
"properties": {
"issue_id": {
"type": "string",
"description": "조회할 이슈 ID (예: PROJ-123)"
}
},
"required": ["issue_id"]
}
),
types.Tool(
name="search_issues",
description="키워드로 이슈를 검색합니다",
inputSchema={
"type": "object",
"properties": {
"query": {"type": "string"},
"status": {
"type": "string",
"enum": ["open", "closed", "all"]
}
},
"required": ["query"]
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
try:
async with httpx.AsyncClient() as client:
if name == "get_issue":
resp = await client.get(
f"{BASE_URL}/issues/{arguments['issue_id']}",
headers=_auth_headers()
)
resp.raise_for_status()
return [types.TextContent(type="text", text=resp.text)]
elif name == "search_issues":
params = {"q": arguments["query"]}
if "status" in arguments:
params["status"] = arguments["status"]
resp = await client.get(
f"{BASE_URL}/issues/search",
params=params,
headers=_auth_headers()
)
resp.raise_for_status()
return [types.TextContent(type="text", text=resp.text)]
else:
return [types.TextContent(
type="text",
text=f"오류: '{name}'은 지원하지 않는 Tool입니다."
)]
except httpx.HTTPStatusError as e:
return [types.TextContent(
type="text",
text=f"API 오류 ({e.response.status_code}): {e.response.text}"
)]
except Exception as e:
return [types.TextContent(type="text", text=f"예상치 못한 오류: {str(e)}")]
async def main():
async with stdio_server() as streams:
await app.run(*streams, app.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())| 코드 포인트 | 설명 |
|---|---|
os.environ["ISSUE_TRACKER_TOKEN"] |
API 토큰을 환경 변수에서 로드. 코드에 하드코딩하면 보안 사고의 원인이 된다 |
try/except 블록 전체 |
Tool 실행 중 발생하는 모든 오류를 TextContent로 반환. 예외를 그대로 던지면 에이전트 파이프라인 전체가 중단된다 |
@app.list_tools() |
이 서버가 제공하는 Tool 목록을 선언. LLM이 어떤 도구를 쓸 수 있는지 이 정보로 판단한다 |
inputSchema |
JSON Schema 형식으로 파라미터 타입·필수값을 정의. LLM이 올바른 형태로 호출하도록 안내한다 |
Claude Desktop에서 사용하려면 ~/.claude/claude_desktop_config.json에 등록한다:
{
"mcpServers": {
"issue-tracker": {
"command": "python",
"args": ["/path/to/server.py"],
"env": {
"ISSUE_TRACKER_TOKEN": "your-token-here"
}
}
}
}TypeScript: PostgreSQL 스키마를 Resource로 실시간 제공하기
LLM이 SQL을 생성할 때 항상 최신 스키마 정보를 참조하도록 Resource로 제공하는 패턴이다.
pnpm add @modelcontextprotocol/sdk pg
pnpm add -D @types/pg tsxTypeScript에서 top-level await를 사용하려면 package.json에 "type": "module"이 반드시 필요하다:
{
"type": "module",
"scripts": {
"start": "tsx server.ts"
}
}// server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { Pool } from "pg";
const server = new Server(
{ name: "db-schema-provider", version: "1.0.0" },
{ capabilities: { resources: {} } }
);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// 사용 가능한 Resource 목록 반환
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const { rows } = await pool.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name
`);
return {
resources: rows.map(row => ({
uri: `db-schema://${row.table_name}`,
name: `${row.table_name} 테이블 스키마`,
mimeType: "application/json",
description: `${row.table_name} 테이블의 컬럼 구조 및 타입`
}))
};
});
// 특정 Resource 내용 반환
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const tableName = request.params.uri.replace("db-schema://", "");
const { rows } = await pool.query(`
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position
`, [tableName]);
return {
contents: [{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify({ table: tableName, columns: rows }, null, 2)
}]
};
});
// 종료 시 DB 연결 풀 정리
process.on("SIGINT", async () => { await pool.end(); process.exit(0); });
process.on("SIGTERM", async () => { await pool.end(); process.exit(0); });
const transport = new StdioServerTransport();
await server.connect(transport);이 패턴의 핵심은 실행 시점에 DB에서 실제 스키마를 조회한다는 점이다. 스키마가 변경되어도 MCP 서버 코드를 수정할 필요 없이 LLM이 항상 최신 정보를 받는다.
DB 연결 풀(Pool): 매 쿼리마다 새 DB 연결을 여닫는 대신, 연결 여러 개를 미리 만들어 재사용하는 방식이다. 성능과 안정성에 유리하며,
pg라이브러리의Pool은 Node.js에서 PostgreSQL 연결 관리의 사실상 표준이다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 표준화된 인터페이스 | Claude, Cursor, Zed 등 MCP를 지원하는 모든 클라이언트에서 동일한 서버 재사용 |
| LLM 독립성 | 서버 코드는 LLM 벤더에 종속되지 않음. Claude → GPT-4o 전환 시 서버 수정 불필요 |
| 실시간 컨텍스트 | 고정된 프롬프트 대신 실행 시점의 실제 데이터를 주입 |
| 보안 경계 명확 | 어떤 도구와 데이터를 노출할지 서버에서 명시적으로 제어 |
| 멀티 에이전트 재사용 | 하나의 MCP 서버를 여러 에이전트 파이프라인에서 공유 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 인증/인가 관리 | SSE 방식에서 인증 구현은 개발자 책임 | MCP OAuth 2.1 활용 또는 Bearer 토큰·mTLS 적용 |
| 레이턴시 증가 | Tool 호출마다 실제 API/DB 요청 발생 (실시간 데이터의 트레이드오프) | 응답 캐싱, 타임아웃 설정 |
| 버전 관리 | Tool 스키마 변경 시 기존 클라이언트 호환성 문제 | 버전 네이밍, Deprecation 플래그 활용 |
| 에러 전파 | 외부 서비스 장애가 에이전트 전체에 영향 | TextContent로 graceful 반환, 서킷 브레이커 패턴 |
에러를 TextContent로 반환하는 패턴은 다음과 같이 구현한다:
# 나쁜 예 — 예외가 던져지면 에이전트 파이프라인 전체가 중단된다
raise ValueError("API 호출 실패")
# 좋은 예 — LLM이 상황을 인지하고 다음 행동을 스스로 결정한다
return [types.TextContent(
type="text",
text="오류: API 호출 실패 (503). 잠시 후 다시 시도하세요."
)]서킷 브레이커(Circuit Breaker): 외부 서비스가 반복적으로 실패할 때 일정 시간 동안 요청을 차단해 시스템 전체 장애로 번지는 것을 막는 패턴. Python의
tenacity,pybreaker라이브러리로 구현할 수 있다.
실무에서 가장 흔한 실수
description을 소홀히 작성 — Tool의description과 파라미터 설명이 부실하면 LLM이 엉뚱한 도구를 호출하거나 잘못된 파라미터를 전달한다. 설명은 LLM이 읽는 API 문서다.- 에러를 예외(exception)로 던지기 — Tool 실행 중 오류가 발생했을 때 예외를 throw하면 에이전트 파이프라인 전체가 중단된다. 오류 상황도
TextContent로 의미 있는 메시지를 반환해 LLM이 상황을 인지하고 대응하도록 설계해야 한다. - 하나의 서버에 모든 도구를 욱여넣기 — 관련 없는 도구들을 하나의 MCP 서버에 몰아넣으면 LLM의 Tool 선택 정확도가 떨어진다. 도메인 단위로 서버를 분리하고, 필요한 서버만 클라이언트에 등록하는 것이 권장된다.
마치며
MCP는 "도메인 지식을 코드로 패키징해서 LLM에게 표준 인터페이스로 제공"하는 프로토콜이며, 직접 서버를 구축하는 순간 범용 AI가 우리 조직의 전문 에이전트로 탈바꿈한다.
지금 바로 시작할 수 있는 3단계:
- MCP SDK 설치 및 Hello World 서버 실행 —
pip install "mcp[cli]"후 공식 quickstart 예제로 stdio 서버 동작 확인 - 가장 자주 쓰는 사내 API 하나를 Tool로 등록 —
inputSchema와description을 충분히 상세히 작성하는 것이 핵심 - Claude Desktop
claude_desktop_config.json에 서버 등록 후 실제 대화에서 Tool 호출 확인
다음 글: MCP 서버를 Docker로 컨테이너화하고 SSE Transport로 전환해 팀 단위 프로덕션 배포를 구현하는 방법을 다룬다.