Skip to content
CP
Writing
9 분 읽기#code

첫 MCP 서버 만들기 — Claude에게 당신만의 도구를 쥐여주기

남이 만든 MCP 서버를 설치하면 80%까지 갑니다. 마지막 20% — Claude를 당신의 데이터베이스, 당신의 API, 사내 도구에 연결하는 일 — 은 직접 하나 짜야 합니다. 생각보다 작습니다.

공유XThreads

플러그인 글에서 추천한 건 전부 남이 만든 MCP 서버였습니다 — Notion, Supabase, Playwright. 흔한 경우는 그것들이 덮어줍니다. 하지만 Claude가 당신의 사내 API, 당신의 스테이징 DB, 당신의 배포 스크립트를 만지길 바라는 순간, 마켓플레이스엔 그런 플러그인이 없습니다.

그래서 직접 짭니다. 그리고 처음 짜보면 놀라는 건 — 그게 얼마나 별것 아닌가입니다. MCP 서버는 그저 Claude가 호출할 수 있는 함수들을 노출하는 프로그램입니다. 프레임워크도, 마법도 없습니다. 도구(tool)는 이름, 설명, 입력 스키마, 그리고 함수 하나입니다. 처음부터 끝까지, 전부 여기 있습니다.

MCP가 실제로 뭔지

MCP — Model Context Protocol — 는 AI 클라이언트(Claude Code, Claude Desktop 등)가 서버가 제공하는 도구를 발견하고 호출하는 표준 방식입니다. 역할 분담이 깔끔합니다:

  • 클라이언트(Claude)가 언제 어떤 인자로 도구를 호출할지 정합니다.
  • 서버(당신 것)가 어떤 도구가 존재하는지, 호출되면 무엇을 하는지 선언합니다.

그게 계약의 전부입니다. 당신의 서버가 "나는 get_order라는 도구가 있고, orderId를 받아 주문 정보를 반환한다"고 알리면, Claude는 대화에서 주문이 등장할 때마다 그걸 호출합니다. 프롬프트 파싱 로직은 한 줄도 안 씁니다. 언어는 Claude가, 일은 당신의 서버가 처리합니다.

셋업

공식 SDK로 TypeScript 작은 서버를 만들겠습니다. convert_pace 도구 하나를 노출 — 목표 완주 시간과 거리를 받아 km당 페이스를 반환합니다. (장난감이지만, "내 로직을 호출해줘" 류의 모든 도구를 대표합니다.)

mkdir pace-mcp && cd pace-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

ESM import가 동작하도록 package.json"type": "module"을 설정하세요.

서버 전체

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: "pace-mcp",
  version: "1.0.0",
});
 
server.registerTool(
  "convert_pace",
  {
    title: "Convert finish time to pace",
    description:
      "Given a target finish time and a race distance, return the " +
      "required pace per kilometer. Use when a runner asks what pace " +
      "they need to hit a goal time (e.g. 'sub-4 marathon pace').",
    inputSchema: {
      distanceKm: z.number().positive().describe("Race distance in km, e.g. 42.195"),
      finishMinutes: z.number().positive().describe("Target finish time in minutes, e.g. 240"),
    },
  },
  async ({ distanceKm, finishMinutes }) => {
    const secPerKm = (finishMinutes * 60) / distanceKm;
    const min = Math.floor(secPerKm / 60);
    const sec = Math.round(secPerKm % 60).toString().padStart(2, "0");
    return {
      content: [{ type: "text", text: `${min}:${sec} /km` }],
    };
  },
);
 
const transport = new StdioServerTransport();
await server.connect(transport);

이게 서버 전부입니다. 위에서 아래로 읽어보세요:

  1. 서버 생성 — 이름과 버전.
  2. 도구 등록 — 이름, 사람이 읽는 제목, description(Claude가 언제 호출할지 판단하려고 읽는 부분), 그리고 Zod로 쓴 inputSchema.
  3. 핸들러 — 검증되고 타입이 붙은 인자를 받아 content(보통 텍스트)를 반환합니다. 당신 코드가 실행되기 전에 Zod가 이미 distanceKmfinishMinutes가 양수임을 보장했습니다.
  4. stdio로 연결 — Claude Code가 당신 서버를 서브프로세스로 띄우고 stdin/stdout으로 대화합니다.

HTTP 서버도, 라우트도, 보일러플레이트도 없습니다. Zod 스키마는 1인 2역입니다: 입력을 검증하고 동시에 Claude에게 인자의 모양을 알려줍니다.

Claude Code에 연결하기

Claude Code는 프로젝트 루트(또는 전역 설정)의 .mcp.json에서 MCP 서버 설정을 읽습니다. 파일을 가리키세요:

