
외부 접속과 확장성 ― MCP로 타인의 도구를 자신의 에이전트에 추가하기 【프롬프트로 읽어내는 AI 에이전트 #8】
요약
Anthropic이 제안한 MCP(Model Context Protocol)를 통해 AI 에이전트가 외부 도구를 표준화된 방식으로 연동하는 방법을 다룹니다. MCP의 핵심 구성 요소인 서버, 클라이언트, 트랜스포트의 개념과 에이전트 루프 내에서의 작동 원리를 설명합니다.
핵심 포인트
- MCP는 LLM 앱과 외부 툴 제공자를 연결하는 공통 인터페이스 규약임
- 기존 에이전트의 툴 메커니즘을 외부 프로세스로 확장하는 역할
- MCP의 3대 요소: 서버(Server), 클라이언트(Client), 트랜스포트(Transport)
- stdio, SSE, Streamable HTTP를 통한 통신 지원
연재 「프롬프트로 읽어내는 AI 에이전트」 제8회 (제6.5장).
실재하는 3가지 AI 에이전트 OSS를 실제 코드와 실제 프롬프트를 원전에서 인용하며 읽어내고,
최종적으로 「스스로 AI 에이전트를 만들 수 있는」 상태를 목표로 하는 연재입니다.
이 장의 전제 (제1장~제6장 복습)
지금까지 실행형 에이전트의 내용은 대략 갖춰졌습니다 ―― 루프 (제1장), 인격과 행동 규범의 시스템 프롬프트 (제2장), LLM에 제시 및 호출하는 툴 (제3장), 휘발과 영속의 2층 메모리 (제4장), 부모-자식 호출과 kanban의 분업 (제5장), 자기 개선과 샌드박스/미신뢰 관측의 안전 규범 (제6장). 전 장 마지막에서 예고한 대로, 지금까지 다룬 「도구」는 모두 에이전트를 개발한 측에서 미리 준비한 것이었습니다.
본 장에서 다루는 것은, 사용자나 제3자가 나중에 도구를 가져오는 세계입니다. 중심이 되는 규약이 MCP (Model Context Protocol). Anthropic이 2024년 말에 제안한 「LLM 앱과 외부 툴 제공자를 연결하는 공통 인터페이스」로, 본 장의 시점(2026년)에서는 실행형 에이전트의 표준 연동 프로토콜로서 널리 채택되어 있습니다. 본 장의 주장을 한 줄로 요약하면 다음과 같습니다.
MCP는 「제3장의 툴 정의 스키마」와 「제1장의 ReAct 루프의 관측 쓰기(observation write-back)」를 네트워크 너머의 프로세스 간에 일치시키기 위한 약속이다.
새로운 원리는 하나도 필요하지 않습니다. 제3장의 툴 메커니즘을 자신의 프로세스 내 함수가 아니라 외부 서버로 향하게 할 뿐입니다. 본 장의 목표는 ―― (a) agent-zero와 Hermes가 MCP를 어떻게 도입하고 있는지 원전에서 확인하고, (b) 독자가 자작 에이전트 (제1장의 run_agent)에 MCP 서버 1개를 연결하는 최소 사례를 가져가는 것입니다. 제1부 「실행형 에이전트의 해부」의 마지막 부품입니다.
MCP란 ―― 제3장의 툴 계약을 프로세스 간에 배치했을 뿐
제3장에서 툴을 정의했을 때, 우리는 「이름·설명·입력 스키마·실행 함수」의 4종 세트를 작성했습니다. MCP 서버가 제공하는 것도 완전히 동일한 4종 세트입니다. 차이점은 「실행 함수가 당신의 프로세스 내에 있는가, 아니면 별도의 프로세스(경우에 따라서는 별도의 머신)에 있는가」뿐입니다.
MCP의 최소 요소는 3가지입니다.
서버 (Server): 툴·리소스·프롬프트를 공개하는 측. 예를 들어 「파일 시스템 액세스」 「GitHub API」 「Slack 게시」 등을 하나의 서버가 묶어서 공개한다.
클라이언트 (Client): 서버에 접속하여 tools/list로 도구 목록을 가져오고, tools/call로 실행을 의뢰하는 측. 에이전트 본체가 여기에 있다.
트랜스포트 (Transport): 서버와 클라이언트의 통신로. 표준 입출력 (stdio), HTTP의 Server-Sent Events (SSE), 새로운 Streamable HTTP의 3종류가 주류다.
클라이언트 입장에서 보면 흐름은 다음과 같습니다.
- 기동 시 각 MCP 서버에 접속하여
tools/list로 툴 정의 리스트를 받는다. - 받은 툴 정의를 자신의 내부 툴과 동일한 틀로 LLM에 제시한다 (제3장의 「LLM에 대한 제시 방법」과 동일).
- LLM이 외부 툴을 호출하고자 하면
tools/call(name, args)로 서버에 의뢰하고, 돌아온 결과를 관측(observation)으로서 이력에 다시 쓴다 (제1장 루프의 Observe와 동일).
즉 MCP는 본 연재에서 이미 조립한 루프 + 툴 메커니즘의 「툴 부분의 출처를 교체하는 것」뿐인 규약입니다. 구체적인 구현을 먼저 agent-zero, 다음으로 Hermes에서 살펴보겠습니다. 양쪽 모두 클라이언트 기능을 가지며, agent-zero는 나아가 자기 자신을 MCP 서버로서도 공개할 수 있습니다.
실물로 확인하기 ① ― agent-zero: 원격 툴을 로컬 툴과 동일한 형태로 감싸기
agent-zero (agent0ai/agent-zero @ f9d8167)의 MCP 통합은 helpers/mcp_handler.py에 집약되어 있습니다. 핵심이 되는 사상은 ――
원격의 MCP 툴을 자전 툴과 완전히 동일한 형태로 만드는 것입니다. 제1장에서 본 루프 (Tool 서브클래스로 래핑하는 agent.py의 monologue...
)는 내부 도구와 외부 MCP 도구의 구분을 거의 의식하지 않아도 됩니다.
MCPTool(Tool)
MCP 도구를 「Tool의 서브클래스 (subclass)」로 감싸기 ― agent-zero의 자체 도구 (response / call_subordinate 등)는 모두 helpers.tool.Tool을 상속받았습니다 (제3장). MCP 도구도 동일한 기저 클래스 (base class)를 상속받습니다.
class MCPTool(Tool):
"""MCP Tool wrapper"""
―― helpers/mcp_handler.py @ f9d8167, L112-113
Tool을 상속받고 있다면, execute · before_execution · after_execution이라는 제3장의 동일한 인터페이스 (interface)와 일치합니다. execute의 구현은 MCP 서버를 묶는 싱글톤 (singleton) MCPConfig를 통해 원격 호출 (remote call)을 수행할 뿐입니다.
async def execute(self, **kwargs: Any):
error = ""
additional: dict[str, Any] | None = None
...
―― helpers/mcp_handler.py @ f9d8167, L343-370 (발췌 · 끝부분 로그 처리는 ...으로 생략)
주목할 점은 마지막의 return Response(message=message, break_loop=False, ...)입니다. 제1장에서 본 "response 도구만이 break_loop=True를 반환하여 루프를 빠져나간다"의 break_loop, 즉 MCP 도구는 항상 False를 반환한다는 점입니다. 다시 말해, 외부 도구가 내부에서 무엇을 하든 반드시 관측 (observation)으로서 이력 (history)에 쌓인 후 루프가 계속되도록 설계되어 있습니다.
after_execution (동 L389-429) 또한 자체 도구와 동일하게 hist_add_tool_result(...)로 관측을 다시 씁니다. 제1장의 ReAct 루프로부터는 내부 도구와 MCP 도구의 구분이 거의 사라지게 되는 것입니다.
MCP 서버 설정 ― 로컬 (stdio)과 원격 (SSE/HTTP)의 2가지 형태
서버의 추상화 (abstraction)는 MCPServerLocal (표준 입출력으로 실행되는 서브 프로세스)과 MCPServerRemote (URL을 호출하는 SSE/HTTP)의 두 클래스로 나뉩니다.
class MCPServerRemote(BaseModel):
name: str = Field(default_factory=str)
description: Optional[str] = Field(default="Remote SSE Server")
...
―― helpers/mcp_handler.py @ f9d8167, L432-441
class MCPServerLocal(BaseModel):
name: str = Field(default_factory=str)
description: Optional[str] = Field(default="Local StdIO Server")
...
―― helpers/mcp_handler.py @ f9d8167, L505-511 (발췌. encoding / timeout 등은 생략)
MCPConfig 싱글톤이 이 둘을 하나로 묶습니다 (get_instance()로 유일한 인스턴스를 반환하며, servers 리스트에 MCPServerLocal / MCPServerRemote를 보유). 설정은 JSON 문자열로 전달되며, MCPConfig.update(config_str)가 이를 파싱하여 각 서버를 구축합니다 (helpers/mcp_handler.py @ f9d8167, L609-703).
tools/list를 받아 교체 가능한 딕셔너리로 정규화
도구 목록 취득 ― 각 서버의 이면에는 MCP SDK의 클라이언트 세션 (MCPClientBase 계열)이 존재하며, 초기화 시 tools/list를 호출하여 도구 정의를 **딕셔너리 리스트 (list of dictionaries)**로 정규화하여 유지합니다.
async def list_tools_op(current_session: ClientSession):
response: ListToolsResult = await current_session.list_tools()
with self.__lock:
...
―― helpers/mcp_handler.py
@ f9d8167
, L1111-1121
이것이 MCP의 "도구 정의 4종 세트"의 정체입니다 ―― **이름(Name)・설명(Description)・입력 스키마(Input Schema)**의 3가지를 추출하여, 나중에 tools/call을 호출할 때의 참조용으로 사용합니다 (실행 함수는 통신 경로 너머에 있습니다). 제3장에서 보았던 로컬 도구의 스키마와 형태가 완전히 동일합니다.
{{tools}}라는 한 줄의 틀
시스템 프롬프트(System Prompt)로의 삽입 ― 수집한 도구 정의를 LLM에게 "사용법을 이해시키는" 방법은 놀라울 정도로 소박합니다. 프롬프트 파일 자체는 단 한 줄뿐입니다.
{{tools}}
―― prompts/agent.system.mcp_tools.md
@ f9d8167
, L1
이 {{tools}}를 채우는 것이, 시스템 프롬프트 조립 시 실행되는 번호 매겨진 확장(Extension) (제2장에서 보았던 것과 동일한 메커니즘)입니다.
class MCPToolsPrompt(Extension):
async def execute(
self,
...
―― extensions/python/system_prompt/_12_mcp_prompt.py
@ f9d8167
, L8-33 (중간의 set_progress 호출을 ...으로 생략)
MCPConfig.get_tools_prompt()의 내용이 그대로 LLM에게 보여줄 설명문입니다. 서버마다 ### <서버 이름> 헤더를 배치하고, 그 아래에 도구별 설명과 입력 스키마, 그리고 제1장에서 보았던 JSON 응답 규약에 맞춘 사용 예시(tool_name에 "server.tool"과 같은 점(dot) 연결 이름을 넣는 형태)를 생성합니다.
prompt += (
f"\n### {server_name}.{tool['name']}:\n"
f"{tool['description']}\n\n"
...
―― helpers/mcp_handler.py
@ f9d8167
, L956-983 (주석 처리된 행과 \n 서식 행을 ...으로 생략)
즉 MCP 도구는 제2장에서 보았던 "role.md / solving.md를 {{ include }}로 한 장에 합성하는 것"과 완전히 동일한 메커니즘으로, 시스템 프롬프트의 끝에 동적으로 주입됩니다. LLM 입장에서는 내부 도구든 MCP 도구든 구분 없이 "설명서가 첨부된 도구"들이 나열되어 있는 것처럼 보입니다.
루프와의 합류 ― 로컬보다 먼저 MCP를 참조하기
제1장에서 보았던 monologue (ReAct 루프)가 LLM이 응답한 tool_name으로부터 실행할 도구를 찾을 때, 어떤 순서로 룩업(Lookup)하는가. agent.py가 그 과정을 드러냅니다.
# Try getting tool from MCP first
try:
import helpers.mcp_handler as mcp_helper
...
―― agent.py
@ f9d8167
, L890-908 (예외 로그 출력을 ...으로 생략)
MCP를 먼저 참조하고, 없으면 로컬로 폴백(Fallback). MCPConfig.get_tool()은 tool_name에 .이 포함되어 있는지(즉, 서버 이름으로 수식되었는지)를 확인하여, 해당 서버에 도구가 있다면 MCPTool(agent=..., name=tool_name, ...)을 반환합니다 (helpers/mcp_handler.py @ f9d8167, L998-1001).
def get_tool(self, agent: Any, tool_name: str) -> MCPTool | None:
if not self.has_tool(tool_name):
return None
...
―― helpers/mcp_handler.py
@ f9d8167
제3장에서 읽었던 「자체 도구 (Self-made tool)」와 완전히 동일한 Tool 인스턴스가 반환되므로, monologue 측은 이후의 코드를 아무것도 변경할 필요가 없습니다. 이것이 바로 「리모트를 로컬과 동일한 형태로 감싸는 (wrapping remote to be isomorphic to local)」 설계의 효과입니다.
보론: agent-zero는 MCP 서버도 될 수 있다
agent-zero는 클라이언트뿐만 아니라, 자기 자신을 MCP 서버로서 공개할 수도 있습니다.
mcp_server: FastMCP = FastMCP(
name="Agent Zero integrated MCP Server",
instructions="""
...
―― helpers/mcp_server.py
@ f9d8167
, L30-38
fastmcp.FastMCP로 구축한 MCP 서버가 @mcp_server.tool(name="send_message", ...) (동일 파일 L67-91)를 통해 「리모트 Agent Zero 인스턴스에 메시지를 보내는」 도구를 공개합니다. 이를 통해 특정 agent-zero를 다른 agent-zero(또는 Claude Desktop 등 MCP 클라이언트)로부터 외부 도구로서 호출할 수 있습니다. 이는 제5장에서 보았던 프로세스 내의 call_subordinate (동일 프로세스에서 await subordinate.monologue()를 직접 스택 호출하는 계통 A)와는 달리, MCP를 경유한 별도 프로세스/별도 머신으로의 메시징 (chat_id/persistent_chat를 인자로 갖는 비동기 인터페이스)입니다. 제5장의 계통 A/계통 B 축으로 말하자면, 이것은 네트워크를 넘나드는 메시징형 인터페이스에 해당합니다.
또한 agent-zero는 FastA2A (Agent-to-Agent 프로토콜)도 갖추고 있습니다. helpers/fasta2a_server.py와 helpers/fasta2a_client.py가 그것이며, a2a_chat 도구 (prompts/agent.system.tool.a2a_chat.md @ f9d8167)로서 LLM에게 제시됩니다.
### a2a_chat
chat with a remote FastA2A-compatible agent; remote context is preserved automatically per `agent_url`
args: `agent_url`, `message`, optional `attachments[]`, optional `reset`
―― prompts/agent.system.tool.a2a_chat.md
@ f9d8167
, L1-3
MCP가 도구 제공을 위한 규약인 것에 반해, A2A는 에이전트 간의 대화를 위한 규약입니다. 같은 「외부 접속」이라도 계층이 다릅니다. 본 연재에서는 MCP를 주로 다루지만, A2A라는 또 다른 대외 프로토콜도 병존한다는 점을 유념해 두시기 바랍니다.
실물로 확인하기 ② ― Hermes: 내부 도구 레지스트리에 나중에 삽입하기
Hermes (NousResearch/hermes-agent @ 6928692)의 MCP 통합은 tools/mcp_tool.py라는 단 하나의 파일에 집약되어 있습니다. 설계의 핵심은 ― MCP 도구를 내부의 「도구 레지스트리 (tool registry)」에 나중에 등록하는 것입니다. 제3장에서 보았던 빌트인 도구도 MCP 도구도, 레지스트리 위에서는 평면적으로 나열됩니다.
모듈의 도입부가 설계와 사상을 그대로 이야기하고 있습니다.
"""
MCP (Model Context Protocol) Client Support
Connects to external MCP servers via stdio, HTTP/StreamableHTTP, or SSE
...
―― tools/mcp_tool.py
@ 6928692
, L1-9 (도입부 발췌)
포인트는 두 번째 줄 ―― registers them into the hermes-agent tool registry so the agent can call them like any built-in tool (툴 레지스트리에 등록하여 빌트인(built-in) 도구와 마찬가지로 호출할 수 있게 한다). agent-zero가 MCPTool(Tool)을 통해 클래스 상속을 사용하여 동형화(isomorphism)한 것과 달리, Hermes는 툴 레지스트리에 대한 사후 등록을 통해 동형화합니다. 구현 방식은 다르지만, 도달하는 효과는 완전히 동일합니다.
MCP 스키마 → 내부 스키마로의 변환
서버로부터 받은 MCP 툴 정의를 Hermes 내부의 툴 스키마 형식으로 정규화하는 함수가 있습니다.
def _convert_mcp_schema(server_name: str, mcp_tool) -> dict:
"""Convert an MCP tool listing to the Hermes registry schema format.
...
..."""
―― tools/mcp_tool.py
@ 6928692, L2947-2965 (docstring 대부분을 ...으로 생략)
MCP의 name / description / inputSchema를 그대로 Hermes의 name / description / parameters로 옮기며, 이름은 **mcp_<server>_<tool>로 접두사(prefix)**를 붙여 충돌을 방지합니다. 이를 통해 내부 툴과 MCP 툴이 동일한 레지스트리 위에서 네임스페이스(namespace)를 나누어 공존할 수 있습니다. 제6장에서 다루었던 <untrusted_tool_result>의 래핑(wrap) 대상에 mcp_* 접두사가 포함되어 있었던 것(agent/tool_dispatch_helpers.py의 _maybe_wrap_untrusted)은, 여기서 붙은 접두사를 보고 "외부 유래인지 여부"를 판단하고 있었던 것입니다.
tools.include / tools.exclude로 도구 선별
서버 단위의 등록 ― 서버별 툴 등록은 _register_server_tools에서 수행됩니다. 안전성을 위한 필터(include/exclude)와 충돌 회피(built-in tool과의 이름 충돌은 skip)가 공존합니다.
def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> List[str]:
"""Register tools from an already-connected server into the registry.
...
..."""
―― tools/mcp_tool.py
@ 6928692, L3177-3209 (docstring 및 기타 주석을 ...으로 생략)
include를 전달하면 화이트리스트(whitelist), exclude를 전달하면 **블랙리스트(blacklist)**가 됩니다. 서버가 200개의 툴을 공개하더라도 필요한 3개만 가져올 수 있는데, 이는 외부 접속의 현실적인 제어점입니다.
실제 등록 루프는 각 툴에 대해 (1) description의 프롬프트 인젝션(prompt injection) 스캔, (2) 스키마 변환, (3) 빌트인과의 이름 충돌 체크, (4) 레지스트리 등록을 순차적으로 진행합니다.
for mcp_tool in server._tools:
if not _should_register(mcp_tool.name):
logger.debug("MCP server '%s': skipping tool '%s' (filtered by config)", name, mcp_tool.name)
...
―― tools/mcp_tool.py
@ 6928692, L3211-3240
_scan_mcp_description이 툴 설명문(LLM의 시스템 프롬프트에 포함됨)을 **프롬프트 인젝션 패턴(prompt injection pattern)**으로 스캔하는 것은 제6장 「신뢰할 수 없는 관측」의 발상을 확장한 것입니다. 외부에서 온 도구는 툴 결과뿐만 아니라 툴 설명 자체도 신뢰할 수 없다는 전제에 기반하고 있습니다.
registry.register(...)
의 handler=
에 전달하는 것이 원격 호출을 수행하는 클로저(closure)입니다.
def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
"""Return a sync handler that calls an MCP tool via the background loop.
...
...
―― tools/mcp_tool.py
@ 6928692
, L2417-2461 (서킷 브레이커(circuit breaker)・예외 처리・결과 정형화를 ...로 생략)
server.session.call_tool(tool_name, arguments=args)
가 MCP SDK의 tools/call 호출입니다. 레지스트리(registry)를 통해 보면, LLM으로부터 보이는 mcp_<server>_<tool>이라는 도구는 제3장의 빌트인 도구(built-in tool)와 호출 방식이 완전히 동일해집니다. LLM이 tool_calls로 mcp_filesystem_read_file을 호출하면, 레지스트리가 _handler를 실행하고, 그것이 배후에서 MCP 프로토콜을 사용하여 통신합니다 ―― 제1장의 tool_calls 분기 이후로는 아무것도 바꿀 필요가 없습니다.
config.yaml의 mcp_servers 설정의 최소 예시 ― 사용자가 어떻게 작성하는지는 모듈의 docstring 도입부 예시가 명확하게 보여줍니다.
mcp_servers:
filesystem:
command: "npx"
...
―― tools/mcp_tool.py
@ 6928692
, L14-32 (docstring 내 YAML 예시. 후속 SSE/sampling 예시는 생략)
command + args로 stdio를 실행하고, url로 HTTP(Streamable HTTP가 기본값, transport: sse로 SSE)를 사용합니다. 파일 하나에 몇 줄의 텍스트를 쓰는 것만으로 외부 도구가 자신의 에이전트에 장착됩니다. 이것이 MCP의 운용상의 위력입니다.
보론: Hermes는 MCP 서버도 될 수 있다
Hermes도 agent-zero와 마찬가지로, 자기 자신을 MCP 서버로서 공개하는 모드를 가집니다. mcp_serve.py가 바로 그것이며, stdio MCP 서버로서 Hermes의 대화 로그·메시징 기능(Slack/Discord 통합의 send/poll 등)을 외부 MCP 클라이언트(Claude Desktop / Cursor 등)에서 호출할 수 있게 합니다.
"""
Hermes MCP Server — expose messaging conversations as MCP tools.
Starts a stdio MCP server that lets any MCP client (Claude Code, Cursor, Codex,
...
―― mcp_serve.py
@ 6928692
, L1-6 (docstring 발췌)
MCP는 대칭적인 프로토콜입니다 ―― agent-zero도 Hermes도, 클라이언트도 서버도 될 수 있습니다. 당신이 직접 만드는 에이전트 역시 양쪽 모두에 동일하게 접근할 수 있습니다.
두 존재의 대조 ―― 같은 MCP, 동형화(isomorphism) 전략이 두 가지
양측 모두 MCP 클라이언트로서 동일한 규약(stdio/SSE/Streamable HTTP, tools/list, tools/call)을 따르며, 외부 도구를 자체 도구와 동형으로 취급한다는 동일한 효과를 냅니다. 차이점은 그 '동형화'를 어느 정도의 입도(granularity)로 수행하느냐에 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기