시리즈 1편. Claude Code에 붙일 MCP 서버 다섯 개를 만들었다. 각 MCP의 상세 구현은 이후 편에서 하나씩 풀고, 이 글은 왜 시작했고, 다 만들고 나서 어떤 패턴이 보였는지를 정리한다.
처음에는 하나로 끝낼 생각이었다. 회사에서 Bitbucket으로 PR을 올리면 CodeRabbit이 자동으로 리뷰를 달아주는데, 이걸 매번 브라우저 열어서 확인하고 스크롤하는 게 답답했다. Claude Code에서 "PR 1번 리뷰 가져와서 정리해줘" 한 번이면 끝나는 일이니까, MCP 서버로 만들면 되겠다 싶었다.
그런데 bitbucket-mcp를 만들고 나니까, 골격이 너무 깔끔했다. @modelcontextprotocol/sdk 하나 의존성에, Node.js 내장 fetch로 HTTP 치고, tsc로 빌드해서 stdio로 붙인다. 번들러 없음, axios 없음, commander 없음. 그래서 그 다음이 자연스럽게 따라왔다:
다섯 개째 만들 때쯤에는 사실 만드는 것보다 도메인 파악이 90%라는 걸 알았다. MCP 자체는 뼈대가 너무 단순해서, 한 번 익히면 다시는 헷갈리지 않는다. 이 시리즈는 그 뼈대를 공유하면서, 각 MCP에서 마주친 도메인 함정들을 기록해두려는 글이다.
2024년 말까지 AI에게 외부 시스템을 붙이는 방식은 제각각이었다. 각 AI 클라이언트마다 자기만의 함수 호출 규격이 있고, 각 SaaS 벤더마다 자기만의 플러그인 방식이 있었다. 가장 정교한 모델조차 정보 사일로와 레거시 시스템 뒤에 갇혀, 새로운 데이터 소스마다 자체 구현이 필요한 상태였다. Anthropic은 이걸 N×M 통합 문제라고 불렀다 — N개의 AI 클라이언트 × M개의 데이터 소스면 N×M개의 커넥터를 관리해야 한다는 뜻이다.
MCP(Model Context Protocol)는 Anthropic이 2024년 11월 오픈 스탠다드이자 오픈소스 프레임워크로 발표한 프로토콜로, 이 N×M을 N+M으로 바꾸는 게 목표였다. 흔히 "AI용 USB-C"라는 비유가 쓰인다. 예전에 아이폰은 라이트닝, 안드로이드는 마이크로USB, 카메라는 또 다른 커넥터였지만 USB-C 하나로 수렴한 것처럼 — Google Drive, Salesforce, 사내 CRM을 위해 각각 커스텀 커넥터를 만드는 대신 단일 프로토콜 하나에 대고 구현하면 된다.
출발은 의외로 소박하다. Anthropic 개발자 David Soria Parra가 Claude Desktop과 자기 IDE 사이에서 코드를 끊임없이 복사·붙여넣기 하던 좌절감에서 시작됐다는 게 원류다. 거창한 표준화 선언이 아니라, 본인이 겪던 불편함을 해결하려던 것.
오픈 프로토콜이 업계를 휩쓰는 건 드문 일이다. OpenAPI(Swagger), OAuth 2.0, HTML/HTTP 같은 유명 스펙들은 비슷한 수준의 크로스 벤더 채택에 각각 대략 5년, 4년, 1990년대 대부분을 썼다. MCP는 1년 만에 됐다.
결정적인 장면: 2025년 3월 26일, OpenAI CEO Sam Altman이 X에 "People love MCP and we are excited to add support across our products"라는 트윗을 올린 날. 경쟁사 표준을 정면으로 수용한다는 선언이었다. 같은 날 MCP 스펙 2판이 출시됐고, 여기에 Streamable HTTP 전송과 OAuth 2.1 기반의 인가 프레임워크가 포함되면서 이전까지 개발자의 AI 코딩 워크플로우 개선에 주로 쓰이던 MCP가 SaaS 벤더들이 안전한 MCP 서버를 인터넷에 퍼블리시해 클라우드·로컬 클라이언트 모두에서 쓸 수 있는 단계로 올라섰다.
그 다음은 도미노였다. 2025년 4월에는 Google DeepMind의 Demis Hassabis가 차기 Gemini 모델에 MCP 지원을 확정했고, Microsoft Build 2025에서는 Windows 11이 MCP를 수용한다고 발표했다. 1년을 채운 시점이 되자 10,000개 이상의 공개 MCP 서버가 가동 중이었고, Python과 TypeScript SDK만 합쳐 월 9,700만 건 이상 다운로드되고 있었다. 2025년 12월에는 Anthropic이 MCP를 Linux Foundation 산하 새로 만들어진 Agentic AI Foundation에 기부하면서, OpenAI의 AGENTS.md, Block의 goose와 함께 창립 프로젝트로 편입됐다. 2026년 3월 기준 누적 설치는 9,700만 건을 넘겼고, 역사상 어떤 AI 인프라 표준보다 빠른 채택 곡선이라는 평가를 받고 있다.
왜 이렇게 빨랐나. 세 가지만 꼽자면:
솔직히 말하면, 내가 이 다섯 개 MCP를 만든 시점은 표준이 자리 잡고도 시간이 한참 흐른 뒤다. 나는 이 흐름에 상당히 뒤늦게 올라탄 편이다. 다만 뒤늦게 올라탄 덕분에 얻은 게 있다 — SDK가 성숙해 있었고, 생태계가 안정적이었고, Claude Code가 claude mcp add 한 줄로 서버를 물리게 해줬다. 얼리 어답터가 먼저 깨진 길 위를 편하게 걸은 셈이다.
한 줄 요약: AI 클라이언트가 외부 도구를 부를 수 있게 해주는 표준 프로토콜이다. "함수 하나를 호출하면 결과 문자열이 돌아온다"는 아주 단순한 모델.
동작 방식은 생각보다 소박하다:
(원격 배포를 위해선 HTTP+SSE나 Streamable HTTP 전송이 따로 있지만, 개인이 Claude Code에 붙이는 수준에서는 stdio로 충분하다. 다섯 개 중 어느 것도 네트워크 리스너를 열지 않는다 — 내부에서 fetch로 외부 API를 치고, 결과를 stdout으로 부모에게 돌려줄 뿐이다. 포트 충돌도 없고, 방화벽도 없다. 그냥 Node.js 프로세스 하나다.)
SDK를 쓰면 서버의 최소 골격은 이렇게 된다. 다섯 개 MCP의 index.ts가 전부 이 형태에서 출발했다:
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{ name: 'my-mcp', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// 1. 내가 가진 도구 목록 선언
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'hello',
description: '...',
inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
}],
}));
// 2. 호출 디스패치
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args } = req.params;
if (name === 'hello') {
return { content: [{ type: 'text', text: `Hello, ${args?.name}` }] };
}
return { content: [{ type: 'text', text: `Unknown: ${name}` }], isError: true };
});
// 3. stdio로 연결
await server.connect(new StdioServerTransport());
50줄 남짓. 여기서부터 HTTP 클라이언트를 분리하고, 포매터를 분리하고, CLI를 붙이면 실전용 MCP가 된다.
각 MCP의 깊은 이야기는 이후 편에서 풀 거고, 여기서는 무엇을 하는지와 다른 넷과 어떻게 다른지만 짚는다.
Bitbucket Cloud PR의 CodeRabbit 리뷰를 마크다운으로 정제해서 Claude에게 넘겨준다. 도구 4개 (list_pull_requests, get_pr, get_pr_comments, get_coderabbit_review). 전체 코멘트 중 CodeRabbit 것만 필터링해서, 각종 마커(coderabbit.ai 링크, HTML 주석)를 정규식으로 제거하고, Nitpick/Suggestion 같은 헤드라인을 뽑아 포매팅한다.
이 MCP를 만들면서 배운 가장 아픈 함정: Bitbucket은 Authorization: Bearer <token>을 받지 않는다. 스코프 포함 Atlassian API 토큰이라도, email:token Basic auth로 보내야 한다. 문서를 읽어봐도 잘 안 드러나고, 테스트하기 전까지는 모른다.
Jira Cloud에서 티켓 조회, JQL 검색, 내 티켓, 활성 스프린트, 주간 요약, 코멘트 추가를 지원. 도구 6개. 인증은 bitbucket-mcp와 같은 Atlassian API 토큰을 재사용할 수 있다.
Jira만의 포인트는 ADF(Atlassian Document Format) 처리다. description과 comment가 평문이 아니라 중첩 JSON으로 내려온다. Claude 컨텍스트 효율을 위해 재귀 추출 함수로 평문만 뽑고, 반대로 코멘트를 쓸 때는 평문을 ADF로 래핑해야 한다:
const extractTextFromAdf = (node: unknown): string => {
if (!node || typeof node !== 'object') return '';
const n = node as Record<string, unknown>;
if (n.type === 'text') return (n.text as string) || '';
if (Array.isArray(n.content)) {
return (n.content as unknown[]).map(extractTextFromAdf).join('');
}
return '';
};
Jira MCP와 3-파일 구성이 완전히 똑같다. Basic auth도 동일, 설정 우선순위도 동일, CLI 3단도 동일. 다른 점은 JQL 대신 CQL(Confluence Query Language)을 패스스루하고, 본문 포맷이 ADF 대신 Storage Format(HTML 변형)이라는 것뿐.
도구 7개 — search_pages, get_page, list_spaces, get_space_pages, get_child_pages, get_recent_pages, get_my_recent_work. 함정 하나: 사용자는 URL에서 본 DEV 같은 스페이스 키를 기억하는데 v2 API는 숫자 ID만 받는다. 내부에서 키 → ID 해석을 한 번 더 해줘야 사용성이 산다.
다섯 개 중 제일 복잡하다. 이유는 세 가지:
list_requests → call_api가 자연스러운 흐름이니까.{{base_url}}/api/users/{{user_id}} 같이 이중 중괄호 변수를 쓴다. 우선순위가 "컬렉션 정의 < CLI setup 지정값 < 호출 시 파라미터"로 3단.raw(JSON), urlencoded(URLSearchParams), formdata(FormData) — 각기 다른 Content-Type과 직렬화 방식.도구 7개. 컬렉션 로드 경로도 셋이다: 로컬 파일, 일반 URL, Postman API(X-Api-Key로 다운로드 후 {collection: {...}} 래핑 언래핑).
OpenAPI 3.x / Swagger 2.0 스펙을 로드해 엔드포인트·스키마 탐색과 실제 호출을 지원. 도구 8개.
이 MCP가 흥미로운 건 postman-mcp와의 관계다. 4-파일 구조(index/cli/parser/api-client)가 동일하고, api-client.ts는 거의 그대로 복붙했다. 파서만 OpenAPI용으로 새로 쓰면 된다. 차이는 이 정도:
| postman-mcp | swagger-mcp | |
|---|---|---|
| 변수 치환 | {{var}} | OpenAPI 표준 {path_param} |
| 기본 baseUrl | 컬렉션 변수 base_url | servers[0].url |
| 바디 모드 | raw/urlencoded/formdata | JSON 위주 |
| 도구 수 | 7 | 8 (스키마 탐색 2개 추가) |
한 쪽을 만들면 다른 쪽은 70% 재사용. 이게 다섯 개째 만들면서 가장 확신하게 된 부분이다.
다섯 개를 만들면서 파일 구조가 점점 수렴했다:
index.ts — MCP 서버 + 도구 디스패치cli.ts — setup / register / status 3단 커맨드<domain>-client.ts — HTTP 레이어CLI 3단도 다섯 개 다 똑같다. setup은 readline으로 대화형 Q&A, register는 execSync('claude mcp add ...')로 Claude Code CLI 호출, status는 현재 상태 표시. 라이브러리 없음, 그냥 process.argv.slice(2) 파싱.
처음엔 "편의를 위해 commander라도 쓸까?" 했지만, 안 썼다. 서브커맨드 5개 정도는 직접 파싱하는 게 의존성 추가보다 싸고 명료하다. 의존성 하나에 @modelcontextprotocol/sdk 하나만 — 다섯 개 중 어느 것도 이 원칙을 안 깼고, 유지보수가 편해졌다.
bitbucket-mcp는 coderabbit.ts를 따로 뒀다. 순수 함수 모음 — HTTP도 모르고 MCP도 모른다. isCodeRabbitComment, cleanBody, extractHeadline, formatReview. 이렇게 분리하니까 단위 테스트가 쉬웠고, 포매팅 규칙을 바꾸고 싶을 때 HTTP 코드를 건드리지 않아도 됐다.
반면 jira-mcp는 포매팅을 jira-client.ts 안에 녹였다. 메서드가 이미 포매팅된 문자열을 반환한다. 이유는 단순 — 응답이 단순하고 도메인 추출 로직이 없었다. CodeRabbit처럼 "전체 코멘트 중 특정 소스만 골라내고 마커를 제거하고 헤드라인을 뽑는" 단계가 없다. 그냥 필드 몇 개를 읽어서 포맷하면 끝.
경계선을 다시 말하면: 도메인 지식이 있는 정제 단계가 있느냐. 있으면 분리, 없으면 통합. 분리 자체가 미덕은 아니다.
이건 예상치 못한 분기였다. Atlassian 삼형제(Jira/Confluence/Bitbucket)는 전부 stateless — 매 호출마다 필요한 만큼 API를 친다. postman/swagger-mcp만 stateful — 컬렉션/스펙을 메모리에 로드해두고 재사용한다.
처음에는 "MCP는 전부 stateless로 짜야 깔끔하지 않나?"라고 생각했는데, postman-mcp를 만들다 보니 생각이 바뀌었다. MCP 서버는 Claude Code 세션 동안 살아있는 장기 프로세스다. 매번 몇 MB짜리 컬렉션을 재다운로드/재파싱하는 건 낭비고, 사용자도 한 세션에서 컬렉션을 열 번씩 바꾸지 않는다.
규칙이 된다면 대충 이렇다: 호출 간 공유할 가치가 있는 상태가 있으면 모듈 스코프 싱글턴으로 두고, 그런 게 없으면 stateless. postman-mcp의 applyCliConfig는 서버 시작 시 컬렉션을 자동 로드한다 — 사용자는 매번 load_collection을 부를 필요가 없다.
이게 처음엔 어색했다. 도구가 { content: [{ type: 'text', text: string }] }을 반환한다. 왜 구조화된 JSON이 아니고 문자열?
써보니까 답이 명확하다. LLM은 반복되는 JSON 키를 좋아하지 않는다. 컬렉션 20개를 [{name, description, state, ...}, ...] JSON으로 넘기는 것보다, 사람이 읽는 마크다운 리스트로 넘기는 게 컨텍스트 효율이 좋고 Claude의 후속 추론도 정확해진다. 다섯 개 MCP 전부 이 원칙을 따른다 — HTTP 응답은 JSON으로 받고, LLM에 넘기기 전에 사람이 읽는 포맷으로 정제한다.
설정 우선순위도 다섯 개 다 같다:
환경변수 (claude mcp add --env로 주입된 것)
↓ 없으면
config.json (CLI setup이 0600으로 저장한 것)
↓ 없으면
process.exit(1)
setup은 로컬 개발/디버깅용, Claude Code가 프로덕션에서 띄우는 경로는 env 기반. 같은 바이너리가 두 용도를 다 커버한다. chmod 0600 잊지 말 것 — 홈 디렉토리에 평문 토큰 놓는 거니까.
Server + StdioServerTransport + ListTools + CallTool. 50줄 스켈레톤에서 출발해서 HTTP와 포매팅을 붙여나가면 된다.이 시리즈는 이렇게 이어진다:
각 편에서 코드를 조금 더 펼치고, 내가 겪은 삽질을 기록으로 남길 생각이다.