소개
MCP(Model Context Protocol)는 AI 모델을 외부 도구와 데이터 소스에 연결하기 위해 Anthropic이 만든 표준 프로토콜입니다. GitHub Copilot, Claude, Cursor 등 주요 AI 도구가 지원합니다.
"Copilot에서 사내 API를 호출하고 싶다"거나 "AI가 프로젝트별 명령을 실행하게 하고 싶다"는 요구는 MCP 서버를 직접 만들어 해결할 수 있습니다.
이 글에서는 TypeScript로 MCP 서버를 구현하고 VS Code의 GitHub Copilot에 연결하는 과정을 살펴봅니다. 설정, 설계, 운영 시 유의할 점까지 함께 다룹니다.
MCP 기본 사항
flowchart TD
A[VS Code Copilot 에이전트] -->|MCP| B[MCP 서버]
B --> C[외부 도구]
B --> D[API]
B --> E[데이터베이스]MCP 서버는 크게 세 종류의 기능을 제공할 수 있습니다.
| 유형 | 설명 | 예 |
|---|---|---|
| 도구 | LLM이 호출할 수 있는 함수 | API 호출, 파일 작업 |
| 리소스 | 읽을 수 있는 데이터 | 문서, 구성 파일 |
| 프롬프트 | 재사용 가능한 프롬프트 템플릿 | 코드 검토, 사건 조사 |
통신에는 stdio 또는 Streamable HTTP를 통한 JSON-RPC를 사용합니다.
MCP 디자인 철학 및 아키텍처
MCP가 만들어진 이유
MCP가 등장하기 전에는 AI 클라이언트마다 외부 시스템 연동을 따로 구현해야 했습니다. 여러 AI 도구에서 같은 사내 API를 사용하려면 클라이언트별로 별도의 연동을 만들어야 했습니다.
[MCP 이전]
GitHub Copilot -> 독자적 연동 A -> 사내 API
Claude -> 독자적 연동 B -> 사내 API (중복)
Cursor -> 독자적 연동 C -> 사내 API (중복)
[MCP 도입 후]
GitHub Copilot -+
Claude +-> MCP 서버 -> 사내 API (한 번만 구현)
Cursor -+
MCP는 표준 프로토콜로 이러한 분산된 연동을 정리합니다. 서버 하나를 구현하면 클라이언트마다 같은 기능을 다시 만들 필요가 없습니다. 다만 실제로 쓸 수 있는 기능은 각 AI 클라이언트가 지원하는 MCP 버전, 전송 방식, 인증 방식에 따라 달라집니다.
핵심 원칙: LLM은 추론하고, 서버는 실행한다
MCP 설계의 핵심은 관심사 분리입니다.
- LLM(예: Copilot): 맥락을 이해하고 호출할 도구와 전달할 인수를 결정합니다.
- MCP 서버: 도구를 실제로 실행하고 결과를 반환합니다.
LLM이 외부 시스템을 직접 호출하지는 않습니다. 모든 요청은 명시적인 도구 호출 인터페이스를 거치므로 인증, 입력 검증, 로깅을 서버에서 한곳으로 관리할 수 있습니다.
sequenceDiagram
participant U as 사용자
participant L as LLM (Copilot)
participant S as MCP 서버
participant A as 외부 API
U->>L: "열린 Issue를 확인해 줘"
L->>S: tools/call search_github_issues
S->>A: GET /search/issues
A-->>S: 결과 JSON
S-->>L: 도구 실행 결과
L-->>U: 정리된 응답JSON-RPC 2.0과의 통신
통신에는 JSON-RPC 2.0을 사용합니다. 연결한 뒤 메시지는 다음 세 단계로 오갑니다.
| 단계 | 방법 | 설명 |
|---|---|---|
| 1 | initialize |
클라이언트와 서버가 버전 및 지원 기능을 교환 |
| 2 | tools/list |
클라이언트가 도구 목록(이름과 스키마)을 조회 |
| 3 | tools/call |
클라이언트가 도구 실행을 요청 |
다음은 search_github_issues의 요청과 응답 예시입니다.
// 요청
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search_github_issues",
"arguments": {
"query": "authentication",
"repo": "owner/my-repo",
"state": "open"
}
}
}
// 응답
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "[{\"number\": 42, \"title\": \"Fix OAuth token refresh\", ...}]"
}
]
}
}
LLM은 이 content를 받아 자연어 응답으로 바꿔 사용자에게 돌려줍니다.
전송 방식의 역할
MCP는 stdio와 Streamable HTTP라는 두 가지 표준 전송 방식을 정의합니다. 기존 HTTP+SSE 전송 방식은 하위 호환성을 위해 남아 있지만, 새로 구현할 때는 Streamable HTTP를 선택합니다.
| stdio | 스트리머블 HTTP | |
|---|---|---|
| 작동 방식 | 서버를 하위 프로세스로 시작하고 표준 입출력으로 통신 | HTTP POST/GET으로 JSON-RPC를 주고받고, 필요하면 SSE로 스트리밍 |
| 적합한 상황 | 로컬 개발, 개인 사용 | 팀 공유, 중앙 관리, 여러 클라이언트 |
| 시작 비용 | 낮음(호출할 때마다 시작할 수도 있음) | 상시 실행되는 서비스를 전제로 함 |
VS Code에서 로컬로 쓸 때는 stdio가 일반적인 선택입니다. 이 선택은 뒤의 "stdio와 Streamable HTTP 선택"에서 다시 살펴봅니다.
Skills 대신 MCP를 직접 만들어야 하는 경우
VS Code의 GitHub Copilot에는 Markdown 파일로 동작을 확장하는 두 가지 방법이 있습니다. 사용자 지정 지침 파일(.github/copilot-instructions.md, .instructions.md 등)은 AI에 전달할 지침을 정의하고, 에이전트 Skills(SKILL.md)는 절차, 스크립트, 보조 리소스를 포함하는 재사용 가능한 워크플로를 정의합니다. 이 글에서는 이들을 묶어 "지침 기반 사용자 지정"이라 부릅니다. MCP 서버를 만들기 전에 이 방법만으로 충분한지 먼저 판단해야 합니다.
명령어 기반 맞춤화로 해결된 사례
- 코딩 규칙이나 프로젝트 구조 같은 정적 정보를 AI에 제공하려는 경우
- 정해진 절차를 프롬프트 템플릿으로 재사용하려는 경우
- 외부 시스템 접근이 필요 없는 워크플로를 정의하려는 경우
지침 기반 사용자 지정에는 전용 서버가 필요 없습니다. Skills의 스크립트는 에이전트가 터미널 같은 기존 도구를 통해 실행할 수 있지만, 실행 가능 여부, 승인, 출력 형식은 호스트 환경의 기능에 좌우됩니다. 먼저 이 방법을 검토하는 편이 좋습니다.
Skills로 외부 API 호출 방법을 알려줄 수도 있다
요구 사항이 "외부 API 사용"이라면 Skills에 호출 방법을 적고 Copilot이 코드나 curl 명령을 생성하게 할 수도 있습니다.
<!-- SKILL.md 예시 -->
내부 상태 API의 엔드포인트는 https://api.internal/status 입니다.
인증 시 Authorization 헤더에 Bearer 토큰을 지정합니다.
curl 호출 예시: curl -H "Authorization: Bearer $TOKEN" https://api.internal/status
이 접근은 Copilot이 코드를 생성하는 것이 목적일 때 적합합니다. 에이전트가 기존 터미널이나 확장 기능으로 API를 호출할 수 있는 환경에서도, 실행 절차, 승인, 결과 처리는 여전히 호스트 환경에 따라 달라집니다.
MCP가 필요한 경계
다음 중 하나에 해당하면 Skills만으로는 충분하지 않습니다.
| 상황 | 명령어 기반 맞춤화 | MCP |
|---|---|---|
| Copilot에 API 호출 방법 알려 주기 | 가능 | — |
| 호스트가 제공하는 도구로 API를 실행하는 절차 정의 | 가능 | — |
| API를 타입이 정해진 전용 도구로 공개 | 불가 | 가능 |
| 서버에서 인증, 입력 검증, 결과 형식 통일 | 불가 | 가능 |
| 여러 MCP 클라이언트에 같은 기능 공개 | 불가 | 가능 |
Skills는 에이전트에 "무엇을 어떤 순서로 할지" 알려 주고, MCP는 클라이언트가 호출할 수 있는 "타입이 정해진 전용 도구 계약"을 제공합니다. MCP에서는 서버가 호출을 실행하고 정해진 형식의 결과를 Copilot의 컨텍스트로 돌려보냅니다. 이 통제력과 이식성이 판단 기준입니다.
MCP 서버가 필요한 경우
지침 기반 사용자 지정만으로 다음 요구를 관리하기 어렵다면 MCP 서버를 직접 만듭니다.
| 요구사항 | 이유 |
|---|---|
| Copilot의 추론에 API 결과 사용 | 실행 결과가 컨텍스트에 직접 들어가 후속 질문과 가공에 활용할 수 있음 |
| 인증이 필요한 리소스 접근 | 토큰과 자격 증명을 사용자에게 노출하지 않고 환경 변수에 안전하게 보관할 수 있음 |
| 쓰기 또는 업데이트 작업 수행 | Issue 생성, 라벨 업데이트, 레코드 변경처럼 부작용이 있는 작업 |
| 실시간 데이터 필요 | 정적 파일에 담을 수 없는 현재 CI 상태나 최신 DB 값을 조회 |
| 대용량 또는 동적 데이터 처리 | 프롬프트에 담기에는 너무 큰 문서나 자주 바뀌는 데이터 |
| 복잡한 처리 또는 변환 수행 | 파일 파싱, 집계, 형식 변환처럼 코드로 실행해야 하는 작업 |
| 프로젝트나 팀 사이에서 공유 | 여러 개발자와 리포지토리에서 호출할 도구를 중앙 관리 |
결정 흐름
flowchart TD
A[하고 싶은 일이 있다] --> B{외부 시스템에<br/>접근해야 하는가?}
B -->|아니요| C[지침 기반 사용자 지정으로 해결 가능]
B -->|예| D{Copilot이 결과를 받아<br/>추론해야 하는가?}
D -->|아니요 - 코드 생성만으로 충분| C
D -->|예| E{인증, 부작용,<br/>동적 데이터가 관련되는가?}
E -->|아니요| F[기존 MCP 서버로<br/>대체할 수 있는지 확인]
E -->|예| G[MCP 서버 구현]
F -->|없다면| G지침 기반 사용자 지정과 MCP를 가르는 핵심은 서버에서 전용 도구 계약, 인증, 입력 검증을 관리해야 하는지입니다.
설정하기
mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/node tsx
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true
},
"include": ["src/**/*"]
}
// package.json(일부)
{
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
기본 MCP 서버 구현하기
// src/index.ts
import { McpServer } from '@modelcontextprotocol/server'
import { serveStdio } from '@modelcontextprotocol/server/stdio'
import * as z from 'zod/v4'
import { fetchWeather } from './tools/weather.js'
interface GithubIssueSearchResponse {
items: Array<{
number: number
title: string
state: string
html_url: string
}>
}
function createServer(): McpServer {
const server = new McpServer({
name: 'my-custom-tools',
version: '1.0.0',
})
server.registerTool(
'get_weather',
{
description: '지정한 도시의 현재 날씨를 조회합니다',
inputSchema: z.object({
city: z.string().describe('날씨를 확인할 도시 이름(예: Tokyo, Osaka)'),
}),
},
async ({ city }) => {
const weather = await fetchWeather(city)
return {
content: [{
type: 'text',
text: `${city}의 현재 날씨: ${weather.description}, 기온: ${weather.temp}°C`,
}],
}
}
)
server.registerTool(
'search_github_issues',
{
description: 'GitHub 리포지토리에서 Issue를 검색합니다',
inputSchema: z.object({
query: z.string().describe('검색 쿼리'),
repo: z.string().regex(/^[^/]+\/[^/]+$/).describe('리포지토리 이름(owner/repo 형식)'),
state: z.enum(['open', 'closed', 'all']).default('open').describe('Issue 상태'),
}),
},
async ({ query, repo, state }) => {
const token = process.env.GITHUB_TOKEN
if (!token) {
return {
content: [{ type: 'text', text: 'GITHUB_TOKEN이 설정되어 있지 않습니다' }],
isError: true,
}
}
const stateQualifier = state === 'all' ? '' : ` state:${state}`
const url = new URL('https://api.github.com/search/issues')
url.searchParams.set(
'q',
`${query} is:issue repo:${repo}${stateQualifier}`
)
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
},
})
if (!response.ok) {
return {
content: [{ type: 'text', text: `GitHub API error: ${response.status}` }],
isError: true,
}
}
const data = await response.json() as GithubIssueSearchResponse
const issues = data.items.slice(0, 5).map((issue) => ({
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.html_url,
}))
return {
content: [{ type: 'text', text: JSON.stringify(issues, null, 2) }],
}
}
)
return server
}
void serveStdio(createServer)
console.error('MCP 서버를 시작했습니다')
도구 구현 예시
날씨 API(외부 API 호출)
// src/tools/weather.ts
interface WeatherData {
description: string
temp: number
humidity: number
}
export async function fetchWeather(city: string): Promise<WeatherData> {
const apiKey = process.env.OPENWEATHER_API_KEY
if (!apiKey) throw new Error('OPENWEATHER_API_KEY가 설정되어 있지 않습니다')
const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=metric&lang=ja`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`날씨 데이터를 가져오지 못했습니다: ${response.statusText}`)
}
const data = await response.json()
return {
description: data.weather[0].description,
temp: Math.round(data.main.temp),
humidity: data.main.humidity,
}
}
사내 문서 검색(로컬 파일)
// src/tools/docs-search.ts
import * as fs from 'fs/promises'
import * as path from 'path'
import { pathToFileURL } from 'url'
interface DocResult {
title: string
excerpt: string
url: string
}
export async function searchDocs(
query: string,
limit: number
): Promise<DocResult[]> {
const docsDir = process.env.DOCS_DIR ?? './docs'
const files = await fs.readdir(docsDir)
const results: DocResult[] = []
for (const file of files.filter((f) => f.endsWith('.md'))) {
const content = await fs.readFile(path.join(docsDir, file), 'utf-8')
if (content.toLowerCase().includes(query.toLowerCase())) {
const lines = content.split('\n')
// frontmatter(첫 번째 --- 블록)를 건너뛴 뒤 첫 번째 H1을 제목으로 사용
let bodyStart = 0
if (lines[0]?.trim() === '---') {
const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---')
bodyStart = end >= 0 ? end + 1 : 0
}
const h1 = lines.slice(bodyStart).find((l) => /^#\s/.test(l))
const title = h1 ? h1.replace(/^#\s*/, '') : path.basename(file, '.md')
const matchIndex = content.toLowerCase().indexOf(query.toLowerCase())
const excerpt = content.slice(
Math.max(0, matchIndex - 50),
matchIndex + 150
)
results.push({
title,
excerpt,
url: pathToFileURL(path.resolve(docsDir, file)).toString(),
})
if (results.length >= limit) break
}
}
return results
}
리소스 구현
리소스는 AI가 참조할 수 있는 읽기 전용 데이터를 제공합니다. 사내 규정, 운영 절차, API 문서처럼 모델이 안정적으로 참조해야 하는 정보에 적합합니다.
// src/index.ts 맨 앞에 추가
import { readFile } from 'node:fs/promises'
import path from 'node:path'
// createServer의 return server 앞에 추가
server.registerResource(
'project-guide',
'docs://project-guide',
{
title: '프로젝트 안내서',
description: '개발 중 참고할 프로젝트 안내서',
mimeType: 'text/markdown',
},
async (uri) => {
const docsDir = process.env.DOCS_DIR ?? path.join(process.cwd(), 'docs')
const content = await readFile(
path.join(docsDir, 'project-guide.md'),
'utf-8'
)
return {
contents: [
{
uri: uri.href,
mimeType: 'text/markdown',
text: content,
},
],
}
}
)
리소스로 제공하기 좋은 정보:
- 설계 가이드와 코딩 규칙
- 운영 런북
- API 사양 요약
- 내부 FAQ
이런 정보를 매 대화에 붙여넣지 않아도 참조할 수 있다는 점이 큰 장점입니다.
프롬프트 활용
MCP는 도구뿐 아니라 프롬프트도 제공할 수 있습니다. 같은 절차를 재사용하거나 같은 관점으로 반복 검토할 때 유용합니다.
- 보안 검토를 위한 프롬프트
- PR 설명 생성 프롬프트
- 장애 초기 조사를 위한 프롬프트
도구는 "실행 수단", 프롬프트는 "사고의 틀"로 구분해 보면 활용할 상황을 찾기 쉽습니다.
VS Code에 연결하기
// .vscode/mcp.json
{
"servers": {
"my-custom-tools": {
"type": "stdio",
"command": "node",
"args": ["${workspaceFolder}/dist/index.js"],
"env": {
"GITHUB_TOKEN": "${env:GITHUB_TOKEN}",
"OPENWEATHER_API_KEY": "${env:OPENWEATHER_API_KEY}"
}
}
}
}
개발 중에는 빌드하지 않고 tsx로 바로 실행할 수 있습니다.
{
"servers": {
"my-custom-tools-dev": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "${workspaceFolder}/src/index.ts"]
}
}
}
Copilot Chat에서 사용하기
VS Code를 다시 시작하면 Copilot Chat에서 MCP 도구를 사용할 수 있습니다.
"Tokyo의 날씨를 알려 줘"
-> Copilot이 get_weather 도구를 자동으로 호출
"\"memory leak\"과 관련된 열린 Issue를 찾아 줘"
-> Copilot이 search_github_issues 도구를 자동으로 호출
서버 이름으로 도구를 좁혀서 사용할 수도 있습니다.
#my-custom-tools owner/my-repo에서 "authentication"과 관련된 열린 Issue를 나열해 줘
-> 이 서버를 통해 search_github_issues 도구 호출
디버깅 팁
# MCP Inspector로 도구를 대화형으로 테스트한다
npx @modelcontextprotocol/inspector npx tsx src/index.ts
도구 호출과 반환 값을 시각적으로 확인하려면 Inspector가 터미널에 표시한 URL을 브라우저에서 엽니다.
도구 설계 모범 사례
무엇을 허용하지 않을지부터 정한다
MCP 도구는 유용하지만, 하나의 도구에 너무 많은 기능을 넣으면 위험해집니다.
| 나쁜 예 | 좋은 예 |
|---|---|
manage_project |
list_open_issues, update_issue_labels |
run_anything |
get_pipeline_status |
execute_sql |
search_customers |
이름만으로 부작용을 짐작할 수 있고, 입력 매개변수가 적으며, 실패 시 동작을 설명하기 쉬운 도구가 더 안정적입니다.
inputSchema를 신중하게 설계한다
도구 입력 스키마는 단순한 검증 수단이 아니라 LLM을 위한 인터페이스이기도 합니다. 설명이 모호하면 인수 추론의 정확도가 떨어집니다.
const updateIssueInput = {
type: 'object',
properties: {
repo: { type: 'string', pattern: '^[^/]+/[^/]+$' },
issueNumber: { type: 'integer', minimum: 1 },
labels: {
type: 'array',
items: { type: 'string' },
maxItems: 10,
},
},
required: ['repo', 'issueNumber', 'labels'],
additionalProperties: false,
}
핵심 사항:
description을 구체적으로 작성한다- 가능한 곳에는
enum을 사용한다 required를 명시한다- 의도하지 않은 매개변수는
additionalProperties: false로 막는다
Zod와 JSON Schema의 역할을 나눈다
inputSchema는 외부에 공개하는 계약이고, Zod는 실행 시 값을 다시 검증합니다. 특히 외부 API나 데이터베이스를 호출하는 도구라면 서버 측 재검증을 생략하면 안 됩니다.
case 'update_issue': {
// JSON Schema로 모델에 보여 줄 형식을 정의하고,
// Zod로 실제로 허용할 값을 검증한다
const input = z.object({
repo: z.string().regex(/^[^/]+\/[^/]+$/),
issueNumber: z.number().int().positive(),
labels: z.array(z.string()).max(10),
}).parse(args)
// ...
}
오류 처리 원칙
오류는 적어도 다음 세 범주로 나누어 처리하면 관리하기 쉽습니다.
- 입력 오류(검증 실패)
- 인증/권한 오류
- 외부 서비스 장애
function toToolErrorMessage(error: unknown): string {
if (error instanceof z.ZodError) {
return '입력값이 올바르지 않습니다. 필수 항목과 값의 형식을 확인하세요.'
}
if (error instanceof Error) {
return `도구 실행에 실패했습니다: ${error.message}`
}
return '도구 실행에 실패했습니다.'
}
반환하는 메시지에는 내부 세부 정보를 지나치게 노출하지 말고, 다음에 할 일을 분명히 알려야 합니다.
자격 증명 처리
비밀 정보는 프롬프트나 인수로 전달하지 말고, 환경 변수나 안전한 실행 환경에서 읽어야 합니다.
| 할 일 | 하지 않을 일 |
|---|---|
| env에서 API 토큰 읽기 | 도구 인수에 비밀 정보 포함 |
| 최소 권한 토큰 사용 | 로그에 토큰 기록 |
| 프로덕션과 개발 환경 분리 | 오류 메시지에 비밀 정보 포함 |
권한은 최소한으로 유지한다
- GitHub Issue만 검색한다면 읽기 전용 토큰을 사용한다
- 데이터베이스 읽기 도구에는 SELECT 전용 사용자를 쓴다
- 배포 확인 도구에는 워크플로 읽기 권한만 부여한다
팀에서 사용할 때는 읽기 전용 도구와 업데이트 도구를 분리하고, 프로덕션용 도구도 별도로 두는 편이 안전합니다.
운영 설계
시간 초과, 재시도 및 부분 실패
로컬 실험에서는 안정적으로 동작해도 프로덕션 환경에서는 불안정할 수 있습니다. 항상 성공한다고 가정하고 코드를 작성해서는 안 됩니다.
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
}),
])
}
최소한 포함할 요소:
- 시간 초과
- 재시도 횟수 제한
- 속도 제한
- 오류 메시지 정규화
관찰성(로깅 설계)
디버깅할 때는 무엇이 호출됐는지만으로 충분하지 않습니다. 다음 항목도 기록합니다.
- 도구 이름
- 호출 시간 및 실행 기간
- 성공 여부와 실패 원인 범주
하지만 과도하게 기록해서는 안 됩니다. 개인 정보, 액세스 토큰, 전체 SQL 문, 외부 API 응답 전체는 로그에서 제외합니다.
테스트 전략
도구 구현을 함수로 분리하면 MCP 프로토콜 밖에서도 해당 로직을 테스트할 수 있습니다.
// 비즈니스 로직을 분리해 테스트할 수 있게 한다
export async function searchGithubIssues(input: SearchIssuesInput) {
// 실제 처리
}
server.registerTool(
'search_github_issues',
{ inputSchema: SearchIssuesInputSchema },
async (input) => formatResult(await searchGithubIssues(input))
)
권장하는 테스트 계층:
- 입력 검증을 위한 단위 테스트
- 도구 핸들러에 대한 단위 테스트
- 전송 방식을 포함한 통합 테스트
- MCP Inspector를 통한 수동 확인
stdio와 Streamable HTTP 선택
| 관점 | stdio | Streamable HTTP |
|---|---|---|
| 로컬 설정 | 매우 간단 | 다소 복잡 |
| 중앙 감사 | 약함 | 강함 |
| 인증 관리 | 사용자별로 다름 | 관리하기 쉬움 |
| 디버깅 | 쉬움 | 더 많은 네트워크 요소 |
| 유통 | npm / 바이너리 | 서비스 URL |
먼저 stdio로 사용성을 확인해 봅니다. 팀 공유나 중앙 관리가 필요해지면 Streamable HTTP를 고려합니다. HTTP로 공개한다면 Origin 검증, localhost 바인딩, 인증을 구현해야 합니다.
MCP 서버 분할
모든 기능을 하나의 서버에 넣으면 책임 범위가 쉽게 커집니다. 다음과 같은 단위로 나누면 관리하기 쉽습니다.
- GitHub 관련 도구
- 내부 API 관련 도구
- 데이터베이스 읽기 도구
- 문서용 리소스 전용 서버
나누어 두면 권한과 환경 변수도 더 쉽게 구성할 수 있습니다.
일반적인 실패 패턴
1. 무엇이든 할 수 있는 범용 도구 구축
run_anything 또는 execute_sql(임의 SQL) 같은 설계는 안전성과 재현성을 모두 떨어뜨리기 쉽습니다.
2. inputSchema를 너무 느슨하게 만들기
모델이 인수를 안정적으로 구성하기 어려워지고, 호출 실패도 늘어납니다.
3. 비밀을 인수로 받기
로그나 대화 경로로 쉽게 유출될 수 있습니다.
4. 동일한 권한 하에서 읽기 및 업데이트 작업 혼합
운영 사고가 일어날 가능성이 커집니다.
5. 핸들러에서 직접 도구 구현 작성
테스트와 유지보수가 어려워집니다.
현실적인 도입 순서
처음 MCP 서버를 만든다면 다음 순서가 현실적입니다.
- stdio로 로컬 전용 서버를 만든다
- 읽기 전용 도구를 한두 개만 만든다
- 리소스와 프롬프트를 추가한다
- 로깅과 오류 처리를 개선한다
- 필요할 때만 업데이트 도구를 신중하게 추가한다
이 순서라면 안전성과 유용성의 균형을 맞추기 쉽습니다.
처음 만들기 좋은 후보:
- 프로젝트 구성 조회
- 내부 문서 검색
- CI 상태 확인
- PR/이슈 검색
사용자의 신뢰를 얻고 감사 체계를 갖춘 뒤에만 업데이트 도구를 추가합니다.
요약
- MCP 서버는 stdio 또는 Streamable HTTP를 통해 JSON-RPC로 통신하며,
@modelcontextprotocol/server를 사용하면 TypeScript로 구현하기 쉽습니다. - 도구(실행 수단), 리소스(참조 데이터), 프롬프트(사고의 틀)를 조합해 Copilot의 기능을 확장할 수 있습니다.
.vscode/mcp.json에 서버를 등록하면 VS Code의 GitHub Copilot에서 호출할 수 있습니다.- 도구 설계는 무엇을 허용하지 않을지부터 시작합니다. 신중하게 만든
inputSchema는 LLM의 인수 추론 정확도를 높입니다. - 비밀 정보는 환경 변수로 관리하고, 권한은 최소화합니다.
- stdio와 읽기 전용 도구 한두 개로 시작한 뒤 사용 경험을 쌓아 확장합니다.
