5장. LLM 외부 도구 연동
실습 4 — 상품 자료 MCP 서버 만들고 붙이기
구름다리여행의 상품 표와 공지 파일을 읽는 MCP 서버를 FastMCP 로 만들어 out_ch05_server.py 로 저장하고, MultiServerMCPClient 로 띄워 도구 목록을 받고 도구를 불러 봅니다. 서버는 data 폴더의 공유 목록에 적은 세 파일(trip_products.csv, trip_notice.txt, faq.csv)만 읽고, 폴더 밖의 경로는 거절합니다. 파일은 모두 연습용 가상 데이터입니다. 모델은 부르지 않으므로 API 키가 없어도 됩니다. 실습 5와 프로젝트가 이 실습이 만든 서버 파일을 씁니다.
1단계. 서버 파일 쓰기
from pathlib import Path
# 서버 코드를 글로 적어 파일로 저장한다. r''' ''' 는 \n 같은 글자를 그대로 두는 글자열이다
SERVER_CODE = r'''
"""구름다리여행 자료 MCP 서버 (5차시 실습 4가 만든 파일)"""
from pathlib import Path
import pandas as pd
from mcp.server.fastmcp import FastMCP
# 이 파일이 있는 폴더(data)만 도구가 볼 수 있는 곳으로 정한다
BASE = Path(__file__).resolve().parent
# 그 폴더 안에서도 이 목록에 적은 파일만 내준다
SHARED = ["trip_products.csv", "trip_notice.txt", "faq.csv"]
mcp = FastMCP("trip-data")
@mcp.tool()
def list_files() -> str:
"""이 서버가 읽어 줄 수 있는 파일의 이름 목록을 돌려준다."""
return ", ".join(SHARED)
@mcp.tool()
def read_file(filename: str, max_lines: int = 5) -> str:
"""공유한 파일 하나를 앞에서부터 max_lines 줄까지 읽어 돌려준다. filename 은 list_files 가 알려 준 이름이다."""
path = (BASE / filename).resolve()
# 1겹: data 폴더 밖을 가리키는 경로(../.env, C:/Windows/...)는 거절한다
if not path.is_relative_to(BASE):
return f"거절: 허락된 폴더 밖의 파일입니다 ({filename})"
# 2겹: 폴더 안이라도 공유 목록에 없는 파일은 거절한다
if path.name not in SHARED:
return f"거절: 공유 목록에 없는 파일입니다 ({filename})"
lines = path.read_text(encoding="utf-8-sig").splitlines()
return "\n".join(lines[:max_lines])
@mcp.tool()
def find_products(region: str = "", max_price: int = 0) -> str:
"""구름다리여행 상품을 찾는다. region 은 지역 이름(예: 제주, 부산), max_price 는 어른 1명 가격의 상한(만 원)이다. 0 이면 따지지 않는다."""
df = pd.read_csv(BASE / "trip_products.csv")
if region:
df = df[df["지역"] == region]
if max_price > 0:
df = df[df["가격"] <= max_price]
if df.empty:
return "조건에 맞는 상품이 없습니다."
return "\n".join(
f"{r.상품코드} {r.상품명} | {r.가격}만 원 | 남은 자리 {r.남은자리} | 출발 {r.출발일}" for r in df.itertuples()
)
if __name__ == "__main__":
# 기본 전송 방식은 stdio: 표준 입력으로 요청을 받고 표준 출력으로 답한다
mcp.run()
'''
Path("out_ch05_server.py").write_text(SERVER_CODE.lstrip(), encoding="utf-8")
print("저장: out_ch05_server.py,", len(SERVER_CODE.strip().splitlines()), "줄")저장: out_ch05_server.py, 52 줄코드가 의미하는 것 SERVER_CODE 를 out_ch05_server.py 로 저장하는 것은 챗봇과 따로 도는 서버 프로그램을 하나 만드는 일입니다. 파일 안의 FastMCP("trip-data"), @mcp.tool(), mcp.run() 이 "FastMCP 서버의 세 걸음"이고, read_file 안의 resolve()·is_relative_to·SHARED 가 "도구를 맡길 때의 안전" 절의 두 겹 검사입니다. BASE 를 Path(__file__) 로 정한 것은 서버를 어디서 켜든 서버 파일이 있는 data 폴더만 보게 하려는 것입니다.
2단계. 서버를 띄워 도구 목록 받기
import asyncio
import json
import sys
from langchain_mcp_adapters.client import MultiServerMCPClient
# 서버 이름 "trip": 지금 쓰는 파이썬(sys.executable)으로 out_ch05_server.py 를 켜고 stdio 로 잇는다
client = MultiServerMCPClient({
"trip": {"command": sys.executable, "args": ["out_ch05_server.py"], "transport": "stdio"},
})
async def main():
# get_tools: 서버를 켜서 도구 목록(tools/list)을 받아 LangChain 도구로 바꾼다
tools = await client.get_tools()
for t in tools:
print(t.name, "|", type(t).__name__)
print(" 설명:", t.description)
print(" 인자:", {k: v["type"] for k, v in t.args.items()})
return tools
mcp_tools = asyncio.run(main())
info = [{"name": t.name, "description": t.description, "args": t.args} for t in mcp_tools]
Path("out_ch05_mcp_tools.json").write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
print("저장: out_ch05_mcp_tools.json")list_files | StructuredTool
설명: 이 서버가 읽어 줄 수 있는 파일의 이름 목록을 돌려준다.
인자: {}
read_file | StructuredTool
설명: 공유한 파일 하나를 앞에서부터 max_lines 줄까지 읽어 돌려준다. filename 은 list_files 가 알려 준 이름이다.
인자: {'filename': 'string', 'max_lines': 'integer'}
find_products | StructuredTool
설명: 구름다리여행 상품을 찾는다. region 은 지역 이름(예: 제주, 부산), max_price 는 어른 1명 가격의 상한(만 원)이다. 0 이면 따지지 않는다.
인자: {'region': 'string', 'max_price': 'integer'}
저장: out_ch05_mcp_tools.json코드가 의미하는 것 await client.get_tools() 를 부르면 클라이언트가 sys.executable 로 서버 파일을 켜고, stdio 로 도구 목록을 받아 LangChain 도구로 바꿉니다. 출력의 StructuredTool 은 이 도구들이 @tool 로 만든 도구와 같은 자리(bind_tools, 에이전트)에 들어갈 수 있다는 뜻입니다. 받은 목록을 out_ch05_mcp_tools.json 으로 남기면 챗봇이 이 서버에서 무엇을 빌려 왔는지 파일로 확인할 수 있습니다.
3단계. 도구를 불러 자료 읽기
def text_of(result):
"""MCP 도구의 결과(글 조각 목록)에서 글만 꺼내 잇는다. 조각의 id 는 부를 때마다 바뀌어 버린다"""
if isinstance(result, str):
return result
return "\n".join(block["text"] for block in result if block.get("type") == "text")
async def main():
tools = {t.name: t for t in await client.get_tools()}
# MCP 도구는 비동기라 invoke 대신 ainvoke 를 await 로 부른다
raw = await tools["find_products"].ainvoke({"region": "제주", "max_price": 40})
print("결과의 모양:", type(raw).__name__, len(raw), "조각,", list(raw[0]))
print(text_of(raw))
print("---")
print(text_of(await tools["list_files"].ainvoke({})))
print("---")
print(text_of(await tools["read_file"].ainvoke({"filename": "trip_notice.txt"})))
asyncio.run(main())결과의 모양: list 1 조각, ['type', 'text', 'id']
P01 제주 동쪽 바다 2박 3일 | 39만 원 | 남은 자리 6 | 출발 2026-12-05
P12 제주 감귤 체험 1박 2일 | 24만 원 | 남은 자리 8 | 출발 2027-01-23
---
trip_products.csv, trip_notice.txt, faq.csv
---
구름다리여행 공지 (연습용 가상 문서)
1. 아동(만 2~12세 미만) 요금은 어른 요금의 80% 입니다.
2. 남은 자리가 0 인 매진 상품은 홈페이지에서 대기 신청을 받습니다.
3. 모든 상품은 출발 3일 전에 확정 문자를 보냅니다.
4. 상품 가격은 어른 1명 기준이며 단위는 만 원입니다.코드가 의미하는 것 ainvoke 는 MCP 도구를 비동기로 부르는 방법이고, 부를 때마다 클라이언트가 서버에 도구 호출 요청을 보냅니다. 결과가 글 하나가 아니라 글 조각 목록으로 오는 것은 "호스트·클라이언트·서버의 짜임" 절에서 본 답의 content 와 같은 꼴이고, text_of() 가 그 목록에서 글만 꺼내 잇습니다. 실습 1의 find_products 와 같은 결과가 나온 것은 같은 표를 같은 조건으로 걸렀기 때문입니다.
4단계. 폴더 밖 경로가 거절되는지 보기
async def main():
tools = {t.name: t for t in await client.get_tools()}
tries = ["faq.csv", "../.env", "../content/lesson01.md", "C:/Windows/win.ini", "staff_memo.txt"]
for name in tries:
answer = text_of(await tools["read_file"].ainvoke({"filename": name, "max_lines": 2}))
# 허락된 파일은 첫 줄만 보여 준다
print(f"{name:24} → {answer.splitlines()[0]}")
asyncio.run(main())faq.csv → 의도,질문,답
../.env → 거절: 허락된 폴더 밖의 파일입니다 (../.env)
../content/lesson01.md → 거절: 허락된 폴더 밖의 파일입니다 (../content/lesson01.md)
C:/Windows/win.ini → 거절: 허락된 폴더 밖의 파일입니다 (C:/Windows/win.ini)
staff_memo.txt → 거절: 공유 목록에 없는 파일입니다 (staff_memo.txt)코드가 의미하는 것 이 단계는 모델이 손님 말에 휘둘려 위험한 경로를 넣는 경우를 우리가 먼저 해 보는 시험입니다. read_file 에 넣은 다섯 이름 가운데 넷이 서버 안의 검사에서 "거절: …" 글로 돌아왔으므로, 모델이 무엇을 요청하든 서버가 내주는 범위는 공유 목록의 세 파일을 넘지 않습니다. max_lines=2 는 시험에 필요한 만큼만 읽게 줄인 것입니다.
이 실습이 뜻하는 것
이 실습은 작은 자료실을 차리고 문지기에게 "이 세 서류만 꺼내 주라"고 일러 둔 뒤, 직원을 보내 자료 목록을 받아 오고 서류를 꺼내 오고, 일부러 금고 속 서류를 달라고 해 보는 일과 같습니다. 자료실을 차리고(1단계), 직원이 목록을 받아 오고(2단계), 서류를 꺼내 오고(3단계), 꺼내면 안 되는 서류를 달라고 합니다(4단계). 여기서 자료실이 out_ch05_server.py, 문지기가 read_file 안의 경로 검사, 직원이 MultiServerMCPClient 입니다. 비유와 다른 점은, 이 자료실은 직원이 올 때마다 문을 열고 일이 끝나면 닫는다는 것입니다. 클라이언트는 get_tools() 와 도구 호출 때마다 서버 프로그램을 켜고 끕니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 서버 파일 | out_ch05_server.py, 52 줄 | 서버 프로그램 하나가 파일로 생겼습니다 |
| 2. 도구 목록 | 도구 셋, 모두 StructuredTool | MCP 도구가 @tool 과 같은 종류의 LangChain 도구로 바뀌었습니다. docstring 이 설명, 타입 힌트가 인자가 됐습니다 |
| 3. 결과의 모양 | list 1 조각, ['type', 'text', 'id'] | 결과는 글 조각의 목록으로 옵니다. 그래서 text_of() 로 글만 꺼냅니다 |
| 3. 상품과 공지 | P01·P12, 공지 5줄 | 실습 1의 @tool 과 같은 상품이 나왔고, 공지는 max_lines 기본값 5 줄까지 읽었습니다 |
| 4. 거절 | 허락 하나, 거절 넷 | 폴더 밖 셋(../.env, ../content/lesson01.md, C:/Windows/win.ini)과 공유 목록에 없는 하나가 막혔습니다 |
한 줄로 줄이면, FastMCP 로 만든 서버 파일 하나를 클라이언트가 켜고 붙으면 서버의 함수가 LangChain 도구가 되어 @tool 과 똑같이 쓰인다는 것을 본 실습입니다. 같은 "제주, 40만 원 이하" 조건에 실습 1과 같은 P01, P12 가 나왔습니다. 다른 점은 결과가 글 하나가 아니라 글 조각 목록으로 온다는 것과, 조각마다 실행할 때마다 바뀌는 id 가 붙는다는 것입니다. 4단계에서 .. 으로 과목 폴더의 .env 와 원고 파일을 가리킨 경로, C:/Windows 의 파일은 실제 위치가 data 폴더 밖이라 거절됐고, staff_memo.txt 는 data 폴더 안을 가리키지만 공유 목록에 없어 거절됐습니다. 실습 5에서 이 서버를 에이전트에 붙입니다.
코드 설명
SERVER_CODE는 서버 프로그램 전체를 담은 글입니다. 실습 코드가 이 글을out_ch05_server.py로 저장하고, 클라이언트가 그 파일을 따로 켭니다. 서버와 클라이언트는 서로 다른 프로그램입니다.MultiServerMCPClient({"trip": {...}})의command와args는 "어떤 명령으로 서버를 켤지"입니다. 터미널에서python out_ch05_server.py를 치는 것과 같습니다.await client.get_tools()는 서버를 켜고 도구 목록을 받아 LangChain 도구(StructuredTool)로 바꿉니다.@tool로 만든 도구와 같은 종류라서bind_tools와 에이전트에 그대로 넣을 수 있습니다.await 도구.ainvoke({...})는 비동기로 도구를 부릅니다. 결과는[{"type": "text", "text": …, "id": …}]꼴의 글 조각 목록이라text_of()로 글만 꺼냅니다.- 서버의
read_file은resolve()와is_relative_to(BASE)로 폴더 밖을 막고,SHARED목록으로 한 번 더 거릅니다. 거절할 때 오류를 내지 않고 "거절: …" 글을 돌려주어 모델이 읽고 손님에게 설명할 수 있게 했습니다.