Skip to content
ioob.dev
Go back

MCP 5편 — 첫 MCP 서버 만들기: TypeScript SDK와 Inspector

· 6분 읽기
mcp 시리즈 (5/8)
  1. MCP 1편 — MCP란 무엇인가
  2. MCP 2편 — 서버가 노출하는 세 가지: Tools, Resources, Prompts
  3. MCP 3편 — 클라이언트가 노출하는 세 가지: Sampling, Roots, Elicitation
  4. MCP 4편 — 메시지 흐름: JSON-RPC 2.0과 lifecycle
  5. MCP 5편 — 첫 MCP 서버 만들기: TypeScript SDK와 Inspector
  6. MCP 6편 — Transport: stdio와 Streamable HTTP
  7. MCP 7편 — 인증과 권한: OAuth 2.1
  8. MCP 8편 — 운영, 레지스트리, 2026 로드맵
Table of contents

Table of contents

프로젝트 설정

Node.js 16 이상이 깔려 있어야 한다. 새 폴더를 만들고 의존성을 깐다.

mkdir weather && cd weather
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D @types/node typescript
mkdir src && touch src/index.ts

@modelcontextprotocol/sdk가 공식 TS SDK다. zod는 입력 스키마 정의용 — SDK가 zod 객체를 받아 JSON Schema로 변환해 spec의 inputSchema 자리에 넣는다.

package.json에 ESM과 빌드 스크립트를 추가한다.

{
  "type": "module",
  "bin": { "weather": "./build/index.js" },
  "scripts": { "build": "tsc && chmod 755 build/index.js" },
  "files": ["build"]
}

tsconfig.json을 만든다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

기본 설정은 끝났다.

서버 인스턴스와 헬퍼

src/index.ts 위에 import와 서버 인스턴스를 둔다.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const NWS_API_BASE = "https://api.weather.gov";
const USER_AGENT = "weather-app/1.0";

const server = new McpServer({
  name: "weather",
  version: "1.0.0",
});

McpServer4편에서 본 lifecycle을 알아서 처리한다. initialize 핸드셰이크, capability 협상, notifications/initialized까지 SDK가 자동이다. 우리가 채워야 할 건 어떤 도구를 노출할지뿐이다.

NWS API를 두 군데서 부르므로 헬퍼 하나를 둔다.

async function makeNWSRequest<T>(url: string): Promise<T | null> {
  const headers = { "User-Agent": USER_AGENT, Accept: "application/geo+json" };
  try {
    const response = await fetch(url, { headers });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return (await response.json()) as T;
  } catch (error) {
    console.error("Error making NWS request:", error);
    return null;
  }
}

stdio 서버에서는 console.log를 쓰면 안 된다. stdout으로 나가서 JSON-RPC 메시지에 섞이고 클라이언트가 파싱에 실패한다. 로그는 console.error로 stderr에 보내는 게 원칙이다.

Tool 등록 — get_alerts, get_forecast

2편에서 본 tools/list·tools/call이 코드에서는 server.registerTool 한 번이면 끝이다.

server.registerTool(
  "get_alerts",
  {
    description: "Get weather alerts for a state",
    inputSchema: {
      state: z.string().length(2)
        .describe("Two-letter state code (e.g. CA, NY)"),
    },
  },
  async ({ state }) => {
    const stateCode = state.toUpperCase();
    const url = `${NWS_API_BASE}/alerts?area=${stateCode}`;
    const data = await makeNWSRequest<AlertsResponse>(url);

    if (!data) {
      return { content: [{ type: "text", text: "Failed to retrieve alerts" }] };
    }
    const features = data.features ?? [];
    if (!features.length) {
      return { content: [{ type: "text", text: `No active alerts for ${stateCode}` }] };
    }
    const text = `Active alerts for ${stateCode}:\n\n${features.map(formatAlert).join("\n")}`;
    return { content: [{ type: "text", text }] };
  },
);

세 인자가 있다. 이름, 메타(description + inputSchema), 핸들러.

get_forecast도 같은 패턴이다 — 위경도를 받아 NWS의 grid point → forecast 두 단계 조회를 묶어 텍스트로 돌려준다. 코드 길이만 좀 더 길 뿐 구조는 동일하다.

LLM 측 동작은 이렇게 흐른다.

sequenceDiagram
    participant LLM
    participant Client
    participant Server
    Client->>Server: tools/list
    Server-->>Client: [get_alerts, get_forecast]
    Note over LLM: 사용자: "캘리포니아 날씨 경보 알려줘"
    LLM->>Client: get_alerts({state: "CA"}) 호출 결정
    Client->>Server: tools/call (get_alerts, {state: "CA"})
    Server->>Server: NWS API 호출
    Server-->>Client: content: [{type: "text", text: "..."}]
    Client->>LLM: 결과 전달
    LLM-->>Client: 사용자 답변 생성

