컴퓨터 액세스 시스템

첫 번째 MCP 서버 구축하기


Model Context Protocol(MCP)은 AI 생태계에서 범용 어댑터에 가장 가까운 것입니다. AI 모델이 도구를 발견하고 호출하는 방법, 그리고 이러한 도구가 구조화된 결과를 반환하는 방법을 정의합니다. MCP 서버가 실행되면 호환되는 모든 클라이언트 — Claude, Claude Code, 또는 MCP를 지원하는 모든 에이전트 프레임워크 — 가 추가 글루 코드 없이 사용할 수 있습니다.

이 가이드는 실제 MCP 서버를 처음부터 구축하는 방법을 안내합니다. 장난감 예제가 아닌, 프로덕션 사용에 확장할 수 있는 패턴입니다.

MCP가 실제로 무엇인가

MCP는 stdio(원격 서버의 경우 HTTP+SSE)를 통한 JSON-RPC 2.0 프로토콜입니다. 클라이언트와 서버는 소수의 메시지 유형을 교환합니다:

  • tools/list — 클라이언트가 사용 가능한 도구를 묻습니다
  • tools/call — 클라이언트가 인수와 함께 도구를 호출합니다
  • resources/list / resources/read — 클라이언트가 파일, 데이터베이스, 또는 서버가 노출하는 모든 컨텍스트를 읽습니다

서버는 각 도구의 입력을 위한 JSON Schema와 함께 도구를 미리 선언합니다. 클라이언트는 이 스키마를 사용하여 유효한 호출을 구성합니다. 전체 상호작용은 클라이언트 관점에서 무상태입니다. 서버는 필요한 상태를 내부적으로 관리합니다.

이 단순함이 핵심입니다. MCP는 프레임워크가 아닙니다. Postgres 데이터베이스, Kubernetes 클러스터, GitHub 저장소, 독점적인 내부 API 등 무엇이든 래핑하고 모든 AI 모델이 사용할 수 있는 타입이 있고 호출 가능한 함수 집합으로 노출할 수 있는 계약입니다.

설정

Node.js 18+와 TypeScript 프로젝트가 필요합니다. 공식 MCP SDK는 보일러플레이트를 최소화합니다:

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

tsconfig.json을 추가합니다:

{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"outDir": "./dist"
},
"include": ["src"]
}

package.json에 시작 스크립트를 추가합니다:

{
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}

서버 구축

src/index.ts를 생성합니다. 구조는 항상 같습니다: 서버를 초기화하고, 도구를 등록하고, stdio 트랜스포트에 연결합니다.

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

이제 도구를 등록합니다. 각 도구에는 이름, 모델이 언제 호출할지 결정하는 데 사용하는 설명, 그리고 입력 스키마가 있습니다:

server.tool(
"get_weather",
"Get current weather conditions for a city",
{
city: z.string().describe("The city name to get weather for"),
units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
},
async ({ city, units }) => {
// Your actual implementation here
const data = await fetchWeather(city, units);
return {
content: [
{
type: "text",
text: `Weather in ${city}: ${data.temp}°${units === "celsius" ? "C" : "F"}, ${data.condition}`,
},
],
};
}
);

서버를 stdio 트랜스포트에 연결합니다:

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

이것이 완전한 서버 스켈레톤입니다. 나머지는 도구를 추가하는 것입니다.

도구 설계 패턴

MCP 서버의 품질은 도구를 얼마나 잘 설계하느냐에 달려 있습니다. 중요한 몇 가지 패턴:

광범위한 모놀리스보다 좁고 구성 가능한 도구

하나의 일을 하는 도구가 많은 일을 하는 것보다 더 유용합니다. 모델은 좁은 도구를 예상치 못한 방식으로 결합할 수 있습니다. 광범위한 도구는 모델을 제한합니다.

이것을 피하세요:

server.tool("manage_database", "Do anything with the database", {
operation: z.enum(["read", "write", "delete", "schema"]),
query: z.string(),
// ... many optional params
})

이것을 선호하세요:

server.tool("query_records", "Run a SELECT query and return rows as JSON", {
table: z.string(),
where: z.string().optional().describe("SQL WHERE clause, e.g. 'status = active'"),
limit: z.number().default(50),
})
server.tool("get_schema", "Return the column definitions for a table", {
table: z.string(),
})

입력을 정확하게 설명하세요

모델은 describe() 문자열을 읽어 무엇을 전달해야 할지 알게 됩니다. 코드베이스를 본 적 없는 개발자를 위한 문서처럼 취급하세요:

{
date_range: z.string().describe(
"ISO 8601 date range in the format YYYY-MM-DD/YYYY-MM-DD. " +
"Example: 2025-01-01/2025-03-31"
),
}

모호한 설명은 잘못된 입력을 생성합니다. 정확한 설명은 올바른 입력을 생성합니다.

원시 JSON이 아닌 구조화된 텍스트 반환

모델은 도구 결과의 원시 JSON 블롭보다 텍스트를 더 안정적으로 파싱합니다. 출력을 포맷하세요:

return {
content: [
{
type: "text",
text: [
`Found ${rows.length} records:`,
...rows.map(r => `- ${r.id}: ${r.name} (${r.status})`),
].join("\n"),
},
],
};

호출자가 다운스트림 처리를 위해 구조화된 데이터가 진정으로 필요한 경우, JSON을 명확하게 레이블이 붙은 텍스트 블록 안에 포함시킵니다.

