콘텐츠로 이동

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 를 어떻게 잡아채는가 — 를 다룹니다.