포스트

LLM 에이전트 기초 (7) - MCP: 도구를 표준 규격으로 분리하기

에이전트 코드에 박혀 있던 도구를 MCP 서버로 떼어내고, 클라이언트로 붙여 다시 같은 에이전트를 돌립니다.

LLM 에이전트 기초 (7) - MCP: 도구를 표준 규격으로 분리하기

LLM 에이전트 기초 시리즈의 7편입니다. 2편부터 써 온 도구를 에이전트 코드 밖으로 꺼냅니다.

도구가 에이전트 안에 있으면 생기는 일

지금까지의 구조는 단순합니다. 도구 세 개는 tools.py에 있고, agent.py가 그것을 import 해 TOOL_FUNCS에 담고, run_agent가 그 딕셔너리를 통해 함수를 호출합니다. 한 프로세스 안에서 전부 끝납니다.

1
from tools import TOOL_SPECS, search_orders, get_order, request_refund

이 한 줄이 만드는 제약이 세 가지 있습니다.

첫째, 도구를 고치면 에이전트를 배포해야 합니다. request_refund가 사유 코드를 하나 더 받도록 바뀌면, 도구 쪽 로직만 바뀌었는데 배포 단위는 에이전트 전체입니다.

둘째, 같은 도구를 다른 에이전트가 쓰려면 코드를 복사해야 합니다. 사내 CS 관리자용 에이전트를 따로 만든다면 tools.py를 복사하거나 공용 패키지로 빼고 버전을 맞춰야 합니다.

셋째, 도구를 만든 팀과 에이전트를 만든 팀이 다르면 이것이 조직 문제가 됩니다. 주문 도메인 팀이 자기 일정으로 배포하고 싶어도 에이전트 팀의 릴리스 창을 기다려야 합니다.

새로운 문제가 아닙니다. 한 프로세스에 있던 코드를 여러 팀이 나눠 갖게 될 때 늘 나오는 문제이고, 답도 늘 같았습니다. 경계를 긋고 그 경계를 통과하는 규격을 정하는 것입니다. 웹에서는 HTTP와 JSON이 그 규격이었고 사내 서비스 사이에서는 gRPC나 REST였습니다. 도구와 에이전트 사이에서 그 자리를 채우는 것이 MCP(Model Context Protocol)입니다.

MCP가 정하는 것

MCP는 도구를 어떻게 노출하고 어떻게 호출할지에 대한 규격입니다. 모델이 도구를 잘 고르게 하는 방법이나 루프를 어떻게 돌릴지는 MCP의 관심사가 아닙니다. 그 부분은 1편부터 6편까지 만든 그대로 남습니다.

규격이 정하는 것은 세 가지입니다.

  • 서버: 도구를 가진 쪽입니다. “내가 가진 도구 목록은 이렇다”(list_tools)와 “이 도구를 이 인자로 실행해 달라”(call_tool)에 답합니다.
  • 클라이언트: 도구를 쓰는 쪽입니다. 서버에 붙어 목록을 받아오고 호출을 보냅니다. 우리 에이전트가 여기에 해당합니다.
  • 전송(transport): 둘 사이를 무엇으로 잇는지입니다. stdio는 클라이언트가 서버를 자식 프로세스로 띄우고 표준 입출력으로 주고받는 방식이고, 원격 서버에는 HTTP 기반 전송을 씁니다.

이 규격이 있으면 두 방향의 무지가 성립합니다. 도구 쪽은 누가 자기를 부르는지 몰라도 되고, 에이전트 쪽은 도구가 어느 언어로 짜였고 어디서 도는지 몰라도 됩니다. 실습에서는 인증도 네트워크도 없이 규격만 확인할 수 있는 stdio를 씁니다.

파이썬 SDK를 설치합니다. 뒤에서 쓸 anthropic 쪽 확장도 함께 넣습니다.

1
pip install -U "mcp" "anthropic[mcp]"

도구를 MCP 서버로 옮깁니다

0편에서 만든 도구 세 개를 MCP 서버로 노출합니다. tools.py의 함수는 건드리지 않고 얇게 감싸기만 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# server.py
from mcp.server import MCPServer

import tools

mcp = MCPServer("orders")


@mcp.tool()
def search_orders(customer: str) -> list[dict]:
    """고객 이름으로 그 사람의 주문 목록을 가져옵니다.

    주문번호를 모르는 상태에서 주문을 찾아야 할 때 먼저 호출합니다.

    Args:
        customer: 고객 이름
    """
    return tools.search_orders(customer)


