TypeScript SDK를 사용하여 프로젝트 설정부터 도구, 리소스, 프롬프트 구현, 테스트까지 MCP 서버를 구축하는 전 과정을 다룹니다.
TypeScript MCP 서버를 구축하기 위한 개발 환경을 준비합니다. 공식 SDK인 @modelcontextprotocol/sdk를 사용합니다.
mkdir weather-mcp-server && cd weather-mcp-server
npm init -ynpm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx@modelcontextprotocol/sdk: 공식 MCP TypeScript SDKzod: 런타임 스키마 검증 라이브러리. SDK가 Zod를 사용하여 도구의 입력 스키마를 정의합니다.tsx: TypeScript 파일을 직접 실행하기 위한 도구{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
}
}package.json에 "type": "module"을 반드시 설정해야 합니다. MCP SDK는 ES 모듈 형식으로 배포되므로, CommonJS 환경에서는 import 에러가 발생합니다.
weather-mcp-server/
src/
index.ts # 서버 진입점
tools/
weather.ts # 날씨 도구
forecast.ts # 예보 도구
resources/
locations.ts # 지역 리소스
prompts/
weather-report.ts # 날씨 리포트 프롬프트
tsconfig.json
package.json
MCP TypeScript SDK의 핵심은 McpServer 클래스입니다. 이 클래스는 도구, 리소스, 프롬프트를 등록하고, 전송 계층과 연결하는 고수준 API를 제공합니다.
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-server",
version: "1.0.0",
description: "날씨 정보를 제공하는 MCP 서버입니다.",
});McpServer는 내부적으로 Server 클래스(저수준 프로토콜 구현)를 래핑하고, 편리한 메서드(tool(), resource(), prompt())를 추가한 것입니다. 대부분의 경우 McpServer만으로 충분합니다.
날씨 정보를 조회하는 도구를 구현합니다. server.tool() 메서드는 이름, 설명, 입력 스키마, 핸들러 함수를 받습니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerWeatherTools(server: McpServer) {
// 현재 날씨 조회
server.tool(
"get_current_weather",
"지정된 도시의 현재 날씨 정보를 조회합니다. 온도, 습도, 날씨 상태 등을 반환합니다.",
{
city: z.string().describe("날씨를 조회할 도시 이름 (예: 서울, 부산)"),
units: z
.enum(["celsius", "fahrenheit"])
.default("celsius")
.describe("온도 단위"),
},
async (params) => {
const weather = await fetchWeather(params.city, params.units);
return {
content: [
{
type: "text" as const,
text: JSON.stringify(weather, null, 2),
},
],
};
}
);
}
interface WeatherData {
city: string;
temperature: number;
units: string;
humidity: number;
condition: string;
wind_speed: number;
observed_at: string;
}
async function fetchWeather(
city: string,
units: string
): Promise<WeatherData> {
// 실제 구현에서는 외부 날씨 API를 호출합니다
const mockData: Record<string, WeatherData> = {
서울: {
city: "서울",
temperature: 18,
units: units,
humidity: 45,
condition: "맑음",
wind_speed: 3.2,
observed_at: new Date().toISOString(),
},
부산: {
city: "부산",
temperature: 22,
units: units,
humidity: 60,
condition: "흐림",
wind_speed: 5.1,
observed_at: new Date().toISOString(),
},
};
const data = mockData[city];
if (!data) {
throw new Error(city + "의 날씨 정보를 찾을 수 없습니다.");
}
return data;
}SDK는 Zod 스키마를 자동으로 JSON Schema로 변환하여 프로토콜에 노출합니다. 따라서 z.string().describe("설명")처럼 작성하면 JSON Schema의 description 필드에 자동 매핑됩니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerForecastTools(server: McpServer) {
server.tool(
"get_forecast",
"지정된 도시의 향후 날씨 예보를 조회합니다. 최대 7일까지 예보를 제공합니다.",
{
city: z.string().describe("예보를 조회할 도시 이름"),
days: z
.number()
.min(1)
.max(7)
.default(3)
.describe("예보 일수 (1~7일)"),
},
async (params) => {
try {
const forecast = await fetchForecast(params.city, params.days);
return {
content: [
{
type: "text" as const,
text: formatForecast(params.city, forecast),
},
],
};
} catch (error) {
return {
content: [
{
type: "text" as const,
text: "예보 조회에 실패했습니다: " + (error as Error).message,
},
],
isError: true,
};
}
}
);
}
interface ForecastDay {
date: string;
high: number;
low: number;
condition: string;
precipitation: number;
}
async function fetchForecast(
city: string,
days: number
): Promise<ForecastDay[]> {
// 실제 구현에서는 외부 API를 호출합니다
const forecasts: ForecastDay[] = [];
const conditions = ["맑음", "구름 조금", "흐림", "비", "눈"];
const today = new Date();
for (let i = 0; i < days; i++) {
const date = new Date(today);
date.setDate(date.getDate() + i + 1);
forecasts.push({
date: date.toISOString().split("T")[0],
high: Math.round(15 + Math.random() * 15),
low: Math.round(5 + Math.random() * 10),
condition: conditions[Math.floor(Math.random() * conditions.length)],
precipitation: Math.round(Math.random() * 80),
});
}
return forecasts;
}
function formatForecast(city: string, forecast: ForecastDay[]): string {
const lines = [city + " " + forecast.length + "일 예보:", ""];
for (const day of forecast) {
lines.push(
day.date +
": " + day.condition +
" (최고 " + day.high + "도 / 최저 " + day.low + "도" +
", 강수확률 " + day.precipitation + "%)"
);
}
return lines.join("\n");
}도구 핸들러에서 에러가 발생하면 두 가지 선택지가 있습니다. (1) 예외를 throw하면 프로토콜 수준의 에러 응답이 됩니다. (2) isError: true가 포함된 결과를 반환하면 LLM이 에러를 인식하고 사용자에게 적절히 안내합니다. 일반적으로 (2)가 더 나은 사용자 경험을 제공합니다. 사용자에게 안내 가능한 에러는 isError: true로, 복구 불가능한 시스템 에러는 예외로 처리하는 것을 권장합니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export function registerResources(server: McpServer) {
// 지원하는 도시 목록 리소스
server.resource(
"supported-cities",
"weather://cities",
{ description: "날씨 정보를 제공하는 도시 목록", mimeType: "application/json" },
async () => ({
contents: [
{
uri: "weather://cities",
text: JSON.stringify({
cities: [
{ name: "서울", country: "KR", timezone: "Asia/Seoul" },
{ name: "부산", country: "KR", timezone: "Asia/Seoul" },
{ name: "제주", country: "KR", timezone: "Asia/Seoul" },
{ name: "인천", country: "KR", timezone: "Asia/Seoul" },
{ name: "대구", country: "KR", timezone: "Asia/Seoul" },
],
total: 5,
lastUpdated: new Date().toISOString(),
}, null, 2),
mimeType: "application/json",
},
],
})
);
// 서버 상태 리소스
server.resource(
"server-status",
"weather://status",
{ description: "날씨 서버의 현재 상태 정보" },
async () => ({
contents: [
{
uri: "weather://status",
text: JSON.stringify({
status: "healthy",
uptime: process.uptime(),
version: "1.0.0",
apiQuota: { used: 150, limit: 1000, resetAt: "2026-04-05T00:00:00Z" },
}, null, 2),
},
],
})
);
}server.resourceTemplate(
"city-detail",
"weather://cities/{city}",
{ description: "특정 도시의 상세 정보", mimeType: "application/json" },
async (uri, params) => {
const cityName = params.city;
const cityInfo = await getCityDetail(cityName);
return {
contents: [
{
uri: uri.toString(),
text: JSON.stringify(cityInfo, null, 2),
mimeType: "application/json",
},
],
};
}
);import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerPrompts(server: McpServer) {
server.prompt(
"daily-weather-report",
"특정 도시의 일일 날씨 리포트를 생성합니다",
[
{ name: "city", description: "리포트 대상 도시", required: true },
{
name: "audience",
description: "대상 독자 (general/farmer/traveler)",
required: false,
},
],
async (params) => {
const audienceGuide = {
general: "일반 독자를 위한 간결한 날씨 요약을 제공하세요.",
farmer: "농업 종사자를 위해 기온, 강수량, 습도 정보를 상세히 다루세요.",
traveler: "여행자를 위해 야외 활동 적합성과 복장 추천을 포함하세요.",
};
const audience = params.audience || "general";
const guide = audienceGuide[audience as keyof typeof audienceGuide]
|| audienceGuide.general;
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: [
params.city + "의 오늘 날씨 리포트를 작성해 주세요.",
"",
"작성 가이드:",
"- " + guide,
"- 날씨 데이터는 get_current_weather 도구를 사용하여 조회하세요.",
"- 예보 정보는 get_forecast 도구를 사용하여 3일치를 조회하세요.",
"- 리포트 형식: 제목, 현재 상황 요약, 향후 전망, 생활 조언",
].join("\n"),
},
},
],
};
}
);
server.prompt(
"compare-cities",
"여러 도시의 날씨를 비교합니다",
[
{ name: "cities", description: "비교할 도시 목록 (쉼표 구분)", required: true },
],
async (params) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: [
"다음 도시들의 날씨를 비교 분석해 주세요: " + params.cities,
"",
"각 도시의 현재 날씨를 get_current_weather 도구로 조회한 후,",
"기온, 습도, 날씨 상태를 비교하는 표를 작성하고,",
"여행이나 야외 활동에 가장 적합한 도시를 추천해 주세요.",
].join("\n"),
},
},
],
})
);
}모든 프리미티브를 하나의 서버로 조립합니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerWeatherTools } from "./tools/weather.js";
import { registerForecastTools } from "./tools/forecast.js";
import { registerResources } from "./resources/locations.js";
import { registerPrompts } from "./prompts/weather-report.js";
const server = new McpServer({
name: "weather-server",
version: "1.0.0",
description: "날씨 정보를 제공하는 MCP 서버입니다.",
});
// 프리미티브 등록
registerWeatherTools(server);
registerForecastTools(server);
registerResources(server);
registerPrompts(server);
// 전송 계층 연결 및 서버 시작
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather MCP Server가 실행되었습니다.");console.error()를 사용하여 로그를 출력하는 것에 주의하세요. stdio 전송에서 console.log()(stdout)는 프로토콜 메시지 채널이므로, 로그를 stdout에 출력하면 프로토콜 메시지와 혼합되어 파싱 에러가 발생합니다. 로그는 반드시 stderr에 출력해야 합니다.
구축한 서버를 Claude Desktop에 연결합니다. Claude Desktop의 설정 파일에 MCP 서버 정보를 추가합니다.
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["/absolute/path/to/weather-mcp-server/dist/index.js"]
}
}
}개발 중에는 tsx를 사용하여 빌드 없이 직접 실행할 수도 있습니다.
{
"mcpServers": {
"weather": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/weather-mcp-server/src/index.ts"]
}
}
}설정 파일을 수정한 후 Claude Desktop을 재시작하면, 날씨 서버의 도구들이 사용 가능해집니다.
MCP Inspector는 MCP 서버를 독립적으로 테스트할 수 있는 개발 도구입니다. 서버의 도구, 리소스, 프롬프트를 직접 호출하고 결과를 확인할 수 있습니다.
npx @modelcontextprotocol/inspector node dist/index.jsInspector는 웹 브라우저에서 실행되며, 다음 작업을 수행할 수 있습니다.
MCP Inspector는 서버 개발 과정에서 매우 유용한 도구입니다. LLM을 거치지 않고 도구를 직접 호출할 수 있으므로, 서버 로직의 정확성을 빠르게 검증할 수 있습니다. 개발 초기에 Inspector로 충분히 테스트한 후 LLM 연동 테스트로 넘어가는 것을 권장합니다.
MCP 서버의 도구 로직을 단위 테스트로 검증할 수 있습니다. SDK의 Client를 사용하여 프로그래밍 방식으로 서버에 연결합니다.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { registerWeatherTools } from "../tools/weather.js";
async function createTestClient(): Promise<Client> {
const server = new McpServer({ name: "test-server", version: "0.0.1" });
registerWeatherTools(server);
const client = new Client({ name: "test-client", version: "0.0.1" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
return client;
}
// 테스트 실행
async function runTests() {
const client = await createTestClient();
// 도구 목록 확인
const tools = await client.listTools();
console.assert(
tools.tools.some((t) => t.name === "get_current_weather"),
"get_current_weather 도구가 존재해야 합니다"
);
// 도구 호출
const result = await client.callTool({
name: "get_current_weather",
arguments: { city: "서울" },
});
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
const data = JSON.parse(text);
console.assert(data.city === "서울", "도시 이름이 일치해야 합니다");
console.assert(typeof data.temperature === "number", "온도가 숫자여야 합니다");
console.error("모든 테스트를 통과했습니다.");
}
runTests().catch(console.error);InMemoryTransport는 네트워크나 프로세스 없이 클라이언트와 서버를 메모리 내에서 연결합니다. 단위 테스트에 이상적인 전송 방식입니다.
이 장에서는 TypeScript SDK를 사용하여 완전한 MCP 서버를 구축했습니다.
@modelcontextprotocol/sdk와 Zod를 사용한 TypeScript 프로젝트 구성6장에서는 Python FastMCP 프레임워크를 사용하여 동일한 기능의 서버를 구축합니다. 데코레이터 기반의 직관적인 API와 비동기 지원을 활용하여 더 간결한 코드로 서버를 구현하는 방법을 다루겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Python의 FastMCP 프레임워크를 사용하여 데코레이터 기반의 간결하고 직관적인 MCP 서버를 구축하는 방법을 다룹니다.
MCP 서버가 제공하는 세 가지 핵심 프리미티브의 스키마 정의, 구현 패턴, 실전 활용 사례를 상세히 다룹니다.
MCP 클라이언트를 직접 구현하여 서버에 연결하고, LLM과 통합하여 도구 호출 파이프라인을 완성하는 방법을 다룹니다.