MCP Servers & Tool Design (ACI)
MCP 서버 & 도구 설계 — 에이전트 인터페이스 공학
MCP는 에이전트의 USB-C 포트다 — 도구를 추가하는 것보다 도구를 잘 설계하는 것이 훨씬 중요하고, Anthropic의 SWE-bench 팀은 프롬프트보다 도구 설계에 더 많은 시간을 썼다.
Overview
MCP(Model Context Protocol)는 Anthropic이 만든 개방형 표준으로, LLM이 외부 데이터 소스, API, 도구와 통신하는 방법을 표준화한다. 'AI의 USB-C 포트' — 어떤 MCP 호환 클라이언트도 어떤 MCP 호환 서버와 연결될 수 있다.
Anthropicr SWE-bench 팀의 중요한 발견: "우리는 전체 프롬프트 최적화보다 도구 설계에 더 많은 시간을 썼다." 절대 경로 vs. 상대 경로 하나만 바꿔도 오류의 전체 클래스가 사라졌다.
97%의 MCP 도구에 품질 문제가 있다는 연구 결과가 있다. 가장 흔한 실수는 REST API를 MCP로 래핑하는 것 — 이것은 API 설계가 아니라 에이전트 인터페이스 설계(ACI)가 필요하다. 완전히 다른 원칙이 적용된다.
- MCP의 3가지 핵심 프리미티브(Tools, Resources, Prompts)를 설명할 수 있다
- 도구 설계 6원칙을 적용해서 에이전트 친화적 도구를 작성할 수 있다
- 에러 메시지를 3-part 공식으로 자기수정 유도에 최적화할 수 있다
- FastMCP로 커스텀 MCP 서버를 구현하고 Claude Code에 등록할 수 있다
Sections
MCP 아키텍처 — 3가지 핵심 프리미티브
MCP는 JSON-RPC 2.0 기반 프로토콜로, 3가지 핵심 프리미티브를 제공한다:
| 프리미티브 | 역할 | 컨트롤 | 예시 |
|---|---|---|---|
| Tools | 에이전트가 호출하는 함수 | Model-controlled (사용자 승인 필요) | search_customer(email) |
| Resources | 에이전트가 읽는 데이터 | Application-controlled | 파일 내용, API 응답 |
| Prompts | 재사용 가능한 프롬프트 템플릿 | User-controlled | 코드 리뷰 템플릿 |
Transport 방식:
- stdio: 서버가 서브프로세스로 실행, stdin/stdout으로 통신. Claude Code와 Claude Desktop이 로컬 서버에 사용.
- Streamable HTTP (2025년 11월 신규): 단일 HTTP 엔드포인트, POST로 JSON-RPC 전송, SSE로 스트리밍.
Tool Annotation Hints (4가지 핵심):
readOnlyHint: 승인 없이 호출 가능destructiveHint: 사람 체크포인트 필요idempotentHint: 재시도 안전openWorldHint: 외부 시스템과 상호작용 (최고 위험)
도구 설계 6원칙 — 97%가 위반하는 것들
원칙 1: Operation이 아닌 Outcome으로 설계
- 나쁜:
get_customer_by_email()+list_orders()+get_status()= 3번 왕복 - 좋은:
track_latest_order(email)= 1번 왕복, 완전한 결과
원칙 2: 인수를 기본 타입으로 평탄화
- 나쁜:
filters: {status: 'pending', date_range: {...}} - 좋은:
status: Literal['pending','shipped'],start_date: str
원칙 3: 풍부한 설명 (3-part 공식)
[무엇을 하는가] [무엇을 반환하는가] [언제 사용하는가]
예: "고객의 최신 주문 상태를 확인한다. 운송사, ETA, 추적 URL을 반환한다.
배송 날짜 견적 전에 사용할 것."
원칙 4: 서버당 5-15개로 도구 수 제한
- 소형 모델(8B): ~19개에서 최고 성능, 46개에서 실패
- GitHub Copilot: 40개 → 13개로 줄인 후 벤치마크 향상
- Block의 Linear MCP: 30개 → 2개
원칙 5: 접두사로 이름 짓기
- 나쁜:
get_data,send - 좋은:
slack_send_message,linear_list_issues
원칙 6: 페이지네이션 기본 제공
limit=20기본값,has_more,next_offset,total_count반환
에러 메시지 설계 — 자기수정 프롬프트
에러 메시지는 Claude가 다음에 올바른 호출을 하도록 유도하는 프롬프트다. 3-part 공식:
[무엇이 잘못됐는가] + [기대값은 무엇인가] + [올바른 예시]
나쁜 예시:
Error: Invalid parameter
좋은 예시:
Error: 'departure_date' 파라미터 형식 오류: '15/04/2026'.
ISO 8601 형식 (YYYY-MM-DD)을 사용하세요.
예시: search_flights(departure_date='2026-04-15', ...)
커스텀 린터 에러 메시지 패턴 (Claude Code에서 자기수정 극대화):
ERROR: [무엇이 잘못됐는가]
파일: [경로:줄번호]
WHY: [왜 이 규칙이 있는가] → docs/adr/007 참조
FIX: [구체적인 수정 방법]
EXAMPLE:
// 잘못됨:
const result = await db.query('SELECT...')
// 올바름:
const result = await db.user.findMany({ where: { id } })
이런 에러 메시지는 Claude가 에러를 보자마자 정확한 수정을 한다. '더 많은 지시'가 아니라 '더 나은 에러'가 답이다.
커스텀 MCP 서버 구현
FastMCP를 사용한 Python MCP 서버 최소 구현:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP('my-project-tools')
@mcp.tool()
async def search_logs(
query: str,
severity: str = 'all', # 'error' | 'warn' | 'info' | 'all'
limit: int = 20
) -> dict:
"""애플리케이션 로그를 검색한다.
query: 검색할 키워드 또는 패턴
severity: 'error' | 'warn' | 'info' | 'all'
오류 디버깅이나 특정 이벤트 추적 시 사용.
"""
# ... 구현
return {"logs": [], "total": 0, "has_more": False}
if __name__ == '__main__':
mcp.run(transport='stdio')
settings.json에 등록:
{
"mcpServers": {
"my-project-tools": {
"command": "python",
"args": ["/path/to/server.py"]
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/username"]
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"}
}
}
}
MCP 도구 설계를 레스토랑 메뉴에 비유해보자.
나쁜 메뉴(API 래핑 방식): 재료 목록 + 조리 방법 + 플레이팅 지시서를 고객에게 준다. 고객은 파스타를 먹고 싶을 뿐인데 3번의 선택과 조합이 필요하다.
좋은 메뉴(Outcome 방식): '해산물 파스타 (30분, 어육 알레르기 표시)'. 고객이 원하는 결과를 한 번에 주문할 수 있다.
메뉴 수 제한(5-15개 원칙): 메뉴가 100개인 레스토랑에서 고객은 결정 장애에 빠진다. 가장 좋은 메뉴 15개만 남기면 주문이 빨라지고 실수가 줄어든다.
에러 메시지(자기수정): '주문 오류'보다 '해산물 파스타는 재료 소진되었습니다. 크림 파스타나 명란 파스타를 추천드립니다'가 다음 올바른 주문으로 이어진다.
프로젝트 특화 MCP 서버 템플릿. 데이터베이스 검색, 로그 조회, 환경 상태 확인 등 반복적인 개발 태스크를 도구화한다.
#!/usr/bin/env python3
"""project-mcp-server.py — 프로젝트 특화 MCP 서버 템플릿"""
from mcp.server.fastmcp import FastMCP
from typing import Literal
import subprocess
import json
import os
mcp = FastMCP('project-tools')
@mcp.tool()
async def run_tests(
pattern: str = '',
verbose: bool = False
) -> dict:
"""프로젝트 테스트를 실행한다.
pattern: 실행할 테스트 파일/이름 패턴 (빈 문자열 = 전체)
verbose: True면 전체 출력, False면 요약만
코드 변경 후 검증 시 사용. PR 전 필수.
결과: passed, failed, duration, output 반환
"""
cmd = ['npm', 'test']
if pattern:
cmd.extend(['--', '--testPathPattern', pattern])
if verbose:
cmd.append('--verbose')
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
lines = result.stdout.split('\n')
summary_lines = [l for l in lines if 'Tests:' in l or 'PASS' in l or 'FAIL' in l]
return {
'exit_code': result.returncode,
'passed': result.returncode == 0,
'summary': '\n'.join(summary_lines[-10:]),
'output': result.stdout if verbose else result.stdout[-2000:],
'error': result.stderr[-1000:] if result.returncode != 0 else None
}
@mcp.tool()
async def search_codebase(
query: str,
file_pattern: str = '**/*.ts',
context_lines: int = 3
) -> dict:
"""코드베이스에서 패턴을 검색한다.
query: grep 패턴
file_pattern: 검색할 파일 패턴 (기본: TypeScript 파일)
context_lines: 매칭 전후 표시할 줄 수
함수, 변수, import 위치 찾을 때 사용. Read 전에 먼저 이것을 써서 파일을 특정하라.
결과: matches (파일경로, 줄번호, 내용), total_count 반환
"""
try:
result = subprocess.run(
['grep', '-rn', f'--include={file_pattern.replace("**","*")}',
f'-A{context_lines}', f'-B{context_lines}', query, '.'],
capture_output=True, text=True, timeout=30
)
matches = []
for line in result.stdout.split('\n')[:100]: # 최대 100줄
if ':' in line and not line.startswith('--'):
parts = line.split(':', 2)
if len(parts) >= 3:
matches.append({
'file': parts[0],
'line': parts[1],
'content': parts[2].strip()
})
return {'matches': matches, 'total_count': len(matches), 'truncated': len(matches) >= 100}
except subprocess.TimeoutExpired:
return {'error': '검색 시간 초과 — 더 구체적인 패턴을 사용하세요', 'matches': [], 'total_count': 0}
@mcp.tool()
async def get_env_status() -> dict:
"""현재 개발 환경 상태를 반환한다.
Node.js 버전, 환경변수 설정 여부, 포트 사용 현황 확인.
세션 시작 시 또는 '왜 안되지?' 디버깅 시 먼저 실행하라.
"""
info = {}
for cmd, key in [(['node', '--version'], 'node'), (['npm', '--version'], 'npm'), (['git', 'branch', '--show-current'], 'branch')]:
try:
r = subprocess.run(cmd, capture_output=True, text=True)
info[key] = r.stdout.strip()
except:
info[key] = 'not found'
required_env = ['DATABASE_URL', 'NEXTAUTH_SECRET', 'NEXTAUTH_URL']
info['env_vars'] = {
var: '✅ 설정됨' if os.environ.get(var) else '❌ 미설정'
for var in required_env
}
return info
if __name__ == '__main__':
mcp.run(transport='stdio') 3가지 도구를 구현했다: run_tests(테스트 실행), search_codebase(코드 검색), get_env_status(환경 상태). 각각 Outcome 중심으로 설계되어 한 번 호출로 원하는 결과를 얻을 수 있다. 설명에는 '무엇을', '무엇을 반환', '언제 사용' 3-part가 포함되어 있다.
✅ 시니어가 보는 것
- 도구 설명에 무엇/반환/언제 3-part가 있는가
- 도구 수가 15개 이하인가
- 에러 메시지가 자기수정 프롬프트로 설계되어 있는가
- readOnlyHint/destructiveHint annotation이 있는가
⚠️ 레드 플래그
- REST API를 그대로 MCP로 래핑 (3번 왕복이 필요한 도구들)
- 30개 이상의 도구 (모델이 선택 불가)
- Generic 에러 메시지 ('Invalid parameter')
- Hint annotation 없음 (승인 여부 결정 불가)
🎤 예상 인터뷰 질문
- MCP Tools, Resources, Prompts의 차이와 각각의 use case는?
- Anthropic SWE-bench 팀이 절대 경로 요구로 오류의 전체 클래스를 없앤 원리는?
- Block의 Linear MCP가 30개에서 2개로 줄인 후 성능이 향상된 이유는?
Key Takeaways
MCP = AI의 USB-C
어떤 MCP 클라이언트도 어떤 MCP 서버와 연결 가능. 표준화된 도구 확장 인터페이스.
ACI ≠ API
에이전트 인터페이스(ACI)는 개발자 인터페이스(API)와 다른 설계 원칙이 필요하다.
Outcome > Operation
3번 왕복이 필요한 3개 도구보다 1번 호출로 완전한 결과를 주는 1개 도구가 낫다.
5-15개 원칙
소형 모델은 19개, 대형 모델도 100개 이상에서 급격히 실패한다. 15개 이하 유지.
3-part 설명
무엇을 하는가 + 무엇을 반환하는가 + 언제 사용하는가 — 이 공식이 올바른 도구 선택을 이끈다.
에러 = 자기수정 프롬프트
에러 메시지에 문제 + 기대값 + 올바른 예시를 넣으면 Claude가 즉시 수정한다.
readOnlyHint 필수
읽기 전용 도구에 annotation을 달면 불필요한 승인 요청이 사라진다.
프롬프트보다 도구
Anthropic SWE-bench 팀: 도구 설계에 프롬프트 최적화보다 더 많은 시간을 썼다.