@mcp.tool()
def get_order(order_id: str) -> dict:
    """주문번호로 주문 한 건의 상세 정보를 조회합니다.

    배송 상태나 결제 금액을 확인해야 할 때 호출합니다.

    Args:
        order_id: 주문번호. 예: 20260801-0012
    """
    return tools.get_order(order_id)


@mcp.tool()
def request_refund(order_id: str, reason: str) -> dict:
    """환불을 접수합니다.

    되돌릴 수 없으므로 주문 상태를 먼저 확인하고, 고객이 환불을
    명시적으로 요청했을 때만 호출합니다.

    Args:
        order_id: 환불할 주문번호
        reason: 고객이 밝힌 환불 사유
    """
    return tools.request_refund(order_id, reason)


if __name__ == "__main__":
    mcp.run(transport="stdio")

여기서 짚을 것은 @mcp.tool() 데코레이터가 함수 시그니처와 docstring을 그대로 도구 스키마로 만든다는 점입니다. customer: str이라는 타입 힌트가 input_schema{"type": "string"}이 되고, docstring 첫 문단이 도구 설명이 되며, Args: 항목이 각 파라미터 설명이 됩니다. 0편에서 손으로 적었던 TOOL_SPECS 딕셔너리를 SDK가 대신 만들어 줍니다.

편해진 만큼 주의할 것도 생깁니다. 0편에서 “설명이 곧 성능”이라고 했던 그 설명이 이제 docstring입니다. 사람용 주석을 대충 적어 두면 그대로 모델 성능이 됩니다. 위 코드의 docstring이 0편 TOOL_SPECS 설명 문구 그대로인 이유입니다.

이 파일을 직접 실행할 일은 거의 없습니다. stdio 전송에서는 클라이언트가 서버를 자식 프로세스로 띄우기 때문입니다. 서버 클래스 이름은 SDK 버전을 탑니다. 파이썬 SDK 1.x에서는 FastMCP였고 2.0부터 MCPServer로 정리되었습니다. 데코레이터 방식과 스키마 생성 규칙은 같으니 설치된 버전에 맞는 이름을 쓰면 됩니다.

클라이언트로 붙이기

이제 에이전트 쪽입니다. 할 일은 둘입니다. 서버에서 받은 MCP 도구 목록을 Anthropic API의 input_schema 형태로 바꾸고, 도구 실행을 MCP 호출로 갈아 끼우는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# mcp_client.py
import asyncio
import json

from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp_types import TextContent

import loop

SERVER = StdioServerParameters(command="python", args=["server.py"])


def to_specs(tool_list) -> list[dict]:
    """MCP 도구 목록을 Anthropic API의 도구 정의 형태로 바꿉니다."""
    return [
        {
            "name": t.name,
            "description": t.description,
            "input_schema": t.input_schema,
        }
        for t in tool_list.tools
    ]


def make_run_tool(mcp_client, event_loop):
    """MCP 도구 호출을 run_agent가 기대하는 동기 함수로 감쌉니다."""

    def run_tool(name: str, args: dict) -> dict:
        future = asyncio.run_coroutine_threadsafe(
            mcp_client.call_tool(name, args), event_loop
        )
        result = future.result()
        text = "\n".join(
            b.text for b in result.content if isinstance(b, TextContent)
        )
        if result.is_error:
            return {"error": text}
        try:
            return json.loads(text)
        except json.JSONDecodeError:
            return {"result": text}

    return run_tool


async def main():
    event_loop = asyncio.get_running_loop()
    async with Client(stdio_client(SERVER)) as mcp_client:
        tool_list = await mcp_client.list_tools()
        print([t.name for t in tool_list.tools])

        loop.TOOL_SPECS = to_specs(tool_list)
        loop.run_tool = make_run_tool(mcp_client, event_loop)

        answer = await asyncio.to_thread(
            loop.run_agent, "이수진 고객이 이어폰 환불하고 싶대"
        )
        print(answer)


if __name__ == "__main__":
    asyncio.run(main())

돌려 봅니다.

1
python mcp_client.py
1
2
3
['search_orders', 'get_order', 'request_refund']
이수진 고객님의 무선 이어폰 주문(20260801-0012, 89,000원)을 환불 접수했습니다.
2026-08-03에 배송완료된 건이며, 사유는 고객 요청으로 기록했습니다.