오류 처리

던져진 예외가 아닌 도구 결과로 오류를 반환합니다. MCP SDK는 던져진 예외를 프로토콜 수준 오류로 변환하며, 클라이언트는 이를 도구 수준 오류와 다르게 처리합니다:

async ({ table, where }) => {
try {
const rows = await db.query(table, where);
return { content: [{ type: "text", text: formatRows(rows) }] };
} catch (err) {
return {
content: [
{
type: "text",
text: `Query failed: ${err instanceof Error ? err.message : "unknown error"}. ` +
`Check that the table name is correct and the WHERE clause uses valid column names.`,
},
],
isError: true,
};
}
}

isError: true 플래그는 클라이언트에게 결과가 실패를 나타낸다는 신호를 보내며, 에이전트가 어떻게 복구할지 결정할 수 있게 합니다.

실제 예시: GitHub 도구 서버

에이전트가 저장소 내용을 읽을 수 있도록 GitHub API를 래핑하는 구체적인 도구입니다:

import { Octokit } from "@octokit/rest";
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
server.tool(
"get_file_contents",
"Read the contents of a file from a GitHub repository",
{
owner: z.string().describe("Repository owner (username or org)"),
repo: z.string().describe("Repository name"),
path: z.string().describe("File path within the repository"),
ref: z.string().optional().describe("Branch, tag, or commit SHA. Defaults to the default branch."),
},
async ({ owner, repo, path, ref }) => {
try {
const { data } = await octokit.rest.repos.getContent({
owner,
repo,
path,
ref,
});
if (Array.isArray(data)) {
// It's a directory — return listing
const listing = data.map(f => `${f.type === "dir" ? "📁" : "📄"} ${f.name}`).join("\n");
return {
content: [{ type: "text", text: `Directory listing for ${path}:\n${listing}` }],
};
}
if (data.type !== "file" || !data.content) {
return {
content: [{ type: "text", text: `${path} is not a readable file` }],
isError: true,
};
}
const content = Buffer.from(data.content, "base64").toString("utf-8");
return {
content: [
{
type: "text",
text: `Contents of ${owner}/${repo}/${path}:\n\`\`\`\n${content}\n\`\`\``,
},
],
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return {
content: [{ type: "text", text: `Failed to read ${path}: ${message}` }],
isError: true,
};
}
}
);

이것은 완전하고 프로덕션 준비가 된 도구입니다. 디렉토리, 오류, 인코딩을 처리하며 모델이 쉽게 읽고 인용할 수 있는 텍스트로 결과를 래핑합니다.

Claude Desktop에 연결하기

서버가 실행되면 macOS의 ~/Library/Application Support/Claude/claude_desktop_config.json 또는 Windows의 %APPDATA%\Claude\claude_desktop_config.json 설정을 편집하여 Claude Desktop에 연결합니다:

{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/absolute/path/to/dist/index.js"],
"env": {
"GITHUB_TOKEN": "your_token_here"
}
}
}
}

Claude Desktop을 재시작합니다. 도구가 도구 선택기에 자동으로 나타납니다.

개발을 위해서는 tsx를 직접 사용합니다:

{
"mcpServers": {
"my-server-dev": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/src/index.ts"]
}
}
}

Claude Code에 연결하기

MCP 서버는 CLI를 통해 Claude Code에 연결됩니다:

Terminal window
claude mcp add my-server node /absolute/path/to/dist/index.js

또는 환경 변수와 함께:

Terminal window
claude mcp add my-server --env GITHUB_TOKEN=your_token node /path/to/dist/index.js

Claude Code는 도구가 도움이 되는 작업에 취할 때 자동으로 도구를 사용합니다. 추가적인 프롬프팅이 필요하지 않습니다.

다음에 구축할 것

MCP 서버는 본질적으로 능력의 경계입니다. 에이전트가 무엇을 보고 무엇을 할 수 있는지? 설계 질문은 항상 어떤 능력을 노출하고 어떻게 안전하게 범위를 정할지입니다.

탐구할 가치가 있는 몇 가지 방향:

  • 데이터베이스 서버 — 에이전트가 쿼리하기 전에 구조를 발견할 수 있도록 스키마 내성 도구를 갖춘 Postgres, SQLite, 또는 DynamoDB에 대한 읽기 전용 쿼리 접근 노출
  • 코드 분석 서버 — tree-sitter 또는 언어 서버 프로토콜을 래핑하여 단순한 파일 읽기를 넘어선 코드베이스의 의미적 이해를 에이전트에게 제공
  • 모니터링 서버 — 메트릭 시스템(Prometheus, Datadog)을 브리지하여 에이전트가 실제 옵저버빌리티 데이터로 인시던트를 조사할 수 있게 함
  • 문서 서버 — 내부 문서나 런북을 인덱싱하고 제공하여 에이전트가 모든 것이 어디에 있는지 알 필요 없이 운영 질문에 답할 수 있게 함

MCP 생태계는 아직 초기 단계입니다. 대부분의 유용한 서버는 아직 구축되지 않았습니다. 패턴이 충분히 단순하여 현재 대시보드나 API를 통해 접근하는 것은 무엇이든 MCP 서버의 합리적인 후보입니다.

환경에서 실제 문제를 해결하는 것을 구축하세요. 프로토콜이 나머지를 처리할 것입니다.


관련 기사