2장: FastMCP로 서버 뼈대 만들기¶
이번 장에서는 MCP 서버를 처음부터 띄우는 법과, 우리가 만들 도구 4개의 시그니처를 잡습니다. 실제로 이미지를 만드는 부분(Codex 호출)은 3장에서 다룹니다. 여기서는 "Claude가 이 함수를 호출할 수 있도록 노출하는 인터페이스"에 집중합니다.
2.1 FastMCP 한 장 요약¶
mcp 파이썬 패키지에는 FastMCP라는 헬퍼가 들어 있습니다. Flask·FastAPI를 한 번이라도 써 봤다면 거의 똑같이 느껴질 겁니다.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(name="my-server")
@mcp.tool()
async def hello(name: str) -> str:
"""간단한 인사 도구."""
return f"Hello, {name}!"
if __name__ == "__main__":
mcp.run(transport="stdio")
이 5줄짜리 스크립트만 있어도 이미 돌아가는 MCP 서버입니다. 핵심은 세 가지뿐입니다.
| 요소 | 역할 |
|---|---|
FastMCP(name=...) |
서버 인스턴스. Claude Code 안에서 보일 이름. |
@mcp.tool() |
데코레이터. 이 함수를 "Claude가 부를 수 있는 도구"로 등록. |
mcp.run(transport="stdio") |
stdin/stdout으로 JSON-RPC 메시지를 주고받게 시작. |
함수의 타입 힌트와 docstring은 그대로 Claude에게 전달되는 도구 설명서가 됩니다. 즉, 이름·인자·docstring을 잘 적어 두는 것 자체가 곧 "Claude를 위한 API 문서"를 쓰는 일입니다.
2.2 서버 헤더 — 임포트와 상수¶
server.py 윗부분은 다음과 같습니다.
#!/usr/bin/env python3
"""
Image Generator MCP Server (구독 전용 - Codex CLI 경유)
Claude → MCP → `codex exec` 서브프로세스 → gpt-image-2 (ChatGPT Plus 구독)
별도 API 키 없이 Codex가 자체 OAuth 토큰을 관리.
"""
import asyncio
import base64
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
OUTPUT_DIR = Path(__file__).parent / "output"
PRESETS_FILE = Path(__file__).parent / "presets.yaml"
CODEX_IMG_DIR = Path.home() / ".codex" / "generated_images"
세 개의 경로 상수가 핵심입니다.
| 상수 | 가리키는 곳 |
|---|---|
OUTPUT_DIR |
우리 프로젝트의 output/ — 사본 + 메타데이터 저장소 |
PRESETS_FILE |
같은 폴더 presets.yaml |
CODEX_IMG_DIR |
~/.codex/generated_images/ — Codex가 PNG를 떨어뜨리는 곳 |
CODEX_IMG_DIR 의 위치를 미리 알고 있어야 3장의 "스냅샷 diff" 트릭이 동작합니다.
2.3 변주 축(VARIATION_AXES) 정의¶
generate_variations 도구는 한 콘셉트로 여러 시안을 발산할 때 씁니다. 매번 무작위로 변주하면 "비슷한 게 또 나왔다" 같은 일이 생기므로, 우리는 결정론적으로 조합되는 변주 축을 미리 정의합니다.
VARIATION_AXES = {
"lighting": [
"soft golden hour lighting",
"dramatic studio lighting with rim light",
"neon glow ambient lighting",
...
],
"composition": [
"centered hero composition",
"rule of thirds asymmetric layout",
"top-down flat lay",
...
],
"mood": [
"minimal premium",
"maximalist vibrant",
"retro 80s nostalgic",
...
],
}
i번째 시안에는 lighting[i % 6], composition[i % 6], mood[i % 6] 을 조합해서 붙입니다. 6장을 뽑으면 자동으로 6가지 다른 조명·구도·무드가 한 번씩 나오게 됩니다. (구현은 4.x 절에서)
2.4 늦은 임포트 패턴 — _get_mcp()¶
def _get_mcp():
try:
from mcp.server.fastmcp import FastMCP
return FastMCP
except ImportError:
print("mcp 패키지가 없습니다. `pip3 install mcp` 실행 후 재시도하세요.",
file=sys.stderr)
sys.exit(1)
def _load_presets() -> dict:
if not PRESETS_FILE.exists():
return {}
try:
import yaml
except ImportError:
print("pyyaml 패키지가 없어 프리셋을 로드할 수 없습니다.", file=sys.stderr)
return {}
return yaml.safe_load(PRESETS_FILE.read_text()) or {}
PRESETS = _load_presets()
FastMCP = _get_mcp()
왜 굳이 함수로 감쌌을까요?
- 친절한 에러 메시지:
import가 그냥 실패하면 사용자에게ModuleNotFoundError: No module named 'mcp'같은 무뚝뚝한 traceback이 노출됩니다. 직접 처리하면 "어떤 명령으로 고치는지"까지 알려 줄 수 있습니다. - 선택적 의존성:
pyyaml이 없어도 서버는 시동됩니다 — 단지 프리셋만 비어 있을 뿐. 필수가 아닌 의존성은 임포트 실패가 곧 죽음이 되지 않게 다루는 게 좋은 습관입니다.
수업 포인트:
mcp처럼 없으면 아예 동작 자체가 불가능한 의존성은 즉시 종료,pyyaml처럼 부가 기능에만 필요한 의존성은 경고 후 계속 — 둘을 구분하세요.
2.5 서버 인스턴스 생성과 instructions¶
mcp = FastMCP(
name="image-generator",
instructions=(
"이미지 생성 요청이 들어오면 generate_image, edit_image, "
"generate_variations 중 적합한 도구를 사용하세요. "
"한국어 요청은 구체적이고 상세한 영문 프롬프트로 변환하여 호출하세요. "
"스타일·조명·분위기·색감을 포함할수록 좋습니다. "
"자주 쓰는 스타일은 preset 인자로 호출하세요 (list_presets 도구로 목록 확인)."
),
)
instructions 는 Claude가 이 서버를 어떻게 써야 하는지를 시스템 메시지처럼 받아서 읽는 부분입니다. 우리가 여기에 "한국어 요청을 영문 프롬프트로 풀어 써라"라고 명시해 둔 덕분에, 사용자가 "노을 진 카페 사진"이라고 적어도 Claude는 알아서 "sunset cafe photograph, warm golden hour lighting, ..." 같은 영문으로 풀어 호출합니다.
팁: instructions는 짧을수록 좋습니다. 모델은 모든 도구의 instructions를 한 번에 읽어야 하기 때문에, 길어지면 다른 서버의 instructions까지 흐려집니다. "어떤 도구가 있고 / 언제 쓰며 / 입력은 어떤 형태가 좋은지" 정도면 충분합니다.
2.6 도구 4개의 인터페이스¶
실제 구현(Codex 호출)은 다음 장에서 다루고, 여기서는 시그니처만 짚습니다.
2.6.1 generate_image¶
@mcp.tool()
async def generate_image(
prompt: str,
size: str = "1024x1024",
quality: str = "high",
n: int = 1,
preset: str = None,
open_viewer: bool = True,
) -> list:
"""ChatGPT Plus 구독 내 gpt-image-2로 이미지 생성 (Codex CLI 경유, 추가 과금 없음).
Args:
prompt: 영문 권장. 스타일·조명·분위기·색감 구체적으로.
size: "1024x1024" | "1536x1024" (가로) | "1024x1536" (세로) | "auto"
quality: "low" | "medium" | "high"
n: 1~4. 동일 프롬프트로 N장. 변주 시안은 generate_variations 사용 권장.
preset: presets.yaml의 키 (예: "luxury_dark", "clean_minimal")
open_viewer: True면 eog로 자동 열기.
"""
여기서 보아야 할 것은 인자 타입·기본값보다 docstring 입니다. Claude는 사용자의 요청을 분석할 때 docstring을 읽고 "이 도구는 영문 프롬프트가 권장되는구나, 사이즈는 이 4가지 중 하나구나" 를 학습합니다. 즉, docstring이 곧 LLM 친화적 API 명세입니다.
수업 포인트: 평소 코드 리뷰에서 "주석은 최소화"가 권장되는 것과 정반대입니다. MCP 도구 함수의 docstring은 사용자(=Claude)에게 보여 주는 UI라서 길고 친절할수록 좋습니다.
반환 타입 list 의 정체는 다음 셋이 섞인 리스트입니다.
from mcp.types import ImageContent, TextContent
return [
ImageContent(type="image", data=<base64 PNG>, mimeType="image/png"),
TextContent(type="text", text="1장 생성 완료. 저장 위치: ..."),
]
ImageContent 를 같이 반환하면 Claude Code가 그 이미지를 채팅창에 바로 인라인으로 띄워 줍니다. (1장 마지막에서 본 그 빨간 사과 미리보기가 이렇게 동작한 결과)
2.6.2 edit_image¶
@mcp.tool()
async def edit_image(
prompt: str,
image_paths: list,
size: str = "1024x1024",
quality: str = "high",
preset: str = None,
open_viewer: bool = True,
) -> list:
"""기존 제품 사진을 입력받아 합성·편집 (gpt-image-2 image-to-image).
Args:
prompt: 변경 지시. "Replace background with luxury dark studio".
image_paths: 입력 이미지 절대 경로 리스트 (1~3장 권장).
...
"""
generate_image 와 비슷하지만 입력 이미지 파일 경로 리스트가 추가됩니다. 사용 예: "이 제품 사진의 배경을 럭셔리 다크 톤으로 바꿔 줘."
2.6.3 generate_variations¶
@mcp.tool()
async def generate_variations(
concept: str,
n_variations: int = 5,
size: str = "1024x1024",
quality: str = "high",
preset: str = None,
open_viewer: bool = True,
) -> list:
"""한 콘셉트로 톤·구도·분위기 다양화한 N장 시안 일괄 생성."""
generate_image(n=5) 와 다른 점은 결정론적 변주입니다. 같은 콘셉트로 5장을 뽑되 조명·구도·무드가 1장씩 다 다릅니다 — 광고 시안 발산처럼 "5장을 한 화면에 깔고 비교"하기에 좋은 형태입니다.
2.6.4 list_presets¶
@mcp.tool()
async def list_presets() -> list:
"""사용 가능한 브랜드/스타일 프리셋 목록 반환."""
가장 단순한 도구. presets.yaml 의 키들을 그대로 텍스트로 돌려줍니다. 사용자가 "어떤 프리셋 있어?" 라고 묻기만 해도 Claude가 이 도구를 호출하도록 만드는 게 목표입니다.
2.7 마지막 한 줄 — 서버 시동¶
if __name__ == "__main__":
mcp.run(transport="stdio")
stdio 는 표준 입출력입니다. Claude Code가 이 파일을 자식 프로세스로 띄우고, 그 프로세스의 stdin/stdout 으로 JSON 메시지를 주고받습니다. 그래서 우리 서버 안에서는 절대 print 를 stdout에 쓰면 안 됩니다. 디버깅 출력은 모두 print(..., file=sys.stderr) 로 보내야 합니다 — _get_mcp() 에서 이미 그렇게 해 두었습니다.
print("디버깅 메시지", file=sys.stderr) # OK
print("디버깅 메시지") # 절대 금지 — JSON 프로토콜이 깨진다
2.8 정리¶
이번 장에서 한 일:
- FastMCP 한 인스턴스 생성,
instructions에 사용 정책을 한국어로 적었음 - 의존성 임포트 실패를 친절하게 처리
- 도구 4개의 시그니처를 docstring 위주로 설계
- 결정론적 변주를 위한
VARIATION_AXES사전 정의 - stdio 전송 모드와 stdout 오염 금지 규칙 확인
여기까지로 "Claude가 인지할 수 있는 도구 4개" 의 외형은 완성되었습니다. 다음 장에서는 그 안쪽 — codex exec 를 어떻게 호출하고 결과 PNG 를 어떻게 잡아채는가 — 를 다룹니다.