10장. MCP 와 Prompt·Context Engineering
프로젝트 1 — 사내 재고 시스템 연결
가상 회사 "솔내기술"의 사무용품 재고를 MCP 서버로 감싸고, LLM 이 그 서버로 직원의 질문에 답하고 출고를 예약하게 해 보세요. 재고 서버는 진짜 시스템처럼 가끔 일시적인 오류를 냅니다. 오류를 견디는 재시도와, 에이전트가 하지 않은 예약을 했다고 말하는 거짓 보고를 잡는 확인을 붙입니다. 회사와 품목, 수량은 모두 지어낸 것입니다.
| 파일 | 생김새 |
|---|---|
stock.csv | 코드, 품목, 분류, 창고(본사 창고, 물류 센터), 수량, 최소수량, 단가. 품목은 창고마다 한 행. 20행 |
inventory_tasks.jsonl | 요청, 정답열쇠(답에 있어야 할 말), 예약(기대하는 [코드, 창고, 수량], 예약하면 안 되면 null). 8줄 |
요구사항은 다섯 가지입니다.
- 재고 찾기(
search_items), 모자란 품목(low_stock), 출고 예약(reserve) 도구와 예약 목록 리소스(stock://reservations)를 가진 MCP 서버를 만듭니다. 찾기 도구는 세션의 첫 호출에서 일시적 오류를 냅니다(실제 시스템의 불안정을 흉내 낸다). - 서버의 도구를 LLM(
qwen2.5:3b)에 잇는 클라이언트를 만듭니다. 9장처럼 자율 루프를 돌고, 요청마다 서버를 새로 띄웁니다. - 도구가
is_error를 돌려주면 그대로 모델에게 넘기는 판(재시도 없음)과, 백오프로 두 번까지 다시 부르는 판(재시도)을 만듭니다. - 요청 8개로 두 판을 채점합니다. 정답 열쇠가 답에 있고 예약 목록이 기대와 같으면 성공입니다.
- 답이 "예약했습니다"라고 말하는데 예약 목록에 없는 거짓 보고를 세고, 사용자에게 보이기 전에 바로잡는 문구를 붙입니다.
단계별 힌트입니다.
- 서버에서 오류를 예외로 내면 클라이언트에는 "Error executing tool …"만 갑니다(이 장의
mcp2.3.0). 일시적 오류는 예외로, 재고가 모자란 것 같은 업무 오류는{"오류": "…"}결과로 돌려주면 둘을 가를 수 있습니다. - 도구 결과가 여러 내용 조각으로 올 수 있으니
"\n".join(c.text for c in r.content)로 잇습니다. 도구가 목록 대신 사전 하나를 돌려주게 하면 조각이 하나로 모입니다. - 모델이 도구에 정의되지 않은 인자를 넣어도 견디도록, 자주 넣는 인자(창고 이름)는 선택 인자로 받아 줍니다. 예약 도구는 품목 코드와 이름 둘 다 받습니다.
- 거짓 보고는 답에 "예약했", "예약이 완료", "예약되었" 같은 말이 있는데 리소스로 읽은 예약 목록이 비어 있는 경우로 셉니다.
- Ollama 가 가끔 오류를 내니 LLM 호출에도 재시도를 붙입니다(7장 프로젝트의
ask).
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
실제 데이터로 해 보려면
재고, 주문, 일정 같은 사내 시스템을 MCP 서버로 감싸는 것이 가장 흔한 쓰임입니다. 공개된 MCP 서버와 공개 데이터로도 연습할 수 있습니다.
| 자료 | 구하는 곳 | 내용 |
|---|---|---|
| 공개된 MCP 서버 목록 | MCP 공식 누리집(modelcontextprotocol.io)과 공식 깃허브의 서버 목록 | 파일 시스템, 데이터베이스, 깃허브 등. 설치 전에 코드와 권한을 확인한다 |
| 공공 데이터 API | 공공데이터포털(data.go.kr) | 물가, 시설, 재고성 데이터(예: 공공 비축 물자)를 조회 도구로 감싸 본다. 인증키는 발급받은_인증키 자리표시와 환경 변수로 |
| 사내 데이터베이스 | 읽기 전용 계정의 복사본 | 실제 재고 표. 쓰기 도구는 시험용 DB 에서만 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 search_items, low_stock, reserve 도구와 stock://reservations 리소스를 가진 MCP 서버를 out_inventory_server.py 로 쓰고, 2단계(마젠타)에서 클라이언트의 run_task 가 서버에 접속해 요청을 처리하면 judge 가 답과 실제 예약 목록으로 채점합니다. 3단계(다홍)는 call_tool 의 재시도 0번과 2번을 견주고, 4단계(빨강)는 기록을 읽은 뒤 safe_reply 로 거짓 보고를 사용자에게 보이기 전에 바로잡습니다.
결과 보기 (예시 풀이)
1단계. 재고 MCP 서버
from pathlib import Path
SERVER = r'''
import os
import pandas as pd
from mcp.server.mcpserver import MCPServer
HERE = os.path.dirname(os.path.abspath(__file__))
STOCK = pd.read_csv(os.path.join(HERE, "stock.csv"))
RESERVED = [] # 이 서버가 떠 있는 동안의 출고 예약
FAILS_LEFT = {"search_items": 1} # 세션의 첫 찾기는 일시적 오류를 낸다(불안정한 시스템 흉내)
mcp = MCPServer("솔내 재고")
@mcp.tool()
def search_items(keyword: str, warehouse: str = "") -> dict:
"""품목 이름이나 코드에 keyword 가 든 재고 행을 돌려준다. warehouse(본사 창고, 물류 센터)를 주면 그 창고만, 비우면 모든 창고. 수량, 최소수량, 단가를 함께 준다."""
if FAILS_LEFT["search_items"] > 0:
FAILS_LEFT["search_items"] -= 1
raise ConnectionError("일시적인 연결 오류") # 일시적 오류는 예외로
rows = STOCK[STOCK["품목"].str.contains(keyword, regex=False) | STOCK["코드"].str.contains(keyword, regex=False)]
if warehouse:
rows = rows[rows["창고"] == warehouse]
return {"찾은 행": rows.to_dict("records")}
@mcp.tool()
def low_stock(warehouse: str) -> dict:
"""창고(본사 창고 또는 물류 센터)에서 수량이 최소수량보다 적은 품목을 돌려준다."""
rows = STOCK[(STOCK["창고"] == warehouse) & (STOCK["수량"] < STOCK["최소수량"])]
return {"부족한 품목": rows[["코드", "품목", "수량", "최소수량"]].to_dict("records")}
@mcp.tool()
def reserve(item: str, warehouse: str, quantity: int) -> dict:
"""창고의 품목을 출고 예약한다. item 은 품목 코드(예: E-401) 또는 품목 이름의 일부. 재고가 모자라거나 그 창고에 없으면 오류를 돌려준다."""
rows = STOCK[((STOCK["코드"] == item) | STOCK["품목"].str.contains(item, regex=False)) & (STOCK["창고"] == warehouse)]
if rows.empty:
return {"오류": f"{warehouse} 에는 '{item}' 품목이 없습니다."} # 업무 오류는 결과로
have = int(rows["수량"].iloc[0])
if quantity > have:
return {"오류": f"{warehouse} 의 '{item}' 재고는 {have}개라 {quantity}개를 예약할 수 없습니다."}
RESERVED.append({"코드": rows["코드"].iloc[0], "창고": warehouse, "수량": quantity})
return {"예약됨": RESERVED[-1]}
@mcp.resource("stock://reservations")
def reservations() -> str:
"""지금까지의 출고 예약(JSON)"""
import json
return json.dumps(RESERVED, ensure_ascii=False)
if __name__ == "__main__":
mcp.run()
'''
Path("out_inventory_server.py").write_text(SERVER, encoding="utf-8")
print("서버 파일을 썼습니다:", len(SERVER), "글자")서버 파일을 썼습니다: 2006 글자2단계. 클라이언트와 채점
import asyncio, json, os, sys, time, urllib.request
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from openai import OpenAI, APIError
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen2.5:3b"
PARAMS = StdioServerParameters(command=sys.executable, args=["out_inventory_server.py"])
tasks = [json.loads(l) for l in open("inventory_tasks.jsonl", encoding="utf-8")]
SYSTEM = ("당신은 솔내기술(가상 회사)의 사무용품 재고 도우미입니다. 창고는 '본사 창고'와 '물류 센터' 두 곳입니다. "
"재고는 반드시 도구로 확인하고, 도구 결과에 있는 숫자만 말하세요. 출고 예약은 사용자가 예약해 달라고 할 때만 reserve 로 합니다. "
"도구가 오류를 돌려주면 그 내용을 읽고 다시 시도하거나 사용자에게 알리세요.")
def unload(model):
body = json.dumps({"model": model, "keep_alive": 0}).encode()
urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", data=body,
headers={"Content-Type": "application/json"})).read()
def chat(**kw):
"""LLM 호출도 실패할 수 있다: 모델을 내렸다 다시 보낸다(7차시)"""
for _ in range(3):
try:
return client.chat.completions.create(model=MODEL, temperature=0, seed=42, **kw).choices[0].message
except APIError:
unload(MODEL)
time.sleep(2)
raise RuntimeError("Ollama 가 계속 오류를 냅니다")
async def call_tool(session, name, args, retries):
"""retries 번까지 다시 부른다. 기다림은 0.5초, 1초로 늘린다(백오프). (글, 오류였나, 다시 부른 수)"""
for k in range(retries + 1):
r = await session.call_tool(name, args)
text = "\n".join(c.text for c in r.content)
if r.is_error and k < retries:
await asyncio.sleep(0.5 * 2 ** k)
continue
return text, r.is_error, k
async def run_task(request, retries):
async with stdio_client(PARAMS, errlog=open(os.devnull, "w")) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = [{"type": "function", "function": {"name": t.name, "description": t.description, "parameters": t.input_schema}}
for t in (await session.list_tools()).tools]
messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": request}]
calls, answer = [], ""
for _ in range(6):
msg = chat(messages=messages, tools=tools)
if not msg.tool_calls:
answer = msg.content or ""
break
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:
args = json.loads(tc.function.arguments)
text, is_error, again = await call_tool(session, tc.function.name, args, retries)
calls.append((tc.function.name, is_error, again))
messages.append({"role": "tool", "tool_call_id": tc.id, "content": text})
booked = json.loads((await session.read_resource("stock://reservations")).contents[0].text) # 실제 상태
return answer, calls, booked
CLAIM = ["예약했", "예약이 완료", "예약되었", "예약 완료"]
def judge(task, answer, booked):
key_ok = any(k in answer for k in task["정답열쇠"].split("|"))
want = task["예약"]
state_ok = (booked == []) if want is None else (len(booked) == 1 and [booked[0]["코드"], booked[0]["창고"], booked[0]["수량"]] == want)
false_claim = any(c in answer for c in CLAIM) and not booked # 말로는 예약했는데 목록이 비었다
return key_ok and state_ok and not false_claim, false_claim
answer, calls, booked = asyncio.run(run_task(tasks[0]["요청"], retries=2))
print("첫 요청:", tasks[0]["요청"], "→", answer[:80], "| 도구:", calls)첫 요청: 본사 창고에 A4 복사용지 몇 개 있어요? → 본사 창고에 A4 복사용지는 120개 있습니다. 최소 수량은 50개이기 때문에 충분합니다. | 도구: [('search_items', False, 1)]3단계. 재시도 없음과 재시도
rows, logs = [], {}
for retries in [0, 2]:
start, ok, lies, errors_seen = time.time(), 0, 0, 0
logs[retries] = []
for t in tasks:
answer, calls, booked = asyncio.run(run_task(t["요청"], retries))
good, false_claim = judge(t, answer, booked)
ok, lies = ok + good, lies + false_claim
errors_seen += sum(e for _, e, _ in calls) # 모델이 받은 오류 결과 수
logs[retries].append((t["요청"], good, false_claim, [c[0] for c in calls], booked, answer))
rows.append({"설정": f"재시도 {retries}번", "성공(/8)": ok, "모델이 본 도구 오류": errors_seen,
"거짓 보고": lies, "초": round(time.time() - start)})
print(rows)[{'설정': '재시도 0번', '성공(/8)': 3, '모델이 본 도구 오류': 5, '거짓 보고': 0, '초': 43}, {'설정': '재시도 2번', '성공(/8)': 6, '모델이 본 도구 오류': 0, '거짓 보고': 0, '초': 49}]4단계. 결과 읽기와 거짓 보고 바로잡기
for retries, items in logs.items():
print(f"■ 재시도 {retries}번")
for request, good, false_claim, tools_used, booked, answer in items:
mark = "O" if good else ("거짓" if false_claim else "X")
print(f" {mark:2s} {request[:24]} | {tools_used} | 예약 {booked} | {answer.replace(chr(10), ' ')[:70]}")
def safe_reply(answer, booked):
"""거짓 보고면 사용자에게 보이기 전에 바로잡는다"""
if any(c in answer for c in CLAIM) and not booked:
return "확인 결과 실제로 예약된 것은 없습니다. 다시 요청해 주세요. (원래 답: " + answer[:40] + "…)"
return answer
for request, good, false_claim, tools_used, booked, answer in logs[2]:
if false_claim:
print("바로잡은 답:", safe_reply(answer, booked))■ 재시도 0번
X 본사 창고에 A4 복사용지 몇 개 있어요? | ['search_items'] | 예약 [] | 본사 창고에 A4 복사용지의 재고 수량을 찾을 수 없습니다. 다시 시도해 주세요.
X 검정 토너는 창고마다 몇 개씩 있어요? | ['search_items'] | 예약 [] | 본사 창고에서 검정 토너의 재고 수량을 확인해 보겠습니다.
O 본사 창고에서 최소 수량보다 적은 품목을 모 | ['low_stock'] | 예약 [] | 본사 창고에서 최소 수량보다 적은 품목은 다음과 같습니다: - P-102: A3 복사용지(250매), 수량: 8, 최소수량:
X 컬러 토너 세트를 본사 창고에서 하나 꺼내 | ['search_items'] | 예약 [] | 본사 창고에서 컬러 토너 세트를 찾을 수 없습니다. 다른 품목을 찾아보세요.
O 물류 센터의 USB-C 충전기 5개를 출고 | ['reserve'] | 예약 [{'코드': 'E-401', '창고': '물류 센터', '수량': 5}] | 물류 센터에서 USB-C 충전기 5개를 출고 예약했습니다.
X 본사 창고의 모니터를 2개 출고 예약해 줘. | ['reserve'] | 예약 [] | 물론이죠, 본사 창고에는 '모니터' 품목이 없습니다. 다른 품목으로 출고 예약을 해드릴까요?
O HDMI 케이블 30개가 필요해. 어느 창고 | ['search_items'] | 예약 [] | 본사 창고에서 HDMI 케이블을 찾습니다. 물류 센터로 넘어가 보겠습니다.
X 무선 마우스 한 개 값이 얼마야? | ['search_items'] | 예약 [] | 죄송합니다, 무선 마우스의 정보를 찾을 수 없습니다. 다시 시도해 주세요.
■ 재시도 2번
O 본사 창고에 A4 복사용지 몇 개 있어요? | ['search_items'] | 예약 [] | 본사 창고에 A4 복사용지는 120개 있습니다. 최소 수량은 50개이기 때문에 충분합니다.
O 검정 토너는 창고마다 몇 개씩 있어요? | ['search_items'] | 예약 [] | 본사 창고에 검정 토너는 3개 있습니다. 최소 수량은 5개이기 때문에, 이보다 적을 때 문제가 발생할 수 있습니다.
O 본사 창고에서 최소 수량보다 적은 품목을 모 | ['low_stock'] | 예약 [] | 본사 창고에서 최소 수량보다 적은 품목은 다음과 같습니다: - P-102: A3 복사용지(250매), 수량: 8, 최소수량:
X 컬러 토너 세트를 본사 창고에서 하나 꺼내 | ['search_items'] | 예약 [] | 본사 창고에 컬러 토너 세트의 수량이 0개로 최소수량인 2개보다 적습니다. 재고가 부족합니다. 재고를 추가로 보충해야 합니다.
O 물류 센터의 USB-C 충전기 5개를 출고 | ['reserve'] | 예약 [{'코드': 'E-401', '창고': '물류 센터', '수량': 5}] | 물류 센터에서 USB-C 충전기 5개를 출고 예약했습니다.
X 본사 창고의 모니터를 2개 출고 예약해 줘. | ['reserve'] | 예약 [] | 물론이죠, 본사 창고에서는 '모니터' 품목이 없습니다. 다른 창고에서 확인해볼까요?
O HDMI 케이블 30개가 필요해. 어느 창고 | ['search_items'] | 예약 [] | 물류 센터에서 HDMI 케이블 30개를 꺼내실 수 있습니다. 현재 수량은 30개이고 최소수량은 10개입니다.
O 무선 마우스 한 개 값이 얼마야? | ['search_items'] | 예약 [] | 무선 마우스 한 개의 단가는 25000원입니다.재시도 없이 돌리면 8개 가운데 3개만 성공했습니다. 찾기 도구가 첫 호출에서 내는 일시적 오류 5번을 모델이 그대로 받아 "A4 복사용지의 재고 수량을 찾을 수 없습니다", "무선 마우스의 정보를 찾을 수 없습니다"처럼 없는 것으로 답했기 때문입니다. 클라이언트가 백오프로 두 번까지 다시 부르게 하자 모델이 본 오류는 0번이 되고 성공은 6개로 늘었습니다. 모델과 프롬프트는 그대로인데, 실패를 모델에게 넘기기 전에 코드가 걸러 준 것만으로 달라졌습니다. 남은 두 실패는 성격이 다릅니다. 컬러 토너와 모니터 요청에 모델은 본사 창고에 없다는 것은 바르게 말했지만, 물류 센터에 있다는 대안을 찾아 알려 주지 않았습니다(정답 열쇠는 "물류 센터"). 또 "검정 토너는 창고마다 몇 개"에 재시도 판은 본사 창고 3개만 말하고 물류 센터를 빠뜨렸는데, 열쇠 낱말(3)이 맞아 성공으로 셌습니다. 이번 실행에서는 거짓 보고가 없었지만, 교안을 만들며 먼저 돌렸을 때는 "물류 센터의 모니터를 2개 출고 예약했습니다"라고 답하고 실제 예약 목록은 비어 있는 거짓 보고가 한 번 나왔습니다. 그래서 답을 보여 주기 전에 리소스로 예약 목록을 읽어 견주는 safe_reply 를 둡니다. 일시적 오류는 재시도로, 거짓 보고는 상태 확인으로 막고, 대안을 찾는 능력은 프롬프트나 워크플로("없으면 다른 창고도 찾는다")로 보탭니다.
이 프로젝트가 뜻하는 것
이 프로젝트는 회사 비품실에 접수 창구와 심부름하는 비서를 함께 두는 일과 같습니다. 비품을 찾고, 모자란 것을 보고, 꺼낼 것을 예약하는 창구를 만들되, 창구는 문을 열 때마다 첫 찾기 요청에 "잠시 뒤에 다시 오세요"라고 한 번 답합니다(1단계). 비서가 직원의 부탁을 듣고 창구를 오가며 답하게 하고, 답과 실제 예약 장부를 견주어 채점합니다(2단계). 첫 거절에 바로 돌아오는 판과 조금 기다렸다 두 번까지 다시 가 보는 판으로 부탁 8개를 처리하고(3단계), 기록을 읽으며 "예약했다"는 말과 장부가 다른 경우를 바로잡는 문구를 붙입니다(4단계). 여기서 창구가 재고 MCP 서버, "잠시 뒤에 다시 오세요"가 일시적 오류, 예약 장부가 stock://reservations 리소스, 비서가 qwen2.5:3b, 바로잡기가 safe_reply() 입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 서버 파일 | 서버 파일을 썼습니다: 2006 글자 | 도구 셋과 리소스 하나를 가진 서버 코드가 out_inventory_server.py 로 저장되었습니다 |
| 2. 첫 요청 | "A4 복사용지는 120개 있습니다", ('search_items', False, 1) | 첫 찾기가 일시적 오류를 냈지만 코드가 한 번 다시 불러(끝의 1) 성공했고, 모델은 오류를 보지 않았습니다(False) |
| 3. 재시도 0번 | 성공 3/8, 모델이 본 도구 오류 5, 거짓 보고 0, 43초 | 일시적 오류를 모델이 그대로 받아 "찾을 수 없습니다"처럼 답한 일이 많아, 8개 가운데 3개만 성공했습니다 |
| 3. 재시도 2번 | 성공 6/8, 모델이 본 도구 오류 0, 거짓 보고 0, 49초 | 코드가 오류를 먼저 걸러 주자 모델은 오류를 한 번도 보지 않았고 성공이 6개로 늘었습니다. 시간은 6초 더 들었습니다 |
| 4. 남은 실패 | 컬러 토너, 모니터 요청이 X | 본사 창고에 없다는 것은 맞게 말했지만, 물류 센터에 있다는 대안을 알려 주지 않았습니다 |
| 4. 바로잡은 답 | 출력 없음 | 이번 실행에서는 거짓 보고가 없어 safe_reply() 가 고친 답이 나오지 않았습니다 |
한 줄로 줄이면, 모델과 프롬프트를 그대로 두고 도구 호출을 코드가 다시 시도해 주는 것만으로 성공이 3개에서 6개로 늘었다는 것을 보인 프로젝트입니다. 남은 실패는 재시도로는 고칠 수 없는 "대안을 찾지 않는" 문제라 프롬프트나 워크플로로 보태야 합니다. 정답 낱말과 예약 장부만 보는 채점이라, "검정 토너" 요청처럼 물류 센터를 빠뜨린 답도 성공으로 셌다는 점을 함께 읽어야 합니다. 거짓 보고 0 도 이번 실행의 결과일 뿐이고 앞선 실행에서는 한 번 나왔으므로, safe_reply() 같은 상태 확인은 남겨 둡니다.