LangChain 챗봇5장 · LLM 외부 도구 연동17 / 21← 이전목차다음 →MetaContents

5장. LLM 외부 도구 연동

실습 4 — 상품 자료 MCP 서버 만들고 붙이기

구름다리여행의 상품 표와 공지 파일을 읽는 MCP 서버를 FastMCP 로 만들어 out_ch05_server.py 로 저장하고, MultiServerMCPClient 로 띄워 도구 목록을 받고 도구를 불러 봅니다. 서버는 data 폴더의 공유 목록에 적은 세 파일(trip_products.csv, trip_notice.txt, faq.csv)만 읽고, 폴더 밖의 경로는 거절합니다. 파일은 모두 연습용 가상 데이터입니다. 모델은 부르지 않으므로 API 키가 없어도 됩니다. 실습 5와 프로젝트가 이 실습이 만든 서버 파일을 씁니다.

1단계. 서버 파일 쓰기

Python
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단계. 서버를 띄워 도구 목록 받기

Python
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단계. 도구를 불러 자료 읽기

Python
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단계. 폴더 밖 경로가 거절되는지 보기

Python
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. 도구 목록도구 셋, 모두 StructuredToolMCP 도구가 @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 목록으로 한 번 더 거릅니다. 거절할 때 오류를 내지 않고 "거절: …" 글을 돌려주어 모델이 읽고 손님에게 설명할 수 있게 했습니다.
← 이전 절5장 · 실습 3 — bind_tools 로 진짜 모델에게 도구 쥐여 주기다음 절 →5장 · 실습 5 — MCP 도구를 쓰는 에이전트