첫 줄이 서버에서 받아 온 도구 목록이고, 그 아래는 3편의 run_agent가 낸 답입니다.

핵심은 run_agent를 한 줄도 고치지 않았다는 점입니다. loop.py는 여전히 TOOL_SPECS를 모델에 넘기고, stop_reason을 보고, run_tool을 부르고, tool_result를 채워 넣습니다. 바뀐 것은 그 두 이름이 가리키는 대상뿐입니다. 도구 정의는 tools.py가 아니라 서버가 알려 준 것이고, 도구 실행은 함수 호출이 아니라 프로세스 경계를 넘는 요청입니다.

두 가지 세부 사항을 짚어 둡니다.

MCP 클라이언트는 비동기이고 run_agent는 동기입니다. 그래서 asyncio.to_thread로 에이전트를 워커 스레드에 올리고, 그 스레드에서 나온 도구 호출은 run_coroutine_threadsafe로 이벤트 루프에 되돌려 보냅니다. future.result()가 블로킹하는 동안에도 루프는 살아 있어서 MCP 세션이 응답을 받아 옵니다. 처음부터 비동기로 짰다면 이 다리는 필요 없습니다.

도구 결과는 dict가 아니라 콘텐츠 블록 목록으로 돌아옵니다. 서버 함수가 dict를 반환하면 SDK가 JSON 텍스트로 직렬화해 텍스트 블록에 담습니다. 그래서 텍스트를 모아 json.loads로 되돌리되 파싱이 안 되면 그대로 감싸 넘깁니다. 도구가 실패해도 예외가 올라오지 않고 is_error가 켜진 결과가 돌아오는데, 이것을 {"error": ...}로 바꿔 주면 3편에서 정한 규칙("error" in output이면 is_error)이 그대로 동작합니다.

anthropic SDK의 MCP 헬퍼

위에서 손으로 한 변환은 anthropic SDK가 대신 해 주기도 합니다. pip install "anthropic[mcp]"로 설치하면 anthropic.lib.tools.mcpmcp_toolasync_mcp_tool을 쓸 수 있습니다. MCP 도구 객체와 클라이언트를 넘기면 SDK의 자동 루프인 tool_runner에 바로 꽂을 수 있는 도구가 나옵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from anthropic import AsyncAnthropic
from anthropic.lib.tools.mcp import async_mcp_tool

client = AsyncAnthropic()


async def run_with_helper(mcp_client, user_message: str):
    tool_list = await mcp_client.list_tools()
    runner = client.beta.messages.tool_runner(
        model="claude-opus-5",
        max_tokens=4096,
        messages=[{"role": "user", "content": user_message}],
        tools=[async_mcp_tool(t, mcp_client) for t in tool_list.tools],
    )
    async for message in runner:
        print(message.stop_reason)

두 방식의 차이는 분명합니다.

 직접 변환async_mcp_tool + tool_runner
스키마 변환직접 작성SDK가 처리
루프우리 run_agentSDK가 돌림
승인 게이트, 컨텍스트 관리4편, 5편에서 만든 것 그대로tool_runner의 훅으로 다시 구성
안정성정식 API만 사용tool_runner는 베타

새 프로젝트를 빠르게 세운다면 헬퍼 쪽이 짧습니다. 다만 4편의 컨텍스트 다이어트, 5편의 환불 승인 게이트, 6편의 위임은 전부 run_agent 안에 있어서, tool_runner로 갈아타면 그 장치를 SDK의 훅 위에 다시 얹어야 합니다. 이미 루프를 쥐고 있다면 변환 함수 열 줄을 직접 쓰는 편이 잃는 것이 적습니다. 클라이언트 클래스 이름도 mcp 1.x의 ClientSession에서 2.0의 Client로 바뀌었으니, 예제를 옮겨 올 때는 설치된 버전을 확인해야 합니다.

도구가 서른 개가 되면

MCP를 붙이면 도구를 늘리기가 쉬워집니다. 주문 서버, 배송 서버, 재고 서버, 정책 검색 서버를 각각 붙이면 도구 서른 개짜리 에이전트가 금방 됩니다. 여기서 두 가지 비용이 생깁니다.

첫째는 토큰입니다. 도구 정의는 매 요청마다 전부 컨텍스트에 올라갑니다. 도구 하나가 200토큰이면 서른 개는 6000토큰이고, 스무 턴 도는 동안 매번 냅니다. 게다가 프롬프트 캐시의 렌더 순서는 tools, system, messages라서 도구 목록이 바뀌면 그 뒤의 캐시가 전부 무효화됩니다.