stdio 트랜스포트로 실행

registerTool이 끝났으면 마지막은 트랜스포트를 골라 connect만 하면 된다.

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Weather MCP Server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error:", error);
  process.exit(1);
});

StdioServerTransport가 stdin·stdout을 잡아 6편에서 다룰 stdio 메시지 프레이밍(개행 구분)을 알아서 처리한다. 빌드해본다.

npm run build

build/index.js가 생성되면 준비 끝. 직접 실행하면 stdio가 떠 있긴 하지만 사용자 입력이 필요하니 무엇도 일어나지 않는다. 클라이언트에서 띄워야 한다.

Inspector로 동작 확인

Claude Desktop에 곧바로 붙이기 전에, 서버 단독으로 도구가 잘 보이는지 MCP Inspector에서 확인한다. Inspector는 클라이언트 역할을 대신해주는 디버깅 GUI다. 설치 없이 npx로 띄운다.

npx @modelcontextprotocol/inspector node ./build/index.js

브라우저가 열리고, 서버에 자동으로 연결된 상태로 GUI가 뜬다.

MCP Inspector 인터페이스

출처: Model Context Protocol — Inspector 공식 문서

탭이 자원·프롬프트·도구별로 갈려 있다. Tools 탭에 들어가면 서버가 노출한 get_alerts, get_forecast 두 개가 보이고, 인자를 입력해 직접 호출할 수 있다. 결과 콘텐츠와 응답 시간이 그대로 보인다. 호출이 실패하면 에러 응답을 그 자리에서 확인할 수 있어 코드 → 빌드 → 호스트 재시작보다 한 자릿수 빠른 피드백이 된다.

서버 capability도 별도 패널에서 보인다. 4편에서 본 tools.listChanged, resources.subscribe 같은 옵션이 어떻게 협상됐는지 한눈에 들어온다.

Claude Desktop에 연결

Inspector에서 동작이 확인됐으면 Claude Desktop에 붙인다. 설정 파일을 연다.

# macOS
code ~/Library/Application\ Support/Claude/claude_desktop_config.json

mcpServers에 항목을 추가한다.

{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/TO/weather/build/index.js"]
    }
  }
}

경로는 절대 경로여야 한다. Claude Desktop을 재시작하면 자기 안에 weather 서버용 클라이언트 인스턴스 하나(1편의 그림)가 만들어지면서, 도구 메뉴에 망치 아이콘과 함께 두 도구가 보인다.

채팅창에 “Sacramento, CA 지역의 활성 기상 경보 좀 알려줘”라고 치면 Claude가 get_alerts를 골라 호출하고, 결과를 받아 답한다.

Weather 서버를 호출한 Claude Desktop 화면

출처: Model Context Protocol — Build a Server 공식 quickstart

도구 호출 직전에 툴 호출을 허용할지 묻는 동의 화면이 뜬다 — 2편에서 spec이 “model-controlled tools에는 사용자 동의가 있어야 한다”고 강하게 권장한 부분이 호스트 UI에 그대로 나타난다.

한 사이클의 완성

코드는 100여 줄, 새 spec 지식은 거의 안 들어갔다. 그동안 1~4편에서 본 것들이 코드의 어디로 떨어지는지가 분명해진다.

Spec 조각코드 위치
initialize 핸드셰이크McpServer 생성자가 자동 처리
tools/list 카탈로그registerTool로 등록한 메타데이터
tools/call 호출registerTool의 핸들러 함수
inputSchemazod 객체 → SDK가 JSON Schema로 변환
stdio 트랜스포트StdioServerTransport() 한 줄
capability 협상SDK가 자동, 우리는 도구를 등록한 것만으로 tools.listChanged: true가 자동 선언됨

여기서 한 칸 더 나가려면 원격에 띄우기다. stdio 대신 HTTP로 트랜스포트를 바꾸면 같은 서버를 SaaS로 굴릴 수 있고, 그러면 인증·세션 같은 새 관심사가 따라온다.


다음 편에서는 그 트랜스포트 차원을 깊이 본다. stdio가 어떻게 돌아가는지, Streamable HTTP가 무엇을 더 풀고 무엇을 새로 짊어지는지, 세션과 재개(resumability)는 어떤 모양인지 — 이번 편에서 한 줄(StdioServerTransport)로 처리한 것을 메시지 단위로 펼쳐본다.

6편: Transport — stdio와 Streamable HTTP


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
MCP 4편 — 메시지 흐름: JSON-RPC 2.0과 lifecycle
Next Post
MCP 6편 — Transport: stdio와 Streamable HTTP