테마 전환

MCP Server 개발 입문: 첫 MCP 서비스를 처음부터 구축하기

Easton editorial illustration: trace beacon network

들어가며: Cursor에서 코드를 작성하다가 AI가 프로젝트 의존성의 최신 버전을 직접 확인해 주면 좋겠다고 생각한 적이 있나요? 또는 Claude로 데이터를 분석할 때 데이터베이스의 정보를 읽게 하고 싶었던 적은요? 각각 구현하려면 AI 도구마다 별도의 어댑터 코드를 작성해야 합니다. MCP Server를 사용하면 한 번만 작성해도 MCP를 지원하는 모든 클라이언트에서 활용할 수 있습니다. 이 글에서는 TypeScript로 완전한 MCP Server를 처음부터 직접 만들어 봅니다.

30분
시작 시간
처음부터 실행까지
3가지
핵심 기능
Tools/Resources/Prompts
1000+
MCP 서버
GitHub 오픈 소스 커뮤니티
Source: MCP 공식 데이터 (2025년)

MCP란? 3분 만에 핵심 개념 이해하기

USB 인터페이스 이야기

몇 년 전 디지털 기기를 사용해 본 사람이라면 다소 불편했던 시기를 기억할 것입니다. 마우스는 둥근 포트, 키보드는 사각 포트, 프린터는 병렬 포트를 사용해 기기마다 맞는 단자를 찾아야 했습니다. 이후 USB가 등장하면서 하나의 인터페이스로 모든 문제를 해결했습니다.

MCP(Model Context Protocol)는 AI 도구 세계의 ‘USB 표준’이 되어 가고 있습니다.

MCP가 없으면 AI가 특정 데이터 소스에 접근하도록 할 때 AI 도구마다 별도의 어댑터 계층을 작성해야 합니다. Claude용 플러그인, Cursor용 확장 기능, Windsurf용 구현을 각각 만드는 식입니다. 복잡도는 N x M(N개 데이터 소스 x M개 AI 도구)이 됩니다.

MCP를 사용하면 MCP Server 하나만 작성하면 되고, MCP를 지원하는 모든 클라이언트가 직접 호출할 수 있습니다. 복잡도는 N+M으로 줄어듭니다.

3계층 구조는 간단합니다.

+-------------+     +-------------+     +-------------+
|    Host     | ->  |   Client    | ->  |   Server    |
|  (Claude)   |     | (MCP 클라이언트)|   | (내 서비스) |
+-------------+     +-------------+     +-------------+
  • Host: Claude Desktop이나 Cursor 같은 AI 애플리케이션 자체
  • Client: Host와 통신하는 MCP 클라이언트
  • Server: 구체적인 기능을 제공하도록 직접 작성한 서비스

MCP Server가 제공하는 세 가지 기능

MCP Server는 서로 다른 세 가지 유형의 기능을 제공할 수 있습니다.

기능용도예시
Tools(도구)작업 실행날씨 조회, 메시지 전송, 데이터베이스 읽기
Resources(리소스)데이터 제공파일 내용, API 응답, 설정 정보
Prompts(프롬프트)사전 정의 템플릿코드 리뷰 템플릿, 일일 보고서 생성 템플릿

Tools는 AI가 호출해 작업을 실행할 수 있는 ‘함수’, Resources는 AI가 내용을 읽을 수 있는 ‘데이터 소스’, Prompts는 AI가 작업을 더 빠르게 이해하도록 돕는 ‘템플릿’으로 생각하면 됩니다.

기존 글과의 차이: 다른 MCP 튜토리얼에서 Python과 FastMCP로 구현한 예시를 보았을 수 있습니다. 이 글은 프론트엔드와 풀스택 개발자에게 더 익숙한 TypeScript 공식 SDK를 사용합니다. 두 방식의 기능은 동등하므로 익숙한 언어를 선택하면 됩니다.

"https://modelcontextprotocol.io"


