Skip to main content
Summary

직접 만들어야 보이는 연결선

MCP 설정 파일에서 자주 보던 command, args, env는 처음에는 단순한 실행 옵션처럼 보입니다. 하지만 직접 서버를 하나 만들어 보면 의미가 선명해집니다.

Host는 설정 파일의 command와 args를 읽고 로컬 프로세스를 실행합니다. 그 프로세스가 MCP 서버입니다. 서버는 시작되자마자 "내가 제공하는 도구는 이것입니다"라고 알려 줄 준비를 합니다. 엄밀히 말하면 Host 안의 MCP Client가 서버에 초기화 요청을 보내고, 필요할 때 Tool 목록을 조회합니다. 사용자가 AI에게 요청하면 Host는 서버의 Tool 목록을 확인하고, 필요한 Tool을 골라 입력값과 함께 호출합니다.

이번 장에서는 가장 안전한 형태의 첫 서버를 만듭니다. 외부 계정도 쓰지 않고, 파일을 수정하지도 않습니다. 숫자 계산 Tool 하나를 만들고, stdio 방식으로 Host에 연결합니다. 목표는 멋진 기능이 아니라 프로토콜의 실제 흐름을 손에 익히는 것입니다.

첫 MCP 서버 만들기AI 생성 이미지

프로젝트 만들기

예제는 TypeScript로 작성합니다. MCP TypeScript SDK는 @modelcontextprotocol/sdk 패키지를 제공하며, 입력 스키마 검증에는 zod를 함께 사용합니다.1 이 장의 코드는 2026년 7월 기준으로 널리 쓰이는 v1 계열 SDK import 경로를 기준으로 합니다. MCP SDK는 변화가 빠른 편이므로, 나중에 패키지명이나 import 경로가 달라진다면 공식 문서의 현재 버전 안내를 함께 확인하세요.

터미널에서 새 폴더를 만듭니다.

mkdir first-mcp-server
cd first-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --init

package.json에는 ESM 설정과 실행 스크립트를 추가합니다. 실제 프로젝트에서는 package-lock.json까지 함께 보관해 같은 버전으로 재설치될 수 있게 하는 편이 좋습니다.

{
  "type": "module",
  "scripts": {
    "dev": "tsx src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "latest",
    "zod": "latest"
  },
  "devDependencies": {
    "@types/node": "latest",
    "tsx": "latest",
    "typescript": "latest"
  }
}

tsconfig.json은 환경마다 조금씩 다르지만, 처음에는 다음 정도면 충분합니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

이제 src/server.ts 파일을 만듭니다.

첫 Tool: 안전한 계산기

첫 Tool은 calculate_total입니다. 물건 가격과 수량, 세율을 받아 총액을 계산합니다. 계산 Tool은 외부 상태를 바꾸지 않기 때문에 첫 예제로 좋습니다.

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: "first-mcp-server",
  version: "1.0.0"
});

function roundMoney(value: number) {
  return Math.round((value + Number.EPSILON) * 100) / 100;
}

