1. 이번 단계에서 달라지는 것
지금까지 도구는 코드 안에 들어있었다. 이번에는 도구를 별도 프로세스(MCP 서버)로 분리하고, 표준 프로토콜로 통신하며 가져다 쓰는 구조를 만들었음.
2편에서 @tool 데코레이터로 만든 곱셈 함수는 에이전트와 같은 파일·같은 프로세스 안에 있었다. 이번에는 도구를 MCP(Model Context Protocol) 서버로 떼어내고, 에이전트는 그 서버에 접속해 도구 목록을 받아온 뒤 LangChain 도구로 변환해 LLM에 바인딩하였음.
- MCP 서버 — 도구의 구현체. 이름·인자·반환 스키마를 외부에 노출함. 에이전트와 분리된 별도 프로세스로 동작.
- MCP 클라이언트 — 서버에 접속해 도구 목록을 조회하고, 도구를 호출(JSON-RPC)하는 통로.
- 도구 어댑터 — MCP 도구를 LangChain StructuredTool로 변환. 이게 있어야 2~5편에서 써온 그래프에 그대로 끼울 수 있음.
2편이 도구를 "내 코드에 직접 넣은" 단계였다면, 이번엔 도구를 "외부 표준으로 빼낸" 단계다. 한 번 어댑터를 만들어 두면 어떤 MCP 서버든 같은 방식으로 붙는다.
2. MCP 서버 — 도구를 외부로 노출
FastMCP로 6개 도구를 구성함. 계산·시간·메모 CRUD·RAG 자리까지, LLM이 가져다 쓸 외부 기능들을 한 파일에 모았다.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP('6ToolsMPCServer')
note_memory = dict() # 메모 저장용 인메모리 저장소(데모용)
`FastMCP`로 서버 객체 하나를 만들고, 이후 `@mcp.tool()`을 붙인 함수들이 자동으로 도구로 등록된다. `note_memory`는 CRUD 도구가 공유하는 서버 측 임시 저장소.
2.1 계산·시간 도구
@mcp.tool()
def add(a: float, b: float) -> str:
'''두 수를 더하는 계산기
Args:
a : 첫 번째 수치
b : 두 번째 수치
Returns
계산 결과
'''
result = a + b
logger.info(f'Tool 1 add 호출 : {a} + {b} = {result}')
return f'계산결과 : {a}+{b} = {result}'
@mcp.tool()
def get_time() -> str:
'''서버측 현재 시간을 조회
Returns
현재 시간 문자열
'''
cur_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return f'현재 시간: {cur_time}'
②편 111글의 `@tool`과 판박이임. 데코레이터로 함수를 도구로 등록하고, docstring·타입힌트가 곧 LLM이 읽는 설명서가 된다. 차이는 이 함수가 에이전트가 아니라 "서버 측"에 있다는 것뿐. `get_time`처럼 인자 없는 도구도 그대로 등록된다.
2.2 메모 CRUD 도구
상태를 가진 도구 묶음. 저장(Create/Update)·조회(Read)·삭제(Delete)를 note_memory 딕셔너리 위에서 처리함.
@mcp.tool()
def save_note(note_id: str, note_content: str) -> str:
'''메모 저장
Args:
note_id: 메모의 고유 ID, 키값
note_content: 메모 내용
Returns
저장 완료 메세지
'''
if not note_id or not note_content: # 방어 코드
return "Fail: 필수 파라미터 누락"
note_memory[note_id] = {
"content" : note_content,
"created_at" : datetime.now().isoformat()
}
return f"메모 저장 완료 {note_id}"
필수 인자 누락을 먼저 막고(방어 코드), 통과하면 딕셔너리에 저장한다. 같은 `note_id`로 다시 부르면 덮어쓰므로 Create와 Update를 겸함.
@mcp.tool()
def list_note() -> str:
'''저장된 모든 메모 목록 조회'''
if not note_memory:
return "저장된 메모 없음"
notes = "\n".join([
f'- id: {note_id}, content: {value["content"]}'
for note_id, value in note_memory.items()
])
return f'저장된 모든 메모:\n{notes}'
@mcp.tool()
def delete_note(note_id: str) -> str:
'''특정 메모 삭제
Args:
note_id: 메모의 고유 ID, 키값
'''
if note_id in note_memory:
del note_memory[note_id]
return f'메모 삭제 완료! {note_id}'
else:
return f'메모 삭제 실패! {note_id}로 구분되는 메모가 없음'
`list_note`는 빈 저장소를 먼저 거르고, 존재하면 메모를 한 덩어리 문자열로 묶어 반환한다. `delete_note`는 키 존재 여부를 확인한 뒤 `del`로 지운다. 반환값이 전부 문자열인 점이 포인트 — LLM이 읽을 자연어 결과로 돌려준다.
2.3 RAG 자리 — 스텁
@mcp.tool()
def rag_search():
pass # 검색증강 -> ④편 RAG와 연결 시 구현 예정
빈 도구지만 자리를 잡아둠. 서버 측 도구로 검색증강을 노출하려는 다음 단계(④편 113글 RAG)의 연결점이다.
2.4 로깅은 반드시 stderr로 — STDIO 통신의 함정
STDIO 모드에서 stdout은 JSON-RPC 메시지 전용 채널임. print/로그를 stdout으로 흘리면 프로토콜이 깨짐.
import sys, logging
logging.basicConfig(
level = logging.INFO,
format = '[MCP Server] %(levelname)s: %(message)s',
stream = sys.stderr # stdout 아님. 반드시 stderr
)
if __name__ == '__main__':
mcp.run(transport="stdio") # 표준 입출력으로 통신
서버와 클라이언트는 stdin/stdout으로 JSON-RPC를 주고받는다. 로그를 stdout에 찍으면 클라이언트가 그걸 RPC 응답으로 오해해 파싱이 깨진다. 그래서 사람이 볼 메시지는 전부 stderr로 분리함.
데이터엔지니어 감각으로 보면 stdout = 데이터 채널, stderr = 관측 채널의 분리다. 파이프라인에서 로그와 페이로드를 섞지 않는 것과 같은 원칙.
3. MCP 클라이언트 — 접속과 도구 호출
서버를 자식 프로세스로 띄우고, 세션을 열어 도구 목록을 받아 호출까지 해봄.
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
server_params = StdioServerParameters(
command = sys.executable, # 현재 파이썬으로
args = ['server.py'], # 서버 스크립트를 띄움
env = None,
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize() # 1. 세션 초기화
res = await session.list_tools() # 2. 도구 목록 조회
self.tools = res.tools
`stdio_client`가 서버 프로세스를 띄우고 입출력 스트림(read/write)을 돌려준다. 그 스트림으로 `ClientSession`을 만들고 초기화하면, `list_tools()`로 서버가 노출한 도구 6개를 받아온다.
각 도구는 name, description, inputSchema(JSON Schema)를 들고 옴. 인자가 있는 도구는 스키마에서 매개변수명을 뽑아 확인할 수 있음.
for tool in self.tools:
if hasattr(tool, "inputSchema") and tool.inputSchema:
props = tool.inputSchema.get('properties', {})
if props:
print(f'입력 : {", ".join(props.keys())}') # 예: a, b
`inputSchema`가 비어 있을 수 있으니 `hasattr` + 기본값 `{}`로 방어한다. 이 스키마가 다음 단계 변환의 원재료다.
3.1 함정 — 결과는 문자열이 아니라 content 블록
`call_tool` 반환값을 그냥 출력하면 객체가 찍힘. 결과는 content 리스트 안에 들어 있어, text 필드를 꺼내야 함.
async def call_tool(self, session, tool_name: str, arguments: dict):
result = await session.call_tool(tool_name, arguments)
if hasattr(result, 'content') and result.content:
for content in result.content:
if hasattr(content, 'text'):
print(f' 결과 : \n{content.text}\n') # 여기서 text를 꺼냄
else:
print(f' 결과 : \n{content}\n')
else:
print(f' 결과 : \n{result}\n')
return result
MCP 응답은 평범한 문자열이 아니라 content 블록의 리스트다. 텍스트·이미지 등 여러 타입을 담을 수 있는 구조라, `content.text`로 명시적으로 꺼내야 사람이 읽을 결과가 나온다. 이 패턴은 클라이언트와 다음 장 어댑터 양쪽에 똑같이 등장함.
도구 호출 시나리오는 다음과 같이 굴렸음.
await self.call_tool(session, "add", {"a": 100, "b": 5})
await self.call_tool(session, "get_time", {})
await self.call_tool(session, "save_note", {"note_id": "de-001", "note_content": "MCP 1"})
await self.call_tool(session, "save_note", {"note_id": "de-002", "note_content": "MCP 2"})
await self.call_tool(session, "list_note", {})
await self.call_tool(session, "delete_note", {"note_id": "de-002"})
await self.call_tool(session, "list_note", {})
저장 2건 → 목록 → 1건 삭제 → 목록으로, CRUD가 서버 측 note_memory에 실제로 반영되는지 확인. 인자 없는 도구는 빈 딕셔너리 `{}`를 넘긴다.
여기까지는 사람이 직접 도구 이름과 인자를 적어 호출한 단계다. 다음 장부터는 이 호출을 LLM이 스스로 판단해 날리게 만든다. 그러려면 MCP 도구를 LangChain 도구로 바꿔야 한다.
4. 도구 어댑터 — MCP → LangChain 변환 (핵심)
MCP 도구를 LangChain `StructuredTool`로 자동 변환하는 어댑터. 이번 편에서 가장 손이 많이 간 부분임.
4.1 함정 1 — async with를 못 쓴다
세션·스트림을 여러 메서드(initialize / call_tool / cleanup)에서 계속 써야 하므로, with 블록으로 자동 종료시킬 수 없음. 컨텍스트 매니저를 수동으로 연다.
async def initialize(self):
server_params = StdioServerParameters(
command=sys.executable, args=[self.server_script], env=None
)
# with문은 블록을 벗어나면 세션을 닫아버림 -> 못 씀
self._stdio_context = stdio_client(server_params)
stdio_tuple = await self._stdio_context.__aenter__() # 직접 진입
self.read_stream, self.write_stream = stdio_tuple
self.session = ClientSession(self.read_stream, self.write_stream)
await self.session.__aenter__() # 세션도 수동 활성화
await self.session.initialize()
res = await self.session.list_tools()
self.mcp_tools = res.tools
return self
3장 client.py처럼 `async with` 한 블록에서 다 끝내면 편하지만, 그건 "접속→호출→종료"가 한 흐름일 때 얘기다. 어댑터는 호출 시점이 다 다르므로, `__aenter__`로 열고 나중에 cleanup에서 `__aexit__`로 닫는다.
async def cleanup(self):
try:
if self.session:
await self.session.__aexit__(None, None, None)
except Exception as e:
print('세션 종료 에러', e)
try:
if self._stdio_context:
await self._stdio_context.__aexit__(None, None, None)
except Exception as e:
print('입출력 스트림 종료 에러', e)
수동으로 연 자원은 수동으로 닫아야 한다. 세션 → 스트림 순서로 `__aexit__`을 호출하고, 각각 try로 감싸 한쪽 종료 실패가 다른 쪽을 막지 않게 한다.
4.2 함정 2 — 클로저 늦은 바인딩
도구 호출 함수를 반복문 안에서 그냥 만들면, 모든 함수가 마지막 도구 이름만 참조하게 됨. 한 겹 감싸 독립시킨다.
def create_tool_func(name: str): # name을 인자로 고정(캡처)
async def async_tool_func(**kwargs) -> str:
return await self.call_tool(name, kwargs)
return async_tool_func
tool_func = create_tool_func(tool_name) # 도구마다 독립 함수 생성
이렇게 안 감싸고 루프 변수 `tool_name`을 직접 참조하면, 파이썬 클로저 특성상 모든 함수가 루프 종료 후의 마지막 값을 본다. add를 부르려 했는데 delete_note가 불리는 식. `create_tool_func(name)`으로 값을 인자에 박아 끊어준다.
어댑터 측 call_tool도 결과에서 text를 꺼내는 건 동일함.
async def call_tool(self, tool_name: str, arguments: dict) -> str:
result = await self.session.call_tool(tool_name, arguments)
if hasattr(result, 'content') and result.content:
for content in result.content:
if hasattr(content, 'text'):
return content.text
return str(result)
3.1장의 content 블록 패턴을 LangChain 도구가 기대하는 "문자열 반환" 형태로 정리한 것. LLM에는 결국 이 문자열이 도구 실행 결과로 전달된다.
4.3 스키마 → pydantic 동적 모델
MCP의 JSON Schema를 읽어 pydantic 모델을 런타임에 생성함. 타입별로 매핑하고, required면 기본값을 강제(...)로 둔다.
from pydantic import BaseModel, Field, create_model
tool_params = {}
props = mcp_tool.inputSchema.get('properties', {})
required = mcp_tool.inputSchema.get('required', [])
for param_name, param_info in props.items():
param_type = param_info.get('type', 'string')
param_desc = param_info.get('description', '')
default = ... if param_name in required else None # ... = 필수
if param_type == 'number': py_type = float
elif param_type == 'integer': py_type = int
elif param_type == 'boolean': py_type = bool
else: py_type = str
tool_params[param_name] = (py_type, Field(default=default, description=param_desc))
if tool_params:
args_schema = create_model(f'{tool_name}_args', **tool_params)
else:
class EmptyArgs(BaseModel): pass # get_time처럼 인자 없는 도구
args_schema = EmptyArgs
MCP는 JSON으로 느슨하게 스키마를 주고, LangChain은 pydantic으로 엄격한 타입 검증을 건다. 그 사이를 메우는 게 이 변환. `create_model`로 도구 개수만큼 인자 스키마 클래스를 찍어내고, 인자 없는 도구는 빈 `EmptyArgs`로 처리한다.
from langchain_core.tools import StructuredTool
try:
langchain_tool = StructuredTool.from_function(
func = tool_func,
name = tool_name,
description = tool_description,
args_schema = args_schema,
coroutine = tool_func, # 비동기 함수임을 명시
)
except Exception:
langchain_tool = StructuredTool.from_function( # coroutine 미지원 버전 폴백
func = tool_func,
name = tool_name,
description = tool_description,
args_schema = args_schema,
)
실행 함수 + 이름 + 설명 + 인자 스키마. 이 네 가지가 곧 ②편에서 LLM이 도구를 고를 때 보던 "판단 재료"다. `coroutine` 인자를 못 받는 버전을 대비해 try/except로 폴백을 둠. 이제 어떤 MCP 서버를 붙여도 이 어댑터 한 벌이면 도구 6개가 전부 LangChain 도구로 변환된다.
5. 에이전트에 결합 — 그래프는 그대로
⑤편 114글에서 만든 그래프 구조(agent 노드 + tools 노드 + 조건부 엣지)를 그대로 재사용함. 도구 출처만 로컬 `@tool`에서 MCP로 바뀐다.
from langgraph.graph import StateGraph, MessagesState, END
from langgraph.prebuilt import ToolNode
# MCP 도구 로드 -> LangChain 도구로 변환
self.mcp_adapter = MCPToolAdapter('server.py')
await self.mcp_adapter.initialize()
self.tools = self.mcp_adapter.create_langchain_tools()
llm_with_tools = self.llm.bind_tools(self.tools) # ②편과 동일
초기화 순서가 곧 의존 순서다. MCP 서버에 붙어 도구를 받아오고(initialize) → LangChain 도구로 변환하고(create_langchain_tools) → LLM에 바인딩(bind_tools). 이 셋이 어긋나면 LLM이 도구를 못 본다.
workflow = StateGraph(MessagesState)
workflow.add_node('agent', call_model)
workflow.add_node('tools', ToolNode(self.tools))
workflow.set_entry_point('agent')
workflow.add_edge('tools', 'agent') # 도구 실행 후 다시 판단
workflow.add_conditional_edges('agent', should_continue, {
'tools': 'tools',
'end' : END
})
self.graph = workflow.compile()
`bind_tools` → `ToolNode` → 조건부 엣지 패턴은 ②편 111글, 분기 함수가 `tool_calls`를 보는 건 ④편 113글 그대로다.
def should_continue(state: MessagesState) -> str:
last_message = state["messages"][-1]
if hasattr(last_message, 'tool_calls') and last_message.tool_calls:
return "tools" # 도구 호출이 있으면 -> tools 노드
return "end" # 없으면 -> 종료
바뀐 건 `self.tools`를 만드는 출처뿐. 그래프 입장에선 도구가 로컬 함수인지 MCP 원격 도구인지 구분하지 않는다.
어댑터가 변환을 책임지므로, 앞서 4편에 걸쳐 쌓은 그래프 자산을 한 줄도 안 고치고 재사용했다. 추상화가 잘 되면 교체 비용이 0에 수렴한다.
5.1 troubleshooting — 첫 작성본이 안 돌던 이유
초안에서 막혔다가 정리하며 잡은 실수들.
- import명 불일치 — 어댑터가 정의한 클래스는 MCPToolAdapter인데 MCPClient로 import해 ImportError. 클래스명을 맞춰 해결.
- await 누락 — initialize()가 비동기인데 await 없이 호출해 코루틴 객체만 생기고 실제 연결이 안 됨. await agent.initialize()로 수정.
- 빈 도구로 bind — MCP 로드를 건너뛴 채 bind_tools([])를 호출해 LLM이 도구를 못 봄. 초기화 순서를 MCP 로드 → 변환 → 바인딩으로 고정.
6. 실행 흐름
"10과 5를 더해줘" 같은 자연어를 넣으면, LLM이 add 도구가 필요하다 판단 → tools 노드에서 MCP 서버 호출 → 결과를 받아 다시 판단 → 최종 답변.
async def process_query(self, user_input: str) -> str:
messages = [HumanMessage(content=user_input)]
result = self.graph.invoke({"messages": messages})
final_message = result["messages"][-1]
return final_message.content if hasattr(final_message, 'content') else str(final_message)
사용자 입력을 HumanMessage로 감싸 그래프에 넣고, 누적된 messages의 마지막 응답을 꺼낸다. 그 사이 agent ↔ tools 순환은 LangGraph가 알아서 돌린다.
| 사용자 입력 | LLM 판단 | 실행 경로 |
| "10과 5 더해줘" | add 필요 | agent → tools(add) → agent → 응답 |
| "지금 몇 시야?" | get_time 필요 | agent → tools(get_time) → agent → 응답 |
| "안녕?" | 도구 불필요 | agent → 응답 (tools 건너뜀) |
요약
도구를 MCP 서버로 외부화하고, 어댑터로 LangChain 도구 6개로 변환해 ⑤편 그래프에 그대로 결합했음. 자연어 한 줄이 MCP 도구 호출로 이어지는 데까지 묶었다.
| 항목 | 2~5편 (로컬 도구) | 6편 (MCP 연계) |
| 도구 위치 | 에이전트와 같은 프로세스 | 별도 MCP 서버 프로세스 |
| 도구 등록 | @tool 데코레이터 | 서버에서 list_tools로 수신 |
| 그래프 결합 | 함수를 직접 바인딩 | 어댑터가 StructuredTool로 변환 |
| 통신 | 함수 호출 | JSON-RPC (STDIO) |
| 결과 형태 | 반환값 그대로 | content 블록에서 text 추출 |
1~5편이 "한 그래프 안에서 능력을 키운" 과정이었다면, ⑥편은 도구를 그래프 밖 표준으로 빼냈다. 핵심은 어댑터다 — MCP의 느슨한 JSON 스키마와 LangChain의 엄격한 pydantic 검증 사이를 메우는 한 벌만 갖추면, 세상의 어떤 MCP 도구든 기존 에이전트에 그대로 꽂힌다. 에이전트가 복잡해진 만큼 "어디서 느리고 얼마를 쓰는지" 관측이 필요해지는데, 그건 다음 편 LangSmith에서 다룬다.
'SK플래닛 ai활용 데이터엔지니어 과정 2기 > ML & DL' 카테고리의 다른 글
| LangGraph 7 - LangSmith로 에이전트 관측하기 (0) | 2026.06.05 |
|---|---|
| LangGraph 5 - 멀티 에이전트 협업 (0) | 2026.06.04 |
| LangGraph 4 - RAG 에이전트 (0) | 2026.06.02 |
| LangGraph 3 - 단기기억 (0) | 2026.06.02 |
| LangGraph 2 - Tool + LLM (0) | 2026.06.02 |