AI 에이전트 개발 실전 #6 MCP 서버 직접 만들기

4 분 소요

LLM 앱 개발 실전 11편에서 MCP를 처음 만났습니다. 그때는 이미 만들어져 있는 MCP 서버에 Claude를 연결하는 쪽이었습니다. 이번에는 반대편에 서 봅니다. 우리 도구를 MCP 서버로 직접 만들어서, 우리 에이전트만이 아니라 Claude Desktop이나 Claude Code 같은 다른 클라이언트에서도 같은 도구를 쓰게 하는 것입니다.

언제 서버로 분리하는가 #

지금까지 도구는 에이전트 코드 안의 파이썬 함수였습니다. 이 방식은 단순하고 빠르지만, 도구가 그 에이전트에 갇힙니다. 같은 도구를 다른 에이전트나 다른 도구에서 쓰려면 코드를 복사해야 합니다.

MCP 서버로 분리하면 도구가 독립된 프로세스가 되고, MCP를 지원하는 어떤 클라이언트든 표준 방식으로 연결합니다. 사내 주문 시스템 조회 도구를 한 번 만들어서, 우리 에이전트와 팀원들의 Claude Desktop과 Claude Code가 모두 같은 서버를 쓰는 그림이 가능해집니다. 반대로 한 에이전트만 쓰는 도구라면 분리할 이유가 없습니다. 함수로 충분합니다.

FastMCP로 서버 만들기 #

파이썬 MCP SDK에 포함된 FastMCP를 쓰면 서버가 함수 몇 개 수준으로 줄어듭니다. 먼저 설치합니다.

terminal
pip install "mcp[cli]"

11편까지 쓰던 주문 도구 두 개를 서버로 옮겨 보겠습니다.

order_server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("order-tools")

@mcp.tool()
def search_orders(query: str) -> str:
    """주문 번호, 고객 이름, 날짜로 주문을 검색한다.
    사용자가 특정 주문의 상태나 내역을 물으면 먼저 이 도구로 주문을 찾는다."""
    results = do_search(query)          # 실제 구현
    return format_results(results)

@mcp.tool()
def get_order_detail(order_id: str) -> str:
    """주문 번호로 주문 상세를 조회한다. 예: ORD-2026-0001"""
    return fetch_detail(order_id)

if __name__ == "__main__":
    mcp.run()   # 기본은 stdio 방식으로 실행된다

@mcp.tool() 데코레이터가 함수 시그니처와 docstring에서 도구 이름, 설명, 입력 스키마를 만들어 줍니다. 2편의 도구 설계 원칙이 여기서도 그대로 적용됩니다. docstring이 곧 description이므로, 언제 쓰는지까지 적습니다.

mcp.run()의 기본 동작은 stdio입니다. 클라이언트가 이 스크립트를 자식 프로세스로 띄우고 표준 입출력으로 대화하는 방식이라, 로컬 도구 서버에 알맞습니다.

우리 에이전트 루프에 연결하기 #

이제 에이전트 쪽에서 이 서버에 연결합니다. MCP 클라이언트로 서버를 띄우고, 서버가 알려 주는 도구 목록을 Claude의 tools 형식으로 변환하고, Claude가 도구를 부르면 서버에 전달합니다. MCP 클라이언트는 async 기반이라 루프도 async로 바뀝니다.

agent_with_mcp.py
import asyncio
import anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

client = anthropic.AsyncAnthropic()

async def run_agent(goal: str, max_steps: int = 20) -> str:
    server = StdioServerParameters(command="python", args=["order_server.py"])
    async with stdio_client(server) as (read, write):
        async with ClientSession(read, write) as mcp_session:
            await mcp_session.initialize()

            # 1. 서버의 도구 목록을 Claude tools 형식으로 변환
            listed = await mcp_session.list_tools()
            tools = [
                {
                    "name": t.name,
                    "description": t.description,
                    "input_schema": t.inputSchema,
                }
                for t in listed.tools
            ]

            # 2. 루프는 1편과 같다. 도구 실행만 MCP 호출로 바뀐다.
            messages = [{"role": "user", "content": goal}]
            for _ in range(max_steps):
                response = await client.messages.create(
                    model="claude-opus-4-8",
                    max_tokens=16000,
                    tools=tools,
                    messages=messages,
                )
                if response.stop_reason == "end_turn":
                    return next(b.text for b in response.content if b.type == "text")

                messages.append({"role": "assistant", "content": response.content})
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        result = await mcp_session.call_tool(block.name, block.input)
                        text = "\n".join(c.text for c in result.content if c.type == "text")
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": text,
                            "is_error": bool(result.isError),
                        })
                messages.append({"role": "user", "content": tool_results})

    raise RuntimeError("단계 한도 안에 작업을 끝내지 못했습니다.")

print(asyncio.run(run_agent("ORD-2026-0001 주문 상태를 알려줘.")))

루프의 뼈대는 1편과 동일합니다. 달라진 것은 두 곳뿐입니다. 도구 목록을 코드에 하드코딩하는 대신 서버에서 받아 오고, 도구 실행이 파이썬 함수 호출 대신 call_tool이 됐습니다. 서버 쪽 도구를 추가하거나 고쳐도 에이전트 코드는 그대로라는 것이 이 구조의 힘입니다.

다른 클라이언트에서도 같은 서버를 #

같은 서버를 Claude Desktop이나 Claude Code에 등록하면, 코드를 한 줄도 더 쓰지 않고 그쪽에서도 주문 도구를 쓸 수 있습니다. 등록 방법은 클라이언트마다 설정 파일에 서버 실행 명령을 적는 식이고, 11편에서 본 연결 흐름 그대로입니다. 도구를 한 번 만들면 모든 클라이언트가 공유한다는 MCP의 약속이 이 지점에서 실현됩니다.

흔히 걸려 넘어지는 곳 #

  • docstring을 대충 쓴다 — FastMCP에서는 docstring이 그대로 도구 description이 됩니다. 비어 있거나 한 단어면 Claude가 도구를 언제 쓸지 판단할 근거가 없습니다.
  • isError를 확인하지 않는다call_tool의 결과에는 에러 여부가 들어 있습니다. 이걸 무시하고 본문만 넘기면, 1편에서 만든 에러 복구 회로가 MCP 도구에서는 동작하지 않게 됩니다.
  • 모든 도구를 서버로 만든다 — 분리에는 프로세스 관리라는 비용이 따릅니다. 여러 클라이언트가 공유할 도구만 서버로 만들고, 한 에이전트 전용 도구는 함수로 둡니다.

마무리 #

이번 글에서는 도구를 MCP 서버로 분리했습니다.

  • FastMCP를 쓰면 함수에 데코레이터를 붙이는 것만으로 도구 서버가 됩니다. docstring이 곧 도구 설명입니다.
  • 에이전트 루프는 그대로 두고, 도구 목록 수신과 도구 실행만 MCP 호출로 바꾸면 연결이 끝납니다.
  • 분리의 가치는 재사용입니다. 여러 클라이언트가 쓸 도구만 서버로 만듭니다.

이제 시리즈에서 다룬 부품이 모두 모였습니다. 다음 글인 “AI 에이전트 개발 실전 #7 실전 프로젝트: 이슈 트리아지 에이전트"에서는 견고한 루프, 도구 설계, 휴먼 승인, 평가까지 묶어서 동작하는 에이전트 하나를 완성하고 시리즈를 마칩니다.

X