개발 환경 준비

사전 요구 사항

이 글은 다음 조건을 충족한다고 가정합니다.

  • Node.js 18+ 또는 Bun 1.0+가 설치되어 있음
  • TypeScript를 사용해 본 적이 있고 interfaceasync/await를 이해함
  • Claude Desktop 또는 MCP를 지원하는 클라이언트(Cursor, Windsurf 등)가 있음

Bun을 사용해 본 적이 없다면 한번 시도해 보세요. npm보다 훨씬 빠르고 TypeScript 지원이 내장되어 있어 ts-node를 별도로 설정할 필요가 없습니다.

프로젝트 초기화

# 프로젝트 디렉터리 생성
mkdir mcp-weather-server && cd mcp-weather-server

# 초기화(Bun 또는 npm 사용)
bun init -y
# 또는 npm init -y

# MCP TypeScript SDK 설치
bun add @modelcontextprotocol/sdk zod
# 또는 npm install @modelcontextprotocol/sdk zod

여기서는 두 가지 의존성을 사용합니다.

  • @modelcontextprotocol/sdk: MCP 공식 TypeScript SDK
  • zod: 도구 매개변수 schema를 정의하는 TypeScript 런타임 타입 검증 라이브러리

TypeScript 설정 핵심 사항

bun init을 사용했다면 tsconfig.json이 이미 설정되어 있습니다. 직접 설정한다면 다음 옵션에 주의하세요.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "strict": true
  }
}

moduleResolution: "bundler"는 ESM 모듈에서 중요합니다. 설정하지 않으면 “xxx is not defined” 오류가 발생할 수 있습니다.


실습: 날씨 조회 MCP Server 직접 만들기

이 튜토리얼에서는 다음 기능을 갖춘 완전한 MCP Server를 만듭니다.

  1. AI의 호출 요청 수신
  2. OpenWeatherMap API로 실시간 날씨 조회
  3. 형식화된 결과 반환

프로젝트 구조 설계

mcp-weather-server/
+-- src/
|   +-- index.ts      # 진입점 파일
|   +-- weather.ts    # 날씨 도구 구현
|   +-- resources.ts  # 리소스 정의
+-- package.json
+-- tsconfig.json

실제 코드는 모두 index.ts에 넣어도 됩니다(이 글에서도 그렇게 진행합니다). 다만 모듈로 나누면 유지 관리가 더 쉽습니다.

1단계: MCP Server 기본 구조 만들기

가장 간단한 것부터 시작해 실행할 수 있는 MCP Server를 만듭니다.

// src/index.ts
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: "weather-service",
  version: "1.0.0",
});

// 도구(Tools) 등록
server.tool(
  "get_weather",
  "지정한 도시의 현재 날씨 정보 조회",
  {
    city: z.string().describe("도시 이름(예: 베이징, 상하이)"),
  },
  async ({ city }) => {
    // 도구 구현은 다음 절에서 다룹니다
    return { content: [{ type: "text", text: `${city} 날씨 조회 중...` }] };
  }
);

// 서버 시작
const transport = new StdioServerTransport();
await server.connect(transport);

McpServer는 SDK가 제공하는 핵심 클래스이며 nameversion을 전달해야 합니다. tool() 메서드는 도구를 등록합니다. 첫 번째 매개변수는 도구 이름, 두 번째는 설명, 세 번째는 매개변수 schema, 마지막은 실행 함수입니다.

2단계: 날씨 조회 도구 구현하기(핵심 코드)

이제 도구가 실제로 작동하게 만들어 봅니다. OpenWeatherMap의 무료 API를 사용합니다.

// src/weather.ts
import { z } from "zod";

// OpenWeatherMap API 응답 타입 정의
interface WeatherResponse {
  name: string;
  main: { temp: number; feels_like: number; humidity: number };
  weather: [{ description: string }];
  wind: { speed: number };
}