{
  "mcpServers": {
    "pace": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/pace-mcp/server.ts"]
    }
  }
}

Claude Code를 재시작하고, 등록됐는지 확인:

/mcp

pace가 도구 하나와 함께 보여야 합니다. 이제 평범한 말로 물어보세요:

sub-4:00 마라톤 페이스가 얼마야?

Claude는 거리(42.195km)와 시간(240분)을 인식하고, convert_pace를 호출하고, 5:40 /km라고 답합니다. 당신은 함수를 짰고; Claude가 문장을 그 함수 호출로 바꿨습니다.

데모와 진짜 서버를 가르는 세 가지

위 장난감은 동작합니다. 사람들이 의지하는 서버를 출시하려면 습관 셋이 더 필요합니다.

1. 설명이 곧 API다

Claude는 description과 파라미터 설명을 통해서만 당신의 도구를 압니다. 설명이 모호하면 Claude는 엉뚱한 때 도구를 부르거나, 아예 안 부릅니다.

// 약함 — 언제 손을 뻗을지 Claude가 모른다
description: "Gets order data."
 
// 강함 — 언제, 왜 부를지 정확히 알려준다
description:
  "Look up a customer order by its ID. Returns status, line items, " +
  "and shipping info. Use when the user references a specific order " +
  "number or asks about the status of a purchase."

도구 설명을 프롬프트처럼 다루세요, 실제로 프롬프트니까. 도구가 오작동하면 고칠 곳은 거의 항상 코드가 아니라 설명입니다.

2. 전부 검증하고, 에러는 데이터로 반환하라

당신의 서버는 시스템 경계입니다 — Claude의 인자는, 신뢰하는 모델에서 왔더라도 외부 입력입니다. 모양은 Zod가 잡습니다. 하지만 로직이 부딪힐 수 있는 실패도 처리하고, 던지는(throw) 대신 읽을 수 있는 텍스트로 반환하세요:

async ({ orderId }) => {
  const order = await db.orders.find(orderId);
  if (!order) {
    return {
      content: [{ type: "text", text: `No order found with ID ${orderId}.` }],
      isError: true,
    };
  }
  return { content: [{ type: "text", text: JSON.stringify(order) }] };
}

isError: true는 호출이 실패했음을 Claude에게 알려, 에러 문자열을 정답으로 취급하는 대신 — 가령 사용자에게 ID를 다시 확인해달라고 — 조정하게 합니다.

3. 범위를 모질게 좁혀라

이게 보안 항목이고, 가장 중요합니다. MCP 서버는 당신의 자격 증명과 당신의 접근 권한으로 돕니다. 임의의 쿼리 문자열을 받는 run_sql 도구는, Claude가 요청을 잘못 읽는 순간 테이블을 날릴 수 있는 도구입니다.

남의 서버를 설치할 때와 같은 논리를, 안으로 돌린 겁니다: 당신이 노출하는 모든 도구는 모델에게 건네는 권한입니다. 일에 딱 필요한 것만, 그 이상은 건네지 마세요.

언제 실제로 하나 짤까

모든 연동이 커스텀 서버를 필요로 하진 않습니다. 이럴 때 손을 뻗으세요:

  • 공개 플러그인이 없는 사내 도구가 있을 때 — 당신만의 API, 비공개 DB, 배포나 마이그레이션 스크립트.
  • 같은 다단계 작업을 손으로 반복하고 있을 때 — 그 순서를 도구 하나로 감싸고 Claude가 호출하게 하세요.
  • Claude가 실시간 상태를 읽길 바랄 때 — 현재 티켓 상태, 오늘의 지표, 스테이징 환경의 내용.

마켓플레이스 플러그인이 이미 그 일을 한다면 그걸 설치하세요 — 유지보수가 적습니다. 커스텀 서버는 아무도 안 만든 20%를 위한 것이고, 그건 당신 회사 안에만 존재하기 때문입니다.

마치며 — 그 기술의 모양

첫 MCP 서버는 이정표처럼 느껴지지만 알고 보면 한나절짜리입니다. 프로토콜은 일부러 작게 만들어졌습니다. 일단 모양이 잡히면 — 이름, 설명, 스키마, 함수 — MCP 자체는 더 이상 생각하지 않게 되고, 당신의 시스템 중 어떤 조각을 모델에게 건넬 가치가 있는지를 생각하기 시작합니다.

그게 진짜 기술이고, 코딩 기술이 아닙니다: Claude가 무엇을 만지게 허락할지 정하고, 각 도구를 옳은 것을 집을 만큼 명확히 설명하는 것. 코드는 쉬운 20%입니다. 판단이 나머지입니다.

다음 글 — 이런 도구 몇 개를 한 줄로 설치하는 플러그인으로 묶는 법.

관련 글