둘째는 정확도입니다. 이름이 비슷한 도구가 다섯 개 있으면 모델이 엉뚱한 것을 고릅니다. 세 개일 때 잘 고르던 에이전트도 서른 개가 되면 틀리기 시작합니다.

해법은 필요한 도구만 찾아 올리는 것입니다. Claude API에는 이것을 서버 쪽에서 처리하는 tool search 도구가 있습니다. 나머지 도구에 defer_loading을 켜 두면 정의가 컨텍스트에 올라가지 않고, 모델이 검색으로 필요한 것을 찾았을 때 그 스키마만 뒤에 덧붙습니다. 덧붙는 방식이라 앞쪽 캐시는 살아남습니다.

1
2
3
4
5
6
TOOLS = [
    {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"},
    SEARCH_ORDERS_SPEC,                              # 자주 쓰는 것은 그대로 올립니다
    {**GET_ORDER_SPEC, "defer_loading": True},
    {**REQUEST_REFUND_SPEC, "defer_loading": True},
]

정규식 방식(tool_search_tool_regex_20251119)과 BM25 방식(tool_search_tool_bm25_20251119)이 있습니다. 검색 도구 자신에게는 defer_loading을 걸면 안 되고, 모든 도구를 미뤄 두면 요청이 400으로 거부되니 자주 쓰는 도구 한둘은 그대로 두는 편이 안전합니다. 도구가 열 개 아래라면 이 장치는 필요 없습니다.

MCP를 쓰지 말아야 할 때

여기까지 읽고 나면 도구는 전부 MCP로 빼야 할 것 같지만 그렇지 않습니다.

도구가 서너 개고 한 팀이 전부 관리한다면 프로토콜 계층은 얻는 것 없이 짐만 됩니다. 도입 전에는 search_orders(customer="이수진") 한 줄이던 것이, 도입 후에는 프로세스 하나와 직렬화 두 번, 그리고 그 사이에서 생길 수 있는 실패가 됩니다. 서버가 뜨지 않는 경우, 응답이 오지 않는 경우, 스키마가 클라이언트 기대와 어긋나는 경우가 전부 새 실패 지점입니다. 디버깅도 갈라져서, 도구에서 터진 예외는 클라이언트에 is_error 결과로 도착하고 원래 스택 트레이스는 서버 쪽 로그에만 남습니다.

MCP가 이득으로 돌아서는 조건은 6편의 서브에이전트와 비슷한 모양입니다.

  • 도구를 만드는 사람과 에이전트를 만드는 사람이 다를 때
  • 같은 도구를 두 개 이상의 에이전트가 쓸 때
  • 도구가 이미 별도 서비스로 존재해서 어차피 경계를 넘어야 할 때
  • 이미 만들어진 MCP 서버를 가져다 쓸 때. 이때는 도구 구현 자체가 공짜입니다

반대로 도구와 에이전트의 배포 주기가 같고 앞으로도 같을 예정이라면 import가 정답입니다. 프로토콜은 이미 있는 경계를 정리해 주는 도구이지, 없는 경계를 만들어서 얻는 것은 아닙니다.

다음 편

이제 구조는 갖췄습니다. 도구를 고르는 루프가 있고, 컨텍스트 관리 장치가 있고, 위험한 행동 앞에는 사람이 있고, 도구는 에이전트 코드 밖에서 삽니다.

그런데 정작 이 에이전트가 일을 잘 하는지는 모릅니다. 지금까지 확인한 방법은 한 번 돌려 보고 출력이 그럴듯한지 눈으로 보는 것뿐이었습니다. 이 방식으로는 답할 수 없는 질문이 많습니다. 프롬프트를 고쳤을 때 좋아진 것인지 나빠진 것인지, 도구 설명을 docstring으로 옮기면서 성능이 떨어지지는 않았는지. 에이전트는 같은 입력에도 매번 다른 경로로 답하니 최종 답만 비교해서는 알 수 없습니다.

8편에서 궤적을 채점합니다. 어떤 도구를 어떤 순서로 불렀는지에 점수를 매기고, 회귀를 잡고, 비용과 지연을 추적합니다.

다음 글: LLM 에이전트 기초 (8) - Evaluation and Operations: 궤적 채점, 비용, 실패 대응

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.