// 날씨 조회 도구 구현
server.tool(
  "get_weather",
  "지정한 도시의 현재 날씨 정보 조회",
  {
    city: z.string().describe("도시 이름(예: 베이징, 상하이)"),
  },
  async ({ city }) => {
    const API_KEY = process.env.OPENWEATHER_API_KEY;
    const url = `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${API_KEY}&units=metric&lang=zh_cn`;

    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`API 요청 실패: ${response.status}`);
      }

      const data: WeatherResponse = await response.json();

      // 형식화된 결과 반환
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify({
              city: data.name,
              temperature: `${data.main.temp}°C`,
              feels_like: `${data.main.feels_like}°C`,
              description: data.weather[0].description,
              humidity: `${data.main.humidity}%`,
              wind_speed: `${data.wind.speed} m/s`,
            }, null, 2),
          },
        ],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `조회 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`,
          },
        ],
        isError: true,
      };
    }
  }
);

다음 사항에 주의하세요.

  1. API Key는 환경 변수에서 읽기: 비밀 키를 코드에 직접 작성하지 마세요.
  2. 오류 처리: 클라이언트가 호출 실패를 알 수 있도록 isError: true를 반환합니다.
  3. 타입 정의: WeatherResponse 인터페이스를 사용하면 TypeScript가 데이터 구조를 검사할 수 있습니다.

OpenWeatherMap에서 무료 계정을 등록하고 API Key를 받은 뒤 환경 변수를 설정합니다.

export OPENWEATHER_API_KEY=your_api_key_here

3단계: Resources 추가하기(선택 사항이지만 권장)

Resources를 사용하면 Server가 ‘읽기 전용’ 데이터를 제공할 수 있습니다. 예를 들어 AI가 서버 상태를 확인할 수 있는 리소스를 제공할 수 있습니다.

// src/resources.ts

// 서버 상태 정보 제공
server.resource(
  "server-status",
  "status://server",
  async (uri) => ({
    contents: [
      {
        uri: uri.href,
        text: JSON.stringify({
          name: "Weather Service",
          version: "1.0.0",
          status: "running",
          timestamp: new Date().toISOString(),
        }, null, 2),
      },
    ],
  })
);

// API 문서 제공
server.resource(
  "api-docs",
  "docs://api",
  async (uri) => ({
    contents: [
      {
        uri: uri.href,
        text: `
# Weather MCP Server API

## Tools
- get_weather(city: string): 지정한 도시의 날씨 조회

## Resources
- status://server - 서버 상태
- docs://api - API 문서
        `.trim(),
      },
    ],
  })
);

resource()의 처음 두 매개변수는 리소스 이름과 URI이고, 세 번째는 읽기 함수입니다. URI는 status://, docs://처럼 구분할 수 있기만 하면 어떤 scheme이든 사용할 수 있습니다.

4단계: Prompts 추가하기(고급 기능)

Prompts는 사전 정의된 대화 템플릿입니다. 예를 들어 ‘날씨 보고서’ 템플릿을 정의하면 AI가 사용할 때 도시 이름을 자동으로 채울 수 있습니다.

// 사전 정의된 날씨 보고서 템플릿
server.prompt(
  "weather_report",
  "형식화된 날씨 보고서 생성",
  {
    city: z.string().describe("도시 이름"),
    include_tips: z.boolean().optional().describe("옷차림 조언 포함 여부"),
  },
  ({ city, include_tips }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `${city}의 날씨 보고서를 작성해 주세요.${include_tips ? " 옷차림 조언도 함께 알려 주세요." : ""}`,
        },
      },
    ],
  })
);

prompt()는 메시지 배열을 반환하며 각 메시지에는 rolecontent가 있습니다. 따라서 AI는 이 기능을 사용할 때 미리 설정된 컨텍스트를 바로 받습니다.

5단계: 진입점 파일 완성하기

앞의 코드를 모두 src/index.ts에 통합하고 오류 처리를 추가합니다.

