사내 레거시 API를 AI가 이해하는 MCP 서버로 만들기 — Node.js + Claude Code 연결 완전 가이드
레거시 ERP에 AI를 붙이려다 별도 플러그인 개발에 2주를 써본 경험이 있으신가요? 사내 REST API는 멀쩡히 돌아가는데, Claude나 GPT가 그 데이터를 직접 읽으려면 매번 커스텀 통합을 새로 만들어야 하는 상황 말입니다. Model Context Protocol(MCP)은 이 문제를 해결하는 범용 표준으로, 레거시 시스템 앞에 "AI가 이해하는 번역 레이어"를 얹는 가장 현실적인 방법입니다. Anthropic이 2024년 11월 오픈소스로 공개한 이후 출시 6개월 만에 월 SDK 다운로드 9,700만 건을 돌파했고, OpenAI·Google·Microsoft도 지원을 선언했습니다. 2025년에는 Linux Foundation 산하 벤더 중립 표준으로 자리잡았습니다.
이 글을 마칠 때쯤이면 "PROD-001 제품 재고 알려줘" 한 마디로 사내 ERP를 조회하는 AI 도구가 완성됩니다. Node.js와 공식 TypeScript SDK로 사내 레거시 시스템을 MCP 서버로 감싸고 Claude Code와 연결하는 전 과정을 코드와 함께 살펴봅니다. MCP 핵심 개념, 실제 동작 코드, 그리고 프로덕션에서 반드시 챙겨야 할 보안 포인트까지 함께 다룹니다.
핵심 개념
MCP란 무엇인가 — "AI를 위한 USB-C"
USB-C가 다양한 장치를 하나의 인터페이스로 연결하듯, MCP는 AI 모델과 외부 세계 사이의 범용 어댑터입니다. 기존에는 Claude용 플러그인, GPT용 Function Calling 등 AI마다 별도 통합이 필요했지만, MCP는 이 연결 방식을 하나의 표준으로 통일합니다.
MCP(Model Context Protocol): AI 어시스턴트가 외부 도구·데이터베이스·시스템과 상호작용하는 방식을 표준화한 오픈 프로토콜. 서버-클라이언트 구조에서 LLM이 도구를 "발견"하고 "호출"하는 메커니즘을 정의합니다.
MCP의 핵심 구성 요소는 세 가지입니다.
| 구성 요소 | 역할 | 사내 레거시 시스템 활용 예 |
|---|---|---|
| Tools | LLM이 호출하는 함수. AI가 언제 실행할지 스스로 결정 | 레거시 ERP API 호출, DB 쿼리 실행 |
| Resources | LLM이 읽는 읽기 전용 데이터 소스 | 사내 API 스펙, 기술 문서, 위키 |
| Prompts | 재사용 가능한 프롬프트 템플릿 | ERP 쿼리 가이드, 코드 컨벤션 문서 |
통신 방식: stdio vs Streamable HTTP
| 방식 | 용도 | 특징 |
|---|---|---|
| stdio | 로컬 프로세스 | 설정 간단, 개인 개발 환경에 적합 |
| Streamable HTTP(SSE) | 원격 서버 | 팀 전체 공유, 엔터프라이즈 환경 |
이 글에서는 가장 빠르게 시작할 수 있는 stdio 방식을 기준으로 설명합니다.
레거시 시스템 래핑 패턴
사내 레거시 시스템을 MCP 서버로 래핑하는 패턴은 기존 API를 Tool로 감싸는 방식이 대부분입니다(arXiv 실증 연구). 레거시 시스템을 수정할 필요 없이, MCP 서버가 그 앞에서 번역 레이어 역할을 합니다.
Claude Code ──MCP 프로토콜──▶ MCP 서버 (Node.js) ──HTTP──▶ 레거시 ERP/APIMcpServer와 StdioServerTransport가 분리된 이유
SDK에서 McpServer와 StdioServerTransport를 별개 객체로 분리한 이유는 전송 계층과 애플리케이션 계층을 독립적으로 교체할 수 있도록 설계했기 때문입니다. 로컬 개발 시 stdio를 쓰다가 팀 공유가 필요해지면 StdioServerTransport만 StreamableHTTPServerTransport로 교체하면 됩니다. Tool 등록 코드는 전혀 건드릴 필요가 없습니다.
실전 적용
ERP 재고 조회 Tool 구현
가장 흔한 시나리오입니다. 레거시 ERP의 REST API를 MCP Tool로 노출하여 Claude Code가 자연어로 재고 데이터를 조회할 수 있도록 구성합니다.
프로젝트 초기 설정부터 시작합니다.
mkdir legacy-erp-mcp && cd legacy-erp-mcp
pnpm init
pnpm add @modelcontextprotocol/sdk zod axios
pnpm add -D typescript @types/node// package.json (핵심 설정)
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.23.0",
"axios": "^1.7.0"
}
}
"type": "module"이 필요한 이유: Node.js는 기본적으로 CommonJS(CJS) 방식으로 파일을 해석합니다. MCP SDK는 ESM(ES Module) 방식으로 패키징되어 있어, 이 설정이 없으면import구문에서 오류가 발생합니다. Python의from module import X와 달리 Node.js는 이 설정을 명시적으로 선언해야 합니다.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"outDir": "dist"
}
}이제 MCP 서버 본체를 작성합니다.
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import axios from "axios";
// 환경변수 초기화 시 검증 — TypeScript strict 모드에서는 undefined 가능성을 처리해야 합니다
const ERP_BASE_URL = process.env.ERP_BASE_URL;
const ERP_API_KEY = process.env.ERP_API_KEY;
if (!ERP_BASE_URL || !ERP_API_KEY) {
console.error("필수 환경변수(ERP_BASE_URL, ERP_API_KEY)가 설정되지 않았습니다.");
process.exit(1);
}
const server = new McpServer({
name: "legacy-erp-server",
version: "1.0.0",
});
// Tool 1: 재고 현황 조회
server.tool(
"get_inventory",
"특정 제품 코드(예: PROD-001)로 현재 재고 수량과 위치를 조회합니다",
{
product_code: z.string().describe("제품 코드 (예: PROD-001)"),
},
async ({ product_code }) => {
try {
const res = await axios.get(
`${ERP_BASE_URL}/inventory/${product_code}`,
{ headers: { "X-API-Key": ERP_API_KEY } }
);
return {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
};
} catch (err) {
// isError: true — Claude가 Tool 실패를 인식하고 대안을 제안할 수 있도록 하는 신호
return {
content: [{ type: "text", text: `오류: ${(err as Error).message}` }],
isError: true,
};
}
}
);
// Tool 2: 발주 목록 조회
server.tool(
"get_purchase_orders",
"특정 기간의 발주 목록을 조회합니다",
{
from_date: z.string().describe("시작일 (YYYY-MM-DD)"),
to_date: z.string().describe("종료일 (YYYY-MM-DD)"),
status: z
.enum(["pending", "approved", "shipped", "completed"])
.optional()
.describe("발주 상태 필터"),
},
async ({ from_date, to_date, status }) => {
try {
const params = { from_date, to_date, ...(status && { status }) };
const res = await axios.get(`${ERP_BASE_URL}/purchase-orders`, {
params,
headers: { "X-API-Key": ERP_API_KEY },
});
return {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
};
} catch (err) {
return {
content: [{ type: "text", text: `오류: ${(err as Error).message}` }],
isError: true,
};
}
}
);
// top-level await는 ES2022 이상 + Node16 모듈 설정에서 사용 가능합니다
const transport = new StdioServerTransport();
await server.connect(transport);코드의 핵심 포인트를 정리하면 다음과 같습니다.
| 코드 요소 | 설명 |
|---|---|
McpServer |
MCP 서버 인스턴스. 이름과 버전으로 식별 |
server.tool() |
Tool 등록. 이름, 설명, Zod 스키마, 핸들러 순으로 전달 |
z.string().describe() |
Claude가 이 파라미터의 의미를 이해하는 데 활용 |
StdioServerTransport |
stdout/stdin으로 Claude Code와 JSON-RPC 통신 |
isError: true |
Tool 실패 신호. 이 플래그가 있으면 Claude가 실패를 인식하고 대안을 제안 |
주의: stdio 서버에서는
console.log()대신 반드시console.error()를 사용해야 합니다. stdout에 일반 텍스트를 쓰면 JSON-RPC 메시지가 깨져 Claude Code가 응답을 파싱하지 못합니다.
Claude Code 연결 설정
빌드 후 .mcp.json으로 Claude Code에 등록합니다.
pnpm build// .mcp.json (프로젝트 루트에 위치, Git으로 팀 공유 가능)
{
"mcpServers": {
"legacy-erp": {
"command": "node",
"args": ["./dist/index.js"],
"env": {
"ERP_BASE_URL": "http://internal-erp.company.com/api/v1",
"ERP_API_KEY": "${ERP_API_KEY}"
}
}
}
}
${ERP_API_KEY}문법: Claude Code가 자체적으로 지원하는 환경변수 참조 문법입니다. Claude Code 실행 시점에 OS 환경변수(export ERP_API_KEY=...또는.env파일)에서 값을 읽어 자동으로 주입합니다. API 키를.mcp.json에 직접 하드코딩하지 않아도 됩니다.
CLI를 통한 등록도 가능합니다.
# 프로젝트 범위로 등록
claude mcp add legacy-erp node ./dist/index.js
# 등록된 서버 목록 확인
claude mcp list
.mcp.jsonvs CLI 등록:.mcp.json은 Git에 커밋하여 팀 전체가 동일한 MCP 서버 설정을 공유할 수 있습니다. 개인 전용 서버라면 CLI의--global옵션을 활용하는 것이 좋습니다.
사내 문서 Resource 노출
Resource는 Claude가 코드 작성 시 자동으로 참조할 수 있는 읽기 전용 데이터 소스입니다. uri 파라미터는 Node.js의 URL 객체로 전달되며, internal-docs://처럼 커스텀 URI 스킴을 정의해서 사용할 수 있습니다. 이 스킴은 MCP 서버 내에서 리소스를 식별하는 용도로만 쓰이며, 실제 네트워크 요청과는 무관합니다.
// 사내 기술 문서를 Resource로 노출
server.resource(
"api-spec",
"internal-docs://api-spec", // 커스텀 스킴: MCP 서버 내 식별자로 사용
async (uri) => {
// uri는 URL 객체 — uri.pathname, uri.href 등으로 접근 가능
const content = await fetchFromInternalWiki(uri.pathname);
return {
contents: [
{
uri: uri.href,
mimeType: "text/markdown",
text: content,
},
],
};
}
);
async function fetchFromInternalWiki(path: string): Promise<string> {
const wikiUrl = process.env.WIKI_BASE_URL;
const wikiToken = process.env.WIKI_TOKEN;
const res = await axios.get(`${wikiUrl}${path}`, {
headers: { Authorization: `Bearer ${wikiToken}` },
});
return res.data.content;
}MCP Inspector로 로컬 디버깅
개발 중 Tool이 제대로 동작하는지 확인할 때는 공식 디버깅 GUI를 활용하면 편리합니다.
npx @modelcontextprotocol/inspector node ./dist/index.js브라우저에서 Inspector UI가 열리고, 등록된 Tool 목록을 확인하거나 직접 호출해 응답을 테스트할 수 있습니다. Claude Code에 연결하기 전에 이 단계를 거치면 디버깅 시간이 크게 줄어듭니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 표준화 | Claude, GPT, Gemini 등 모든 AI 도구와 동일한 인터페이스로 연결 가능 |
| 레거시 보호 | 기존 시스템을 수정하지 않고 AI 접근 레이어만 추가 |
| 빠른 구축 | 공식 SDK 활용 시 수 시간 내 프로토타입 완성 가능 |
| 팀 공유 | .mcp.json 하나로 프로젝트 단위 설정 공유 |
| 격리 구조 | AI가 검증된 Tool만 호출하도록 제한하여 안전성 확보 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 시크릿 노출 | 공개 MCP 서버의 88%가 장기 유효 정적 API 키를 사용해 탈취 위험이 높음(Astrix Security 보고서) | OAuth 2.0 + 단기 토큰 사용. HashiCorp Vault 같은 시크릿 매니저로 자격증명을 별도 저장하고 런타임에 주입 |
| 인젝션 취약점 | 분석된 서버 43%에서 커맨드 인젝션 취약점 발견 | Zod 스키마로 모든 입력값 엄격히 검증. z.string().max(50).regex(/^[A-Z0-9-]+$/) 수준의 화이트리스트 방식을 권장 |
| 로컬 전용 한계 | stdio 서버는 실행 중인 머신 외에서 접근 불가 | 팀 전체 공유가 필요하다면 Streamable HTTP 기반 원격 서버로 전환 |
| 컨텍스트 비용 | Tool이 많아질수록 Claude의 컨텍스트 토큰 사용량 증가 | 도메인별로 서버를 분리해 운영(예: erp-server, docs-server) |
| 유지보수 부담 | 레거시 API 변경 시 MCP 서버도 함께 수정 필요 | 레거시 API 스펙을 OpenAPI로 관리하면 변경 추적이 용이하며, Stainless 같은 도구로 MCP 서버 자동 생성도 가능 |
Zod: TypeScript 생태계의 스키마 검증 라이브러리입니다.
z.string(),z.number(),z.enum()등으로 입력 타입을 선언하면 런타임에서 자동으로 검증이 이루어집니다. MCP Tool에서 SQL 인젝션이나 커맨드 인젝션을 방어하는 1차 방어선 역할을 합니다.
OAuth Resource Server: 2025년 6월 MCP 스펙 개정으로 MCP 서버가 공식적으로 OAuth Resource Server로 분류되었습니다. 프로덕션 환경에서는 환경변수 API 키 대신 OAuth 2.0/OIDC 기반 단기 토큰 인증을 권장합니다. 토큰 발급·갱신은 Authorization Server가 담당하고, MCP 서버는 토큰 유효성만 검증하는 구조입니다.
실무에서 가장 흔한 실수
console.log()를 디버깅에 사용하는 경우: stdio 서버에서 stdout에 임의 텍스트를 쓰면 JSON-RPC 파싱이 실패합니다. 반드시console.error()를 사용하거나 파일 로거를 도입하는 것이 좋습니다.- Tool 설명을 대충 작성하는 경우: Claude는 Tool의
description과 파라미터의describe()내용을 바탕으로 언제 어떤 Tool을 호출할지 결정합니다."재고 조회"보다"특정 제품 코드(예: PROD-001)로 현재 재고 수량과 위치를 조회합니다"처럼 구체적으로 작성하면 Claude가 올바른 Tool을 선택할 확률이 높아집니다. - 에러 핸들링을 생략하는 경우: 레거시 API가 타임아웃이나 500 오류를 반환할 때 MCP 서버가 크래시되면 Claude Code 전체 세션이 끊깁니다. 아래 패턴처럼 try-catch와
isError: true를 함께 사용하면 Claude가 실패를 인식하고 대안을 제안할 수 있습니다.
// 권장 에러 핸들링 패턴
async ({ product_code }) => {
try {
const res = await axios.get(`${ERP_BASE_URL}/inventory/${product_code}`, {
headers: { "X-API-Key": ERP_API_KEY },
});
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
} catch (err) {
return {
content: [{ type: "text", text: `오류: ${(err as Error).message}` }],
isError: true, // Claude가 Tool 실패를 인식하고 다음 행동을 결정
};
}
}마치며
레거시 시스템은 수년간 쌓인 비즈니스 로직과 데이터의 집약체입니다. MCP는 그 자산을 유지하면서 AI 인터페이스를 얹을 수 있는 현실적인 경로입니다. 이 글에서 구현한 패턴의 핵심은 isError: true를 포함한 에러 핸들링입니다. Tool이 아무리 잘 만들어져도 실패를 올바르게 전달해야 Claude가 상황을 파악하고 다음 단계로 나아갈 수 있습니다. 에러를 삼켜버리는 서버와 에러를 Claude에게 알려주는 서버는 실제 사용성에서 큰 차이가 납니다.
지금 바로 시작해볼 수 있는 3단계:
- 환경 준비:
mkdir my-mcp && cd my-mcp && pnpm init && pnpm add @modelcontextprotocol/sdk zod axios로 프로젝트를 초기화하고, 위에서 소개한tsconfig.json과package.json설정을 그대로 적용해보시면 됩니다. - 첫 번째 Tool 등록: 사내에서 가장 자주 조회하는 레거시 API 엔드포인트 하나를 골라
server.tool()로 감싸보시면 좋습니다. 사내 API에 로컬에서 직접 접근이 어렵다면https://httpbin.org/anything으로 응답 형태를 먼저 테스트한 뒤 실제 엔드포인트로 교체하는 방법도 있습니다.npx @modelcontextprotocol/inspector node ./dist/index.js로 응답이 잘 오는지 확인해보시면 됩니다. - Claude Code 연결: 프로젝트 루트에
.mcp.json을 추가하고 Claude Code를 열면 등록된 Tool을 자연어로 바로 호출해볼 수 있습니다. "PROD-001 제품 재고 알려줘" 같은 질문에 Claude가get_inventoryTool을 스스로 선택해 호출하는 것을 확인해보시면 됩니다.
다음 글: Streamable HTTP 기반 원격 MCP 서버 구축과 OAuth 2.0 인증 적용으로 팀 전체가 안전하게 공유하는 엔터프라이즈 MCP 아키텍처 설계하기