MCP 서버로 레거시 API를 Claude Code에 연결하기 — TypeScript SDK로 커스텀 서버 처음부터 구현하기
회사에 5년 된 Java 기반 주문 관리 API가 있다고 가정해 봅시다. "이번 스프린트 미결 주문 건 다 가져와서 코드 고쳐줘" 같은 자연어 지시를 Claude Code에 먹이고 싶은데, AI가 사내망 API를 어떻게 알아서 건드릴지 감이 안 옵니다. 저도 처음엔 딱 그 상태였어요. 그 다리 역할을 하는 게 MCP(Model Context Protocol)입니다.
MCP는 Anthropic이 2024년 말 공개한 오픈 표준 프로토콜로, AI 모델이 외부 도구·데이터·API와 상호작용하는 방식을 표준화합니다. 기존엔 팀마다 사내 API용 스크립트나 독자적인 래퍼를 따로 만들었는데, MCP를 쓰면 서버 하나로 Claude Code·Cursor·Windsurf를 동시에 지원합니다. 커스텀 MCP 서버를 한 번 만들어 두면, Claude Code가 사내 레거시 API, 프라이빗 DB, 독자적인 워크플로우 도구를 직접 다루는 팀의 AI 어시스턴트로 탈바꿈합니다.
이 글에서는 TypeScript SDK로 MCP 서버를 처음부터 만드는 과정을 다룹니다. 프로젝트 초기화부터 tool 등록, Claude Code 연결, 그리고 실무에서 놓치기 쉬운 보안 포인트까지 순서대로 살펴보겠습니다. Resources·Prompts 프리미티브도 잠깐 소개하지만, 이번 글은 가장 즉시 써먹을 수 있는 Tools에 집중합니다. Resources와 Prompts 활용은 별도 편에서 다룰 예정입니다.
핵심 개념
MCP 아키텍처: 세 계층을 머릿속에 그려두면
솔직히 처음에 이 구조를 이해하는 데 시간을 좀 쏟았는데, 한 번 잡히면 이후가 훨씬 수월합니다.
| 계층 | 역할 | 예시 |
|---|---|---|
| Host | MCP를 소비하는 AI 애플리케이션 | Claude Code, Claude Desktop |
| Client | Host 내부에서 MCP 서버와 통신하는 커넥터 | 프로토콜 핸들러 (내부 구현) |
| Server | 개발자가 직접 구현하는 서버 | 오늘 우리가 만드는 것 |
우리가 집중할 부분은 Server 계층입니다. 이 서버는 세 가지 프리미티브를 Claude에 노출합니다.
| 프리미티브 | 역할 | 실무 예시 |
|---|---|---|
| Tools | Claude가 호출하는 함수·액션 | DB 조회, API 호출, 배포 트리거 |
| Resources | Claude가 읽는 데이터 소스 | 팀 위키, DB 스키마, 설정 파일 |
| Prompts | 재사용 가능한 프롬프트 템플릿 | 코드 리뷰 양식, 버그 리포트 형식 |
통신 방식 MCP는 JSON-RPC 2.0 위에서 동작합니다. SDK가 이 프로토콜 처리를 알아서 해주기 때문에 직접 다룰 일은 없지만, 로컬 개발 환경에서는
stdio(서브프로세스 표준 입출력), 팀 공유 원격 서버에는Streamable HTTP를 씁니다. 2025년 3월 스펙 업데이트에서 기존 HTTP+SSE 방식이 Streamable HTTP로 대체됐으니, 새로 만든다면 처음부터 Streamable HTTP를 선택하는 편이 낫습니다.
프로젝트 설정
먼저 패키지를 설치해 줍니다. 예시 2에서 DB 직접 접근이 필요하므로 postgres도 함께 설치합니다. postgres는 node-postgres 기반의 경량 SQL 클라이언트입니다. Prisma나 Drizzle 같은 ORM을 쓰는 환경이라면 해당 패키지로 교체해서 사용할 수 있어요.
mkdir my-mcp-server && cd my-mcp-server
pnpm init
pnpm add @modelcontextprotocol/sdk zod postgres
pnpm add -D typescript @types/node tsx여기서 zod는 TypeScript 런타임 스키마 검증 라이브러리입니다. MCP SDK가 tool 입력값을 검증할 때 Zod 스키마를 그대로 활용하기 때문에 같이 설치해 줍니다.
tsconfig.json은 이렇게 잡아주면 됩니다.
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}package.json에 "type": "module"과 빌드 스크립트도 추가해 줍니다.
{
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
}MCP 서버의 기본 골격
서버 뼈대는 생각보다 간단합니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "my-legacy-api",
version: "1.0.0",
});
// tool 등록은 아래 실전 예시에서 다룹니다
const transport = new StdioServerTransport();
await server.connect(transport);McpServer 인스턴스를 만들고, tool을 등록하고, transport에 연결하는 세 단계가 전부입니다.
SDK 버전 참고 아래 예시는
@modelcontextprotocol/sdkv1.x 기준으로server.tool()API를 사용합니다. 일부 구버전 튜토리얼에서server.registerTool()을 쓰는 경우가 있는데, 현재 SDK 공식 문서는server.tool()기준입니다. 코드를 그대로 복사했을 때 동작하지 않는다면 SDK 버전을 먼저 확인해 보시면 좋습니다.
실전 적용
예시 1: 사내 레거시 REST API를 Tool로 감싸기
실무에서 가장 흔한 패턴입니다. 구형 Java 서비스나 PHP 레거시 API 앞에 MCP 레이어를 얹는 방식이에요.
서버 시작 시점에 필수 환경변수를 먼저 검증해 두면, process.env.API_KEY가 없는 상태로 서버가 조용히 뜨는 상황을 막을 수 있습니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// 서버 시작 전 필수 환경변수 검증
const requiredEnvVars = ["API_BASE_URL", "API_KEY"] as const;
for (const key of requiredEnvVars) {
if (!process.env[key]) {
throw new Error(`환경변수 ${key}가 설정되지 않았습니다`);
}
}
const API_BASE = process.env.API_BASE_URL as string;
const server = new McpServer({
name: "order-management-api",
version: "1.0.0",
});
// 주문 조회 tool
server.tool(
"get_order",
{
description:
"주문 ID로 주문 상세 정보를 조회합니다. 상태, 품목, 배송 정보를 포함합니다.",
inputSchema: {
orderId: z.string().describe("조회할 주문 ID (예: ORD-2024-00123)"),
},
},
async ({ orderId }) => {
try {
const res = await fetch(`${API_BASE}/orders/${orderId}`, {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
});
if (!res.ok) {
return {
content: [
{
type: "text",
text: `주문 조회 실패: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = await res.json();
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
} catch (err) {
// 네트워크 타임아웃, DNS 실패 등 fetch 자체가 던지는 에러 처리
return {
content: [
{ type: "text", text: `네트워크 오류: ${(err as Error).message}` },
],
isError: true,
};
}
}
);
// 주문 상태 업데이트 tool
server.tool(
"update_order_status",
{
description: "주문의 처리 상태를 변경합니다.",
inputSchema: {
orderId: z.string(),
status: z.enum(["processing", "shipped", "delivered", "cancelled"]),
note: z.string().optional().describe("상태 변경 사유 (선택)"),
},
},
async ({ orderId, status, note }) => {
try {
const res = await fetch(`${API_BASE}/orders/${orderId}/status`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ status, note }),
});
if (!res.ok) {
return {
content: [
{
type: "text",
text: `상태 변경 실패: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const result = await res.json();
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
} catch (err) {
return {
content: [
{ type: "text", text: `네트워크 오류: ${(err as Error).message}` },
],
isError: true,
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);MCP 서버는 Claude가 자동으로 호출하기 때문에, 예외 처리 없이 프로세스가 죽으면 Claude 세션 전체가 멈춥니다. 모든 async 핸들러에 try/catch를 감싸두는 게 중요한 이유가 여기에 있어요.
| 포인트 | 설명 |
|---|---|
description |
Claude가 도구를 언제 쓸지 판단하는 기준이 됩니다. 구체적일수록 좋습니다 |
z.enum(...) |
허용 값을 enum으로 제한해 잘못된 입력을 차단합니다 |
isError: true |
오류 응답을 Claude에 명확히 전달해 재시도 로직을 도울 수 있습니다 |
process.env.* |
크레덴셜은 반드시 환경변수로 주입합니다. 코드에 하드코딩하면 git에 올라가는 순간 되돌리기가 어렵습니다 |
예시 2: DB를 직접 쿼리하는 Tool (보안 강화 버전)
DB에 직접 접근하는 tool은 SQL 인젝션 위험이 있어서 특히 조심해야 합니다. 저도 처음엔 "어차피 내부망이니까 괜찮겠지" 했다가 팀 코드 리뷰에서 된통 혼난 경험이 있어요. Parameterized query와 허용 목록 방식을 같이 쓰는 것이 안전합니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import postgres from "postgres";
// DATABASE_URL 없이 시작하면 바로 에러를 낼 수 있도록
if (!process.env.DATABASE_URL) {
throw new Error("환경변수 DATABASE_URL이 설정되지 않았습니다");
}
const sql = postgres(process.env.DATABASE_URL);
const server = new McpServer({ name: "db-query-server", version: "1.0.0" });
server.tool(
"query_orders",
{
description: "주문 테이블에서 조건에 맞는 주문 목록을 조회합니다.",
inputSchema: {
status: z
.enum(["pending", "processing", "shipped", "delivered"])
.optional(),
limit: z.number().int().min(1).max(100).default(20),
},
},
async ({ status, limit }) => {
try {
// 동적 WHERE절도 Parameterized query로 처리
const rows = status
? await sql`SELECT * FROM orders WHERE status = ${status} LIMIT ${limit}`
: await sql`SELECT * FROM orders LIMIT ${limit}`;
return {
content: [
{
type: "text",
text: `총 ${rows.length}건 조회됨\n\n${JSON.stringify(rows, null, 2)}`,
},
],
};
} catch (err) {
return {
content: [
{ type: "text", text: `DB 조회 오류: ${(err as Error).message}` },
],
isError: true,
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);postgres 라이브러리는 템플릿 리터럴 문법으로 쿼리를 작성하면 자동으로 Parameterized query로 변환해 줍니다. Prisma나 Drizzle 같은 ORM을 이미 쓰고 있다면 그걸 그대로 가져다 써도 됩니다. 이 예시는 가장 의존성이 적은 방식을 보여주는 것뿐이에요.
예시 3: Claude Code에 MCP 서버 등록하고 팀과 공유하기
서버를 빌드한 뒤 Claude Code에 등록하는 방법입니다.
# 빌드
pnpm build
# 내 로컬 환경에만 등록
claude mcp add my-order-api -- node /absolute/path/to/dist/index.js
# 팀 전체와 공유 (프로젝트 루트에 .mcp.json 생성)
claude mcp add --scope project my-order-api -- node dist/index.js프로젝트 루트에 생성되는 .mcp.json을 git에 커밋하면 팀원 모두가 동일한 MCP 설정을 바로 씁니다.
{
"mcpServers": {
"my-order-api": {
"command": "node",
"args": ["./mcp-server/dist/index.js"],
"env": {
"API_BASE_URL": "https://internal-api.company.com",
"API_KEY": "${MY_INTERNAL_API_KEY}"
}
}
}
}MCP Inspector 개발 중에 tool이 제대로 동작하는지 확인할 때는
npx @modelcontextprotocol/inspector를 활용해 볼 수 있습니다. Postman처럼 브라우저 UI에서 tool·resource·prompt를 직접 호출해 JSON-RPC 수준의 응답을 확인할 수 있어서 개발 속도가 꽤 올라갑니다. 기본 포트는 UI 6274, 프록시 6277입니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 표준화 | Claude, Cursor, Windsurf 등 MCP 호환 클라이언트 어디서든 동일한 서버를 재사용할 수 있습니다 |
| 레거시 연결 | 기존 REST/SOAP API에 MCP 레이어를 얹기만 하면 AI 접근이 가능해집니다. 레거시를 리라이트할 필요가 없습니다 |
| 타입 안전성 | Zod 스키마로 tool 입력을 런타임에 검증하고, TypeScript strict mode와 시너지가 납니다 |
| 팀 협업 | .mcp.json을 git에 커밋하면 팀 전체가 동일한 AI 도구 환경을 공유합니다 |
| 벤더 독립성 | 특정 AI 플랫폼에 종속되지 않아 플랫폼 전환 비용이 낮습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 운영 오버헤드 | MCP 서버가 수십 개로 늘어나면 배포·모니터링이 복잡해집니다 | 도메인 단위로 서버를 통합하는 것이 효과적입니다. 저희 팀은 주문·배송·정산을 세 서버로 묶었더니 관리 부담이 확 줄었어요 |
| 보안 리스크 | 잘못 설정된 MCP 서버에서 API 키·비밀번호가 대량으로 노출된 사례가 꾸준히 보고됩니다 | 크레덴셜은 환경변수로 주입하고, 코드에는 절대 하드코딩하지 않습니다 |
| 프롬프트 인젝션 | 악의적 데이터가 MCP tool을 통해 Claude에 주입될 수 있습니다 | tool 응답 데이터를 신뢰하지 않는 외부 입력으로 취급하는 것이 안전합니다 |
| 프로토콜 변화 속도 | HTTP+SSE → Streamable HTTP 전환처럼 스펙이 빠르게 바뀝니다 | SDK 릴리즈 노트를 주기적으로 확인하고 변경 사항을 팀에 공유하는 것이 좋습니다 |
| 도구 호출 일관성 | 다단계 작업에서 LLM이 tool을 일관성 없이 호출하는 경우가 있습니다 | description을 구체적으로 작성하고 tool의 역할 범위를 명확히 나누면 훨씬 나아집니다 |
Confused Deputy 문제 실제로 일어납니다. "주문 조회" tool이 내부적으로 삭제 권한까지 가진 API 키를 쓰는 경우, Claude가 의도치 않게 삭제 작업을 실행해버릴 수 있습니다. 이런 일이 생기면 되돌리기 어렵습니다. tool별로 최소 권한 원칙을 적용하는 게 맞습니다. 읽기 전용 tool에는 읽기 전용 API 키를, 쓰기 tool에는 별도로 발급된 쓰기 키를 쓰는 구조가 현실적으로 안전합니다.
실무에서 가장 흔한 실수
-
description을 대충 쓰는 것: Claude는 description을 보고 어떤 tool을 쓸지 결정합니다. "데이터 조회" 같은 모호한 설명보다 "주문 ID로 주문 상태·품목·배송 추적 정보를 조회합니다"처럼 구체적으로 적어야 Claude가 올바른 상황에서 호출합니다. -
크레덴셜을 코드에 하드코딩하는 것:
.mcp.json의env필드를 활용해 환경변수로 주입하면 됩니다. git에 API 키가 한 번 올라가면 히스토리에서 완전히 제거하기가 생각보다 훨씬 번거롭습니다. -
입력 검증 없이 Shell이나 SQL에 직접 전달하는 것:
z.enum()으로 허용 목록을 제한하고, DB 쿼리는 반드시 Parameterized query를 사용하는 것이 안전합니다. "내부망이라 괜찮겠지"는 저도 한 번 믿었다가 후회한 가정입니다.
마치며
MCP 서버를 직접 구현하면, Claude Code가 사내 레거시 시스템까지 이해하고 실제로 조작하는 팀의 AI 어시스턴트로 바뀝니다. 레거시를 리라이트할 필요 없이, TypeScript로 얇은 어댑터 레이어만 만들면 됩니다.
지금 바로 시작해볼 수 있는 3단계입니다.
-
개발 환경 초기화:
pnpm add @modelcontextprotocol/sdk zod로 패키지를 설치하고, 위의tsconfig.json설정을 그대로 복사해서 붙여넣어 볼 수 있습니다. -
첫 번째 tool 등록: 팀에서 가장 자주 쓰는 사내 API 하나를 골라서
server.tool()로 감싸볼 수 있습니다.npx @modelcontextprotocol/inspector를 띄워두면 브라우저에서 바로 동작을 확인할 수 있습니다. -
Claude Code에 연결:
claude mcp add --scope project [이름] -- node dist/index.js로 프로젝트에 등록하고,.mcp.json을 팀 git 저장소에 커밋하면 팀원 모두가 같은 환경을 씁니다.
처음으로 Claude에게 자연어로 지시해서 사내 레거시 시스템이 움직이는 순간, 꽤 묘한 느낌이 납니다. 그 경험을 직접 해보시면 좋겠습니다. 그리고 tool이 늘어나면서 어떻게 구조를 잡고 팀 전체가 하나의 서버를 공유하는 환경을 만드는지, 그 부분에서 새로운 고민이 시작됩니다.
참고 자료
- Model Context Protocol 공식 문서 — Build an MCP Server
- MCP TypeScript SDK 공식 문서
- modelcontextprotocol/typescript-sdk (GitHub)
- Connect Claude Code to tools via MCP — Claude Code 공식 문서
- Remote MCP servers — Claude API 공식 문서
- FreeCodeCamp — How to Build a Custom MCP Server with TypeScript
- Build an MCP Server in TypeScript: From Scratch 2026 — Digital Applied
- Building MCP Servers: Custom Context for Claude Code — SitePoint
- MCP Inspector — 공식 문서
- MCP Security Best Practices — modelcontextprotocol.io
- Model Context Protocol Security Risks and Controls — Red Hat
- The Pros and Cons of Adopting MCP Today — Knit