// src/index.ts (전체 버전)
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: "weather-service",
  version: "1.0.0",
});

// 모든 도구, 리소스, 프롬프트 등록
// ...(위 코드)

// 오류 처리
process.stdin.on("error", (err) => {
  console.error("표준 입력 오류:", err);
  process.exit(1);
});

process.stdout.on("error", (err) => {
  console.error("표준 출력 오류:", err);
  process.exit(1);
});

// 정상 종료
process.on("SIGINT", async () => {
  await server.close();
  process.exit(0);
});

// 서버 시작
const transport = new StdioServerTransport();
await server.connect(transport);

console.error("MCP Weather Server가 시작되었습니다. 연결을 기다리는 중...");

StdioServerTransport는 표준 입력과 표준 출력으로 통신하므로 stdin/stdout 오류 처리가 중요합니다. SIGINT 처리를 추가하면 Ctrl+C로 서비스를 정상적으로 중지할 수 있습니다.

bun run src/index.ts로 시작했을 때 ‘시작되었습니다’라는 메시지가 표시되면 정상입니다.


클라이언트 설정: Claude에서 Server 사용하기

Server를 만들었으니 이제 Claude Desktop이나 Cursor에서 호출할 수 있게 설정합니다.

Claude Desktop 설정

설정 파일을 찾습니다.

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Server 설정을 추가합니다.

{
  "mcpServers": {
    "weather": {
      "command": "bun",
      "args": ["run", "/absolute/path/to/mcp-weather-server/src/index.ts"],
      "env": {
        "OPENWEATHER_API_KEY": "API Key 입력"
      }
    }
  }
}

주의: args의 경로는 반드시 절대 경로여야 합니다. 상대 경로를 사용하면 Server를 시작하지 못합니다.

Cursor / Windsurf 설정

Cursor와 Windsurf도 비슷하게 설정합니다. IDE 설정에서 MCP 구성을 찾아 서버 항목을 추가하세요(형식은 위와 같습니다).

Cursor 설정 파일은 일반적으로 다음 위치에 있습니다.

  • macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
  • 또는 IDE 내부: 설정 -> AI -> MCP -> 서버 추가

Server 테스트하기

  1. Claude Desktop / Cursor를 다시 시작합니다.
  2. 대화창에 “베이징 날씨를 확인해 줘”라고 입력합니다.
  3. Claude가 자동으로 MCP Server를 호출해야 합니다.

다음과 비슷한 출력이 표시되면 성공입니다.

{
  "city": "베이징",
  "temperature": "18°C",
  "feels_like": "16°C",
  "description": "흐림",
  "humidity": "65%",
  "wind_speed": "3.2 m/s"
}

자주 발생하는 문제 해결

문제가능한 원인해결 방법
Server가 연결되지 않음경로 오류args의 절대 경로 확인
API Key가 유효하지 않음환경 변수가 전달되지 않음env 설정이 올바른지 확인
응답 없음TypeScript 컴파일 오류먼저 bun build 또는 tsc로 컴파일
권한 오류설정 파일 권한설정 파일을 읽을 수 있는지 확인

"https://github.com/modelcontextprotocol/typescript-sdk"


확장 및 배포 제안

도구 더 추가하기

날씨 조회는 시작일 뿐입니다. 다음 기능을 추가할 수 있습니다.

  • 과거 날씨 조회: 과거 데이터 API를 호출해 특정 날짜의 날씨 반환
  • 여러 도시 비교: 여러 도시를 한 번에 조회해 비교표 반환
  • 기상 특보 구독: 악천후 특보가 있는지 확인

이 도구들은 구현 로직만 다를 뿐 get_weather와 완전히 같은 방식으로 등록합니다.

배포 방식 비교

Server를 팀과 공유하려면 로컬 stdio 전송만으로는 부족합니다. 다음과 같은 배포 방식을 사용할 수 있습니다.

