MCP 서버 구현, 2026년 5월 기준 “Claude용 에이전트 확장 서버”를 프로덕션에 올리는 법
들어가며
LLM/agent를 제품에 붙이다 보면 매번 같은 벽을 만납니다. “모델은 똑똑한데, 우리 시스템(DB/사내 API/권한/감사로그/업무 규칙) 안으로 안전하게 들어오게 하려면?” 대부분 팀이 모델별 custom tool-calling이나 SDK별 플러그인을 각자 구현하다가, 클라이언트(Claude Desktop/Claude Code/Cursor/VS Code/ChatGPT 등)와 툴 서버 간 결합이 폭발합니다.
Model Context Protocol(MCP)는 이 지점을 겨냥합니다. “도구/컨텍스트 제공”을 표준화해, 한 번 MCP server를 만들면 여러 MCP client가 재사용하게 하는 구조입니다. Anthropic 문서에서도 Claude Code가 MCP로 외부 툴/데이터소스에 연결된다고 명시합니다. (docs.anthropic.com)
언제 쓰면 좋나?
- 여러 클라이언트(Claude Code, Claude Desktop, Cursor 등)에서 동일한 사내 도구 묶음을 공유하고 싶을 때
- 툴 호출을 권한/감사/레이트리밋/안전장치까지 포함해 “서버 경계”로 만들고 싶을 때
- 로컬 자동화(파일/깃/스크립트)와 원격 SaaS(API) 통합을 같은 인터페이스로 묶고 싶을 때
언제 쓰면 안 되나?
- 단일 앱 내부에서만 쓰는 단발성 tool-calling이면, 굳이 MCP로 “서버/스키마/버전”까지 운영하는 비용이 과합니다.
- 강한 보안 요구(특히 로컬 stdio 기반)에서 프로세스 실행/권한 위임을 제대로 샌드박싱할 자신이 없다면, MCP 도입이 오히려 공격면을 키울 수 있습니다. 2026년에는 MCP 생태계 전반에 보안 이슈(악성 서버/설계 취약점 논란)가 공개적으로 논의되었습니다. (techradar.com)
🔧 핵심 개념
1) MCP의 구성요소: Tool / Resource / Prompt + Transport
MCP 서버는 대체로 세 가지 “제공물”을 노출합니다.
- Tools: 모델이 실행을 요청하는 동작(부작용 가능: 네트워크 호출/DB 업데이트 등)
- Resources: 읽기 전용 데이터(문서/레코드/설정 등)
- Prompts: 재사용 가능한 템플릿(일관된 작업 지시)
TypeScript SDK 문서도 이 3요소를 핵심으로 잡고 있고, 서버는 McpServer로 만들고 transport를 붙입니다. (ts.sdk.modelcontextprotocol.io)
Transport는 2026년 기준 실무적으로 두 축입니다.
- stdio: 클라이언트가 서버를 로컬 서브프로세스로 실행(개발 편함, 권한 문제 큼)
- Streamable HTTP: 원격 서버 운영의 사실상 표준(권장) (ts.sdk.modelcontextprotocol.io)
2) 내부 동작 흐름(“서버는 에이전트의 확장 런타임”)
실전에서 중요한 건 “Tool 호출이 단순 RPC가 아니라, 에이전트 루프의 일부”라는 점입니다.
대략 흐름은:
- MCP client가 서버에 initialize(프로토콜 버전/기능 협상)
- client가 서버에서 제공하는 tool 목록/스키마를 “모델에게” 알려줌
- 모델이 특정 tool을 선택해 호출(입력은 JSON schema 기반)
- 서버는 실제 업무 시스템 호출 → 결과 반환
- 결과가 다시 대화 컨텍스트로 들어가 다음 추론에 영향
Streamable HTTP는 여기에 session id(헤더 MCP-Session-Id)와 프로토콜 버전 헤더(MCP-Protocol-Version) 같은 운영 요소가 붙습니다. 이게 “로드밸런싱/관측/리줌” 설계를 좌우합니다. (modelcontextprotocol.io)
3) 2026년 5월 관점에서 “왜 지금 MCP 서버를 다시 설계해야 하나”
2026년 MCP는 단순 로컬 툴 연결을 넘어 원격/엔터프라이즈 운영으로 이동 중입니다. 공식 블로그에서 “HTTP 인프라에서 스케일”을 강조하고, 2026 로드맵/RC에서 stateless core, OAuth/OIDC 정렬, Tasks 같은 확장 방향이 언급됩니다. (blog.modelcontextprotocol.io)
즉, “내가 만드는 MCP server”는 이제 로컬 스크립트 래퍼가 아니라:
- 멀티테넌트/권한
- 감사로그/레이트리밋
- 세션/재시도/타임아웃
- 위험도 표시(어노테이션)
까지 포함한 agent extension server로 보는 게 맞습니다. (툴 어노테이션이 리스크 vocabulary로 다뤄지는 것도 같은 맥락) (blog.modelcontextprotocol.io)
💻 실전 코드
아래는 “현실적인 시나리오”로, 사내 티켓 시스템(Jira 유사) + Runbook(문서) + 승인 게이트를 하나의 MCP server로 묶습니다.
목표:
search_tickets(read-only): 최근 이슈 검색create_deploy_task(side-effect): 배포 태스크 생성(단, 운영환경은 approval 필요)- Streamable HTTP로 원격 운영(Claude Desktop/Claude Code/기타 클라이언트에서 연결 가능)
1) 초기 셋업
mkdir mcp-ops-server && cd mcp-ops-server
npm init -y
npm install @modelcontextprotocol/sdk zod express pino undici
npm install -D typescript tsx @types/node @types/express
npx tsc --init
2) 서버 구현 (Streamable HTTP + 안전장치)
// src/server.ts
import express from "express";
import pino from "pino";
import { z } from "zod";
import { request } from "undici";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
const log = pino({ level: process.env.LOG_LEVEL ?? "info" });
/**
* 현실 포인트:
* - "툴 구현"은 결국 외부 API 호출 + 정책(권한/승인/레이트리밋)이다.
* - 모델이 임의로 prod 배포 같은 부작용을 일으키면 끝장나므로, 서버에서 강제 게이트를 둔다.
*/
const Env = z.object({
PORT: z.coerce.number().default(8787),
TICKETS_API_BASE: z.string().url(),
TICKETS_API_TOKEN: z.string().min(1),
DEPLOY_API_BASE: z.string().url(),
DEPLOY_API_TOKEN: z.string().min(1),
// 예: "prod"면 승인 필요
REQUIRE_APPROVAL_FOR_ENVS: z.string().default("prod"),
// 간단한 shared secret (실무는 OAuth/OIDC 권장)
MCP_AUTH_TOKEN: z.string().min(1),
});
const env = Env.parse(process.env);
type AuthedContext = { actor: string };
// 간단 auth: 헤더 토큰 → actor 식별
function authFromReq(req: express.Request): AuthedContext {
const token = req.header("authorization")?.replace(/^Bearer\s+/i, "");
if (!token || token !== env.MCP_AUTH_TOKEN) {
throw Object.assign(new Error("Unauthorized"), { statusCode: 401 });
}
// 실무: 토큰에 사용자/팀/스코프 클레임 넣고 RBAC 결정
return { actor: "mcp-client" };
}
// 외부 API helper
async function httpJson<T>(url: string, options: any): Promise<T> {
const res = await request(url, {
...options,
headers: { "content-type": "application/json", ...(options.headers ?? {}) },
});
if (res.statusCode >= 400) {
const body = await res.body.text();
throw new Error(`HTTP ${res.statusCode} ${url}: ${body}`);
}
return (await res.body.json()) as T;
}
const server = new McpServer({
name: "ops-extension",
version: "2026.05.0",
});
// Tool 1) 티켓 검색 (read-only)
server.tool(
"search_tickets",
"Search recent tickets by text and status. Returns compact fields for agent routing.",
{
q: z.string().min(2).describe("Search query"),
status: z.enum(["open", "in_progress", "done", "any"]).default("any"),
limit: z.number().int().min(1).max(20).default(10),
},
async ({ q, status, limit }) => {
const url = new URL("/v1/tickets/search", env.TICKETS_API_BASE);
url.searchParams.set("q", q);
url.searchParams.set("status", status);
url.searchParams.set("limit", String(limit));
const data = await httpJson<{ items: Array<any> }>(url.toString(), {
method: "GET",
headers: { authorization: `Bearer ${env.TICKETS_API_TOKEN}` },
});
// 모델에 과다 데이터를 주지 않도록 “요약 필드만”
const items = data.items.map((t) => ({
id: t.id,
title: t.title,
status: t.status,
priority: t.priority,
updatedAt: t.updated_at,
url: t.url,
}));
return {
content: [
{
type: "text",
text: JSON.stringify({ items }, null, 2),
},
],
};
}
);
// Tool 2) 배포 태스크 생성 (side-effect + 승인 게이트)
server.tool(
"create_deploy_task",
"Create a deploy task. For prod, require explicit approvalToken issued by your change-management system.",
{
service: z.string().min(1),
gitRef: z.string().min(3).describe("Commit SHA or tag"),
env: z.enum(["dev", "staging", "prod"]),
changeReason: z.string().min(10),
approvalToken: z.string().optional().describe("Required for prod"),
},
async (input) => {
const requireApprovalEnvs = new Set(
env.REQUIRE_APPROVAL_FOR_ENVS.split(",").map((s) => s.trim())
);
if (requireApprovalEnvs.has(input.env) && !input.approvalToken) {
// 핵심: “모델에게 다시 물어보게” 만들지 말고,
// 서버에서 명확한 에러 계약을 반환해 워크플로우를 강제.
return {
isError: true,
content: [
{
type: "text",
text:
"approvalToken is required for this environment. " +
"Obtain approval via change-management flow and retry.",
},
],
};
}
const task = await httpJson<{ id: string; url: string }>(
`${env.DEPLOY_API_BASE}/v1/deploy/tasks`,
{
method: "POST",
headers: { authorization: `Bearer ${env.DEPLOY_API_TOKEN}` },
body: JSON.stringify({
service: input.service,
gitRef: input.gitRef,
env: input.env,
reason: input.changeReason,
approvalToken: input.approvalToken ?? null,
}),
}
);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
created: true,
taskId: task.id,
url: task.url,
},
null,
2
),
},
],
};
}
);
// Express 앱 (DNS rebinding protection 포함 옵션이 기본으로 켜짐)
const app = createMcpExpressApp({
// host 바인딩에 따라 보호가 달라질 수 있으니 운영 시 의식적으로 결정
host: "0.0.0.0",
});
// 공통 미들웨어: auth + 로깅
app.use((req, res, next) => {
try {
const ctx = authFromReq(req);
(req as any).mcpActor = ctx.actor;
next();
} catch (e: any) {
res.status(e.statusCode ?? 500).json({ error: e.message ?? "error" });
}
});
app.use((req, _res, next) => {
log.info(
{
method: req.method,
path: req.path,
session: req.header("mcp-session-id"),
actor: (req as any).mcpActor,
},
"mcp_request"
);
next();
});
// MCP 엔드포인트 연결
app.post("/mcp", async (req, res) => {
await server.handleHttpRequest(req, res);
});
app.get("/mcp", async (req, res) => {
await server.handleHttpRequest(req, res);
});
app.listen(env.PORT, () => {
log.info({ port: env.PORT }, "MCP ops-extension listening");
});
실행:
export PORT=8787
export TICKETS_API_BASE="https://tickets.example.com"
export TICKETS_API_TOKEN="..."
export DEPLOY_API_BASE="https://deploy.example.com"
export DEPLOY_API_TOKEN="..."
export MCP_AUTH_TOKEN="dev-secret"
npm run -s build || true
npx tsx src/server.ts
예상 로그(요약):
{"level":"info","port":8787,"msg":"MCP ops-extension listening"}
{"level":"info","method":"POST","path":"/mcp","session":"...","actor":"mcp-client","msg":"mcp_request"}
3) Claude Code에 붙이는 방법(프로젝트 스코프 예시)
Claude Code는 .mcp.json에 서버를 등록할 수 있고, 팀 공유는 project scope가 핵심입니다. (docs.anthropic.com)
// .mcp.json
{
"mcpServers": {
"ops-extension": {
"type": "sse",
"url": "http://localhost:8787/mcp",
"headers": {
"Authorization": "Bearer ${MCP_AUTH_TOKEN}"
}
}
}
}
(주의) 실제로 어떤 클라이언트는 Streamable HTTP의 특정 모드/호환성에 민감할 수 있어, 클라이언트 요구 transport 설정을 확인해야 합니다. 프로토콜/클라이언트 버전 불일치로 서버가 깨지는 사례도 커뮤니티에서 보고됩니다(예: 프로토콜 버전 변경). (reddit.com)
⚡ 실전 팁 & 함정
Best Practice (프로덕션에서 체감 큰 것들)
1) “툴 계약”을 좁혀라: 결과는 요약 필드만
- MCP는 모델 컨텍스트로 결과가 들어가 비용을 만듭니다.
- 티켓 검색 결과처럼 “모델 라우팅에 필요한 최소 필드”만 주고, 상세는
get_ticket_detail같은 별도 툴로 쪼개세요.
2) Side-effect 툴은 서버에서 정책 강제(approval, RBAC, rate limit)
- “프롬프트로 조심하라”는 안전장치가 아닙니다.
- 위 코드처럼 prod에선
approvalToken없으면 실패시키는 식으로 서버 경계에서 제어하세요.
3) Streamable HTTP에서 세션을 ‘상태’로 쓰지 말고 ‘힌트’로 써라
- spec상 session id는 가능하지만, 운영/스케일을 생각하면 서버를 가능하면 stateless로 유지하고(캐시/DB로 상태 외부화), 세션은 관측/리줌 정도로만 쓰는 게 안전합니다. (modelcontextprotocol.io)
흔한 함정/안티패턴
- stdio 기반 “로컬 만능 서버”를 무분별하게 배포: MCP 생태계는 로컬 프로세스 실행과 권한 문제가 자주 논의됩니다. 실제로 보안 논란(설계상 RCE 가능성 주장, 악성 MCP 서버 사례)이 공개되었습니다. (tomshardware.com)
→ 최소한: allowlist, 샌드박싱, 네트워크 egress 제한, 토큰 최소권한, 마켓/레포 검증이 필요합니다. - 버전 핀닝 없이 SDK/프로토콜 업데이트: 클라이언트가 프로토콜 버전을 바꾸면(헤더
MCP-Protocol-Version) 예전 서버/SDK가 깨질 수 있습니다. (modelcontextprotocol.io) - Tool 이름/스키마가 자주 바뀌는 서버: 에이전트는 “학습된 사용 습관”을 갖기 때문에, 스키마 변동은 곧 품질 하락입니다. 변경은 deprecation 정책을 두고 천천히.
비용/성능/안정성 트레이드오프
- 원격 MCP server는 결국 API 제품입니다: p95 latency, 타임아웃, 재시도, 부분 실패 설계가 없으면 에이전트 경험이 급격히 나빠집니다.
- “모델이 한 번에 다 해결”하려 할수록 tool 호출 횟수/토큰이 늘어납니다 → 작은 도구 여러 개 vs 큰 도구 한 개의 균형을 잡아야 합니다(보통은 “작게 쪼개되, 흔한 작업은 합성 도구 1개”가 운영에 유리).
🚀 마무리
2026년 5월 기준 MCP는 “도구 연결 표준”을 넘어, 에이전트 확장 서버를 제품화하는 쪽으로 무게중심이 이동 중입니다(원격/HTTP 스케일, 확장(Tasks/Apps) 논의, 기업 채택 확대). (blog.modelcontextprotocol.io)
도입 판단 기준은 간단합니다.
- 여러 클라이언트/에이전트가 동일한 툴을 공유해야 하고
- 권한/승인/감사/안전장치를 “프롬프트”가 아니라 “서버 경계”에서 강제해야 하며
- 장기적으로 툴 생태계를 확장할 계획이 있다면
→ MCP server로 가는 게 맞습니다.
다음 학습 추천(실무 순서):
1) MCP spec의 transport/session/version 헤더를 먼저 정독(운영 이슈의 80%가 여기서 납니다). (modelcontextprotocol.io)
2) TypeScript SDK의 Streamable HTTP 예제 기반으로 “auth/observability/rate limit”을 넣어 프로덕션 템플릿화. (ts.sdk.modelcontextprotocol.io)
3) Claude Code 쪽에서는 프로젝트 스코프 .mcp.json로 팀 배포 + 승인 UX를 설계. (docs.anthropic.com)
원하면, 위 예제를 (1) OAuth/OIDC로 교체하거나 (2) MCP Mediator(서버가 다른 MCP 서버를 호출하는 패턴) 형태로 확장(사내 “툴 허브”)하는 설계까지 이어서 작성해드릴게요. (arxiv.org)