server.registerTool(
  "calculate_total",
  {
    title: "Calculate Total Price",
    description:
      "Calculate total price from unit price, quantity, and optional tax rate. This tool does not modify external systems.",
    inputSchema: {
      unitPrice: z.number().finite().nonnegative().describe("Unit price"),
      quantity: z.number().int().positive().describe("Quantity"),
      taxRate: z
        .number()
        .finite()
        .min(0)
        .max(1)
        .default(0)
        .describe("Tax rate from 0 to 1")
    },
    outputSchema: {
      subtotal: z.number().nonnegative().describe("Price before tax"),
      tax: z.number().nonnegative().describe("Tax amount"),
      total: z.number().nonnegative().describe("Final total price")
    }
  },
  async ({ unitPrice, quantity, taxRate }) => {
    const subtotal = roundMoney(unitPrice * quantity);
    const tax = roundMoney(subtotal * taxRate);
    const total = roundMoney(subtotal + tax);

    console.error(
      JSON.stringify({
        event: "tool_called",
        tool: "calculate_total",
        input: { unitPrice, quantity, taxRate },
        result: { subtotal, tax, total },
        time: new Date().toISOString()
      })
    );

    const output = { subtotal, tax, total };

    return {
      content: [
        {
          type: "text",
          text: `Subtotal: ${subtotal}, Tax: ${tax}, Total: ${total}`
        }
      ],
      structuredContent: output
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

여기서 중요한 부분은 네 가지입니다.

첫째, Tool 이름은 calculate_total입니다. 모델은 이 이름과 설명을 보고 어떤 상황에서 이 도구를 쓸지 판단합니다. 이름은 짧고 동작이 분명해야 합니다.

둘째, description은 사람만 읽는 문장이 아닙니다. Host와 모델이 Tool의 용도를 이해하는 힌트입니다. "총액 계산"만 쓰는 것보다 "외부 시스템을 수정하지 않는다"까지 적어 두면 안전한 도구라는 성격이 더 분명해집니다.

셋째, inputSchema는 Tool의 입력 문입니다. 아무 문자열이나 받지 않고, 숫자·정수·최솟값·최댓값을 정합니다. taxRate처럼 선택 입력에는 기본값도 줄 수 있습니다. 지난 장에서 말한 "좁은 스키마"가 실제 코드에서는 이런 모습입니다.

넷째, 반환값은 content 배열입니다. MCP Tool은 실행 결과를 Host가 이해할 수 있는 콘텐츠 형태로 돌려줍니다. 여기에 outputSchema와 structuredContent를 함께 쓰면 사람에게 보여 줄 텍스트와 기계가 다시 활용하기 쉬운 구조화 결과를 나란히 반환할 수 있습니다.2

예제에서는 금액을 소수점 둘째 자리까지 반올림했습니다. 실제 결제나 회계 시스템이라면 통화 단위, 부가세 정책, 반올림 규칙을 더 엄격하게 다뤄야 합니다. 여기서는 MCP Tool의 입력 검증과 결과 반환 흐름을 보기 위한 단순 계산으로 이해하면 됩니다.

stdio로 연결하기

이 서버는 웹 서버처럼 포트를 열지 않습니다. StdioServerTransport를 사용하면 Host가 서버 프로세스를 실행하고 표준 입력·표준 출력으로 MCP 메시지를 주고받습니다. 로컬 MCP 서버에서 자주 쓰는 방식입니다.1

먼저 빌드합니다.

npm run build

그다음 Host 설정에 서버를 추가합니다. Claude Desktop이나 Cursor 계열에서는 대체로 다음과 같은 구조를 사용합니다. 실제 설정 파일 위치는 사용하는 Host의 문서를 따르세요.

{
  "mcpServers": {
    "first-calculator": {
      "command": "node",
      "args": [
        "/ABSOLUTE/PATH/first-mcp-server/dist/server.js"
      ]
    }
  }
}

여기서 연결선이 완성됩니다.

command는 node입니다. Host는 Node.js를 실행합니다. 데스크톱 앱으로 실행되는 Host에서는 터미널과 PATH 환경이 다를 수 있습니다. node를 찾지 못한다는 오류가 나면 command에 Node 실행 파일의 절대 경로를 넣어 보세요.

args는 우리가 만든 서버 파일의 절대 경로입니다. Host는 이 인자를 붙여 서버 프로세스를 띄웁니다. 상대 경로도 지원하는 Host가 있지만, 처음에는 절대 경로가 덜 헷갈립니다.

서버가 시작되면 MCP Client와 Server 사이에서 초기화가 일어납니다. 이후 Host는 서버의 Tool 목록을 확인할 수 있습니다. 사용자가 "단가 12000원짜리 3개에 세율 10%면 총액이 얼마야?"라고 묻고 모델이 Tool이 필요하다고 판단하면, Host는 calculate_total에 다음과 비슷한 입력을 보냅니다.

{
  "unitPrice": 12000,
  "quantity": 3,
  "taxRate": 0.1
}

서버는 계산 결과를 반환하고, Host는 그 결과를 모델의 답변에 반영합니다. 설정 파일의 command가 Tool 호출과 이어지는 과정은 결국 "프로세스 실행 → 초기화 → Tool 목록 발견 → 입력 검증 → 함수 실행 → 결과 반환"입니다.

로그는 stdout이 아니라 stderr로

예제 코드에서 로그를 console.error로 남긴 이유가 있습니다. stdio 방식에서는 표준 출력(stdout)이 MCP 메시지 통로입니다. 여기에 console.log("started") 같은 디버그 문장을 아무렇게나 출력하면 Host가 그 문장을 JSON-RPC 메시지로 해석하려다 연결이 깨질 수 있습니다. 진단 로그는 stderr나 별도 파일에 남기는 편이 안전합니다.3

처음에는 다음 항목만 남겨도 충분합니다.

{
  "event": "tool_called",
  "tool": "calculate_total",
  "input": {
    "unitPrice": 12000,
    "quantity": 3,
    "taxRate": 0.1
  },
  "result": {
    "subtotal": 36000,
    "tax": 3600,
    "total": 39600
  },
  "time": "2026-07-19T00:00:00.000Z"
}

실무 서버에서는 여기에 사용자 식별자, 세션 ID, 승인 ID, 요청 원천을 추가할 수 있습니다. 단, 개인정보와 토큰을 그대로 로그에 남기면 안 됩니다. 로그는 문제를 추적하기 위한 장치이지, 민감 정보를 복제하는 저장소가 아닙니다.

다음 Tool로 확장하는 기준

계산기가 동작하면 바로 강한 권한의 Tool을 만들고 싶어집니다. 하지만 첫 확장은 여전히 읽기 중심이 좋습니다.

예를 들어 파일 읽기 Tool을 만든다면 전체 디스크를 열지 말고 ./data 같은 허용 폴더 하나만 읽게 해야 합니다. 입력값도 자유 경로가 아니라 파일 이름 목록이나 제한된 상대 경로로 받는 편이 안전합니다.

공개 API 조회 Tool도 좋은 두 번째 예제입니다. ISBN으로 공개 도서 정보를 조회하거나, 공개 환율 API를 읽어 오는 식입니다. 이때도 API 키가 필요 없는 엔드포인트부터 시작하고, 네트워크 실패·응답 없음·잘못된 입력을 설명 가능한 결과로 돌려주는 습관을 들이는 것이 좋습니다. Tool 실행이 실패했지만 서버 자체는 정상인 상황이라면 다음처럼 isError: true를 반환할 수 있습니다.

return {
  content: [
    {
      type: "text",
      text: "Book information API did not respond. Please try again later."
    }
  ],
  isError: true
};

쓰기 Tool은 더 나중에 만듭니다. 파일 수정, 이슈 생성, 메시지 발송은 모두 외부 상태를 바꿉니다. 이런 Tool에는 미리보기, 사용자 승인, 감사 로그가 필요합니다.

첫 서버 점검표

첫 MCP 서버가 잘 만들어졌는지는 기능 수가 아니라 다음 항목으로 확인합니다.

  • Tool 이름만 보고도 동작을 예상할 수 있는가?

  • description에 용도와 제한이 분명히 적혀 있는가?

  • 입력 스키마가 숫자, enum, 범위 제한 등으로 좁혀져 있는가?

  • 필요한 경우 outputSchema와 structuredContent로 구조화된 결과를 제공하는가?

  • 반환값이 Host가 읽을 수 있는 content 형식인가?

  • 로컬 Host 설정의 command와 args가 정확한가?

  • Host가 node를 찾지 못할 때 실행 파일 절대 경로로 바꿔 볼 수 있는가?

  • 로그를 stdout이 아니라 stderr나 파일에 남기는가?

  • 오류가 났을 때 서버가 죽지 않고 설명 가능한 결과를 돌려주는가?

이번 장에서 만든 서버는 작습니다. 하지만 이 작은 서버 안에 MCP의 핵심 흐름이 모두 들어 있습니다. Host가 프로세스를 실행하고, 서버가 Tool을 노출하고, 모델이 입력을 구성하고, 서버가 검증된 함수만 실행합니다. 이 흐름을 이해하면 다음 장에서 사내 API, 데이터베이스, 업무 도구를 연결하는 실무형 MCP 서버로 확장할 준비가 됩니다.

참고

1Server | MCP TypeScript SDK (v1)

2Tools | MCP TypeScript SDK

3Transports | Model Context Protocol

책 목록

MCP: AI 에이전트 연결의 표준

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기