배포 방식적합한 상황장점단점
로컬 stdio개인 사용, 개발 테스트간단하고 안전함공유할 수 없음
HTTP/SSE팀 공유, 다중 사용자원격 접근 가능인증 처리 필요
Serverless프로덕션 환경자동 확장 및 축소콜드 스타트 지연

프로덕션 환경 주의 사항

인증: HTTP로 배포할 때는 반드시 인증을 구현해야 합니다. MCP는 OAuth 2.1을 지원하며 간단한 API Key를 사용할 수도 있습니다.

// 요청 헤더의 API Key 확인
const apiKey = request.headers.get("Authorization");
if (apiKey !== `Bearer ${process.env.API_KEY}`) {
  return new Response("Unauthorized", { status: 401 });
}

요청 제한: 악의적인 호출로 API 할당량이 소진되지 않게 해야 합니다. express-rate-limit 또는 Cloudflare Workers의 내장 요청 제한 기능을 사용할 수 있습니다.

로그: pino 또는 winston으로 도구 호출을 기록하면 문제를 해결하기 쉽습니다.

import pino from "pino";
const logger = pino();

server.tool("get_weather", /* ... */, async ({ city }) => {
  logger.info({ city }, "날씨 조회");
  // ...
});

모니터링: 도구 호출 성공률과 응답 시간을 추적합니다. Prometheus + Grafana가 자주 사용되는 조합입니다.


마무리

이 글에서는 TypeScript로 MCP Server를 처음부터 작성하는 방법을 설명했습니다. 다룬 내용은 다음과 같습니다.

  • MCP의 핵심 개념과 3계층 구조 이해
  • MCP TypeScript SDK로 서버 생성
  • 날씨 조회 도구(Tools) 구현
  • 서버 상태 리소스(Resources) 추가
  • 날씨 보고서 템플릿(Prompts) 정의
  • Claude Desktop / Cursor에서 Server 호출 설정

이제 다음 작업을 시도할 수 있습니다.

  1. 자주 사용하는 API용 MCP 래퍼 구축(GitHub, Slack, Notion 등)
  2. 내부 비즈니스 시스템용 MCP 인터페이스 생성(CRM, 데이터베이스)
  3. MCP 커뮤니티의 기존 결과물 탐색

심화 학습 자료:

MCP 프로토콜의 원리를 더 깊이 알고 싶다면 MCP 프로토콜 원리 자세히 알아보기를 읽어 보세요.

FAQ

MCP Server를 개발하려면 어떤 기초 지식이 필요한가요?
JavaScript 또는 TypeScript 기초가 필요합니다. 이 글은 MCP TypeScript SDK를 사용하므로 async/await와 타입 정의에 익숙하면 시작할 수 있습니다.
MCP Server와 FastMCP는 어떤 차이가 있나요?
FastMCP는 Python 개발자에게 적합한 Python 프레임워크입니다. 이 글에서 사용하는 TypeScript 공식 SDK는 프론트엔드와 풀스택 개발자에게 적합합니다. 기능은 동등하므로 선호하는 기술 스택에 따라 선택하면 됩니다.
MCP Server가 정상적으로 작동하는지 어떻게 테스트하나요?
Claude Desktop을 설정한 뒤 대화창에 '베이징 날씨를 확인해 줘'와 같은 자연어 요청을 입력합니다. Claude가 자동으로 도구를 호출하고 결과를 반환하면 Server가 정상적으로 작동하는 것입니다.
MCP Server를 원격 서버에 배포할 수 있나요?
가능합니다. 이 글에서 사용하는 stdio 전송은 로컬 개발에 적합합니다. 프로덕션 환경에서는 HTTP/SSE 전송을 사용하고 OAuth 인증과 요청 제한을 구현해야 합니다.

3분 읽기 · 게시일: 2026년 3월 19일 · 수정일: 2026년 9월 8일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog