LLM 엔지니어링10장 · MCP 와 Prompt·Context Engineering16 / 22← 이전목차다음 →MetaContents

10장. MCP 와 Prompt·Context Engineering

실습 3 — MCP 도구를 LLM 에 잇기

MCP 서버에서 받은 도구 목록을 9장의 도구 꼴로 바꿔 Ollama 의 qwen2.5:3b 에 넘기고, 모델이 고른 도구를 MCP 로 불러 답하게 합니다. 9장의 자율 루프와 같지만, 도구가 우리 코드가 아니라 MCP 서버에 있습니다.

1단계. MCP 도구를 OpenAI 꼴로 바꾸기

Python
import asyncio, json, os, sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen2.5:3b"
PARAMS = StdioServerParameters(command=sys.executable, args=["out_stock_server.py"])

def to_openai(mcp_tool):
    """MCP 도구(name, description, input_schema)를 9장의 tools 꼴로 바꾼다"""
    return {"type": "function", "function": {"name": mcp_tool.name, "description": mcp_tool.description,
                                             "parameters": mcp_tool.input_schema}}

async def peek():
    async with stdio_client(PARAMS, errlog=open(os.devnull, "w")) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            return [to_openai(t) for t in (await session.list_tools()).tools]

print(json.dumps(asyncio.run(peek())[0], ensure_ascii=False)[:200])
출력
{"type": "function", "function": {"name": "get_stock", "description": "품목 코드(예: P-101)의 창고별 재고 수량과 최소수량을 돌려준다.", "parameters": {"properties": {"code": {"title": "Code", "type": "string"}}, "required":

2단계. MCP 로 도구를 부르는 루프

Python
SYSTEM = ("당신은 솔내기술(가상 회사)의 사무용품 재고 도우미입니다. 재고는 반드시 도구로 확인하고, "
          "도구 결과에 있는 숫자만 말하세요. 품목 코드를 모르면 list_items 로 먼저 찾으세요.")

async def ask(question, max_steps=5):
    async with stdio_client(PARAMS, errlog=open(os.devnull, "w")) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = [to_openai(t) for t in (await session.list_tools()).tools]
            messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": question}]
            for step in range(1, max_steps + 1):
                msg = client.chat.completions.create(model=MODEL, messages=messages, tools=tools,
                                                     temperature=0, seed=42).choices[0].message
                if not msg.tool_calls:
                    return msg.content
                messages.append({"role": "assistant", "content": msg.content or "",
                                 "tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
                for tc in msg.tool_calls:
                    r = await session.call_tool(tc.function.name, json.loads(tc.function.arguments))   # MCP 로 실행
                    text = "\n".join(c.text for c in r.content)                    # 내용 조각을 모두 잇는다
                    print(f"  걸음 {step}: {tc.function.name}({tc.function.arguments}) → {text[:60]!r}")
                    messages.append({"role": "tool", "tool_call_id": tc.id, "content": text})
            return "[걸음 한도에 닿았습니다]"

print("답:", asyncio.run(ask("검정 토너(T-201)는 창고마다 몇 개 있어요?")))
출력
  걸음 1: get_stock({"code":"T-201"}) → '{\n  "품목": "검정 토너(SN-PRT 용)",\n  "창고별": [\n    {\n      "창고": "본'
답: 본사 창고에 검정 토너(T-201)는 3개, 물류 센터에 25개 있습니다. 최소 수량은 본사 창고에서 5개, 물류 센터에서 10개입니다.

3단계. 코드를 모르는 질문

Python
for q in ["본사 창고에 볼펜이 몇 개 있어요?", "탕비 품목에는 무엇이 있어요?"]:
    print("질문:", q)
    print("답:", (asyncio.run(ask(q)) or "").replace("\n", " ")[:120])
출력
질문: 본사 창고에 볼펜이 몇 개 있어요?
  걸음 1: get_stock({"code":"P-102"}) → '{\n  "품목": "A3 복사용지(250매)",\n  "창고별": [\n    {\n      "창고": "본사 '
답: 본사 창고에 볼펜은 8개 있습니다.
질문: 탕비 품목에는 무엇이 있어요?
  걸음 1: list_items({"category":"전자"}) → '{\n  "품목": [\n    {\n      "코드": "E-401",\n      "품목": "USB-C 충전'
답: 탕비 품목에는 USB-C 충전기(65W), 무선 마우스, HDMI 케이블(2m), 모니터(27인치)가 있습니다.

이 실습이 뜻하는 것

이 실습은 새로 온 비서에게 창구 이용법을 알려 주고 심부름을 시키는 일과 같습니다. 창구의 메뉴판을 비서가 읽는 양식으로 옮겨 적고(1단계), 비서가 질문을 듣고 어느 메뉴를 부를지 스스로 정해 창구에 다녀오게 한 뒤(2단계), 상품 번호를 모르는 질문도 맡겨 봅니다(3단계). 여기서 비서가 Ollama 의 qwen2.5:3b 모델, 양식 옮기기가 to_openai(), 창구를 오가는 반복이 9장의 자율 루프입니다. 비유와 다른 점은, 사람 비서라면 번호를 모를 때 목록부터 찾아보겠지만 이 작은 모델은 번호를 짐작해 버린다는 것입니다.

단계출력뜻
1. 꼴 바꾸기{"type": "function", "function": {"name": "get_stock", …MCP 도구가 9장에서 쓰던 도구 꼴로 바뀌었습니다. 스키마는 거의 그대로 옮겨졌습니다(출력은 앞 200글자만 보였습니다)
2. 코드를 아는 질문걸음 1: get_stock({"code":"T-201"})모델이 한 번에 맞는 도구와 코드를 골랐습니다
2. 답"본사 창고에 … 3개, 물류 센터에 25개"도구가 준 숫자 그대로 바르게 답했습니다
3. 볼펜get_stock({"code":"P-102"}) → "볼펜은 8개"볼펜 코드를 찾지 않고 P-102(A3 복사용지)로 짐작해, 복사용지 수량을 볼펜 수량이라고 틀리게 말했습니다
3. 탕비list_items({"category":"전자"}) → 충전기, 마우스 등목록 도구는 불렀지만 분류를 "전자"로 잘못 넣어, 전자 제품을 탕비 품목이라고 답했습니다

한 줄로 줄이면, MCP 도구를 LLM 에 잇는 일 자체는 꼴만 조금 바꾸면 되지만, 작은 모델은 정보가 모자랄 때 찾아보지 않고 짐작한다는 것을 보인 실습입니다. 세 질문 가운데 하나만 맞았고, 틀린 두 답도 말투는 자신 있었습니다. 질문 세 개로 본 결과라 모델의 실력을 숫자로 말할 수는 없습니다. 이름으로도 찾는 도구(search_items)는 프로젝트 1에서 만듭니다.

코드 설명

  • to_openai() 는 MCP 도구의 input_schema 를 그대로 parameters 로 옮깁니다. MCP 의 스키마와 OpenAI 의 도구 스키마가 같은 JSON 스키마라서 바꿀 것이 거의 없습니다.
  • 코드(T-201)를 알려 준 질문은 걸음 하나로 get_stock 을 불러, 본사 3개와 물류 센터 25개를 바르게 답했습니다.
  • 코드를 모르는 두 질문은 모두 틀렸습니다. "볼펜"에는 list_items 로 찾지 않고 코드를 P-102(A3 용지)로 짐작해 불러, A3 용지의 수량 8개를 "볼펜 8개"라고 답했습니다. "탕비"에는 list_items 를 불렀지만 분류를 "전자"로 넣어, 충전기와 마우스를 탕비 품목이라고 답했습니다. 지시문에 "코드를 모르면 list_items 로 먼저 찾으세요"라고 적었어도 작은 모델은 짐작으로 건너뜁니다. 9장의 워크플로처럼 코드가 먼저 찾아 주거나, 도구가 이름으로도 찾게(프로젝트의 search_items) 만드는 편이 낫습니다.
  • 질문마다 서버를 새로 띄우고 닫습니다(async with). 간단하지만 느리므로, 실제 앱에서는 연결을 한 번 열어 두고 여러 질문에 씁니다.
← 이전 절10장 · 실습 2 — 클라이언트로 도구와 리소스 쓰기다음 절 →10장 · 실습 4 — 프롬프트 다듬기