Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

도구

이 노트북은 로컬 대형 언어 모델(Ollama)이 외부 도구를 호출하는 과정을 세 단계로 나누어 발전시킵니다. 같은 도서 검색 도구(search_books)를 FastAPI → MCP 서버(자동 변환) → MCP 클라이언트 순서로 표준화해 가며, 각 단계가 무엇을 자동화하는지 비교합니다.

MCP(Model Context Protocol)는 앤트로픽이 주도한 개방형 표준 프로토콜로, 로컬 LLM이 호스트 환경의 다양한 리소스와 도구에 안전하고 규격화된 방식으로 상호작용하도록 중재합니다.

단계하는 일표준화되는 것
1단계 FastAPI도구를 REST 엔드포인트로 노출하고, 도구 스키마·호출을 모두 손으로 연결(없음, 전부 수작업 글루)
2단계 MCP 서버기존 FastAPI 앱을 fastmcp로 MCP 서버로 자동 변환, 도구·스키마는 엔드포인트에서 도출도구 인터페이스와 스키마
3단계 MCP 클라이언트서버에 접속해 도구를 동적으로 발견하고 호출도구 발견과 호출, 서버 교체

이 실습에 필요한 패키지를 먼저 설치합니다.

!uv pip install fastapi "uvicorn[standard]" httpx fastmcp nest-asyncio

1공통 준비: 도서 데이터베이스 구축

세 단계가 공통으로 검색할 대상 데이터베이스(books.db)를 먼저 만듭니다. 임시 도서 테이블을 만들고 샘플 도서 세 권을 입력합니다.

import sqlite3

def init_db():
    conn = sqlite3.connect("books.db")
    cursor = conn.cursor()

    cursor.execute("""
    CREATE TABLE IF NOT EXISTS books (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        author TEXT NOT NULL,
        category TEXT NOT NULL,
        summary TEXT
    )
    """)

    sample_books = [
        ("딥러닝 언어 모델 이론", "이성주", "인공지능", "트랜스포머 아키텍처부터 사전훈련 모델 활용까지를 다룹니다."),
        ("Ollama로 시작하는 로컬 LLM", "김철수", "인공지능", "초보자를 위한 Ollama 환경 설정과 API 연동 가이드."),
        ("파이썬 알고리즘 프로그래밍", "박영희", "컴퓨터공학", "자료구조와 코딩테스트 기출문제 풀이.")
    ]

    cursor.execute("SELECT COUNT(*) FROM books")
    if cursor.fetchone()[0] == 0:
        cursor.executemany("""
        INSERT INTO books (title, author, category, summary)
        VALUES (?, ?, ?, ?)
        """, sample_books)
        conn.commit()
        print("books.db에 샘플 데이터 입력이 성공적으로 수행되었습니다.")
    else:
        print("books.db 데이터베이스가 이미 존재하며 데이터가 채워져 있습니다.")

    conn.close()

init_db()

21단계: FastAPI로 도구를 REST로 노출

MCP를 도입하기 전의 모습을 먼저 봅니다. 도서 검색 기능을 평범한 FastAPI 엔드포인트(GET /search)로 노출합니다.

%%writefile 매직 커맨드로 서버 코드를 파일로 저장합니다.

%%writefile book_api.py
import sqlite3
from fastapi import FastAPI

app = FastAPI(title="Book API")

def query_books(query: str) -> str:
    conn = sqlite3.connect("books.db")
    cursor = conn.cursor()
    cursor.execute(
        "SELECT title, author, category, summary FROM books WHERE title LIKE ? OR category LIKE ?",
        (f"%{query}%", f"%{query}%"),
    )
    rows = cursor.fetchall()
    conn.close()

    if not rows:
        return f"'{query}'에 대한 도서 검색 결과가 없습니다."

    result_str = "[도서 검색 결과]\n"
    for title, author, category, summary in rows:
        result_str += f"- 제목: {title} | 저자: {author} | 분류: {category}\n  요약: {summary}\n"
    return result_str.strip()

@app.get(
    "/search",
    operation_id="search_books",
    description="도서 데이터베이스에서 제목 또는 카테고리에 검색어가 포함된 책을 찾습니다. "
                "query에는 사용자 질문의 한국어 키워드를 그대로 넣습니다(영어로 번역하지 않습니다).",
)
def search(query: str):
    return {"result": query_books(query)}

저장한 FastAPI 앱을 별도 프로세스로 띄우고, 응답할 준비가 될 때까지 기다립니다. 바로 아래 1단계 예시에서 이 서버의 /search 엔드포인트를 호출합니다(2단계부터는 이 REST 서버[8000 포트] 없이 book_api.py의 앱을 in-process로 불러 쓰므로, 1단계 예시를 마친 뒤에는 이 프로세스를 종료해도 됩니다).

import sys
import time
import subprocess
import httpx

server = subprocess.Popen(
    [sys.executable, "-m", "uvicorn", "book_api:app", "--port", "8000"],
    stdout=subprocess.DEVNULL,
    stderr=subprocess.DEVNULL,
)

# 서버가 응답할 때까지 최대 15초 대기
for _ in range(30):
    try:
        httpx.get("http://localhost:8000/openapi.json", timeout=1)
        print("FastAPI 서버가 준비되었습니다: http://localhost:8000")
        break
    except Exception:
        time.sleep(0.5)
else:
    print("서버 시작에 실패했습니다. 포트 충돌 여부를 확인하세요.")

이제 Ollama /api/chat을 직접 호출해 이 REST 도구를 사용해 봅니다. 도구 스키마를 손으로 정의하고, 모델이 도구 호출을 요청하면 도구 이름을 보고 해당 FastAPI 엔드포인트를 직접 부릅니다.

import os
import httpx

# Ollama 서버 주소. localhost가 아니면(예: WSL에서 Windows 호스트) OLLAMA_HOST로 지정합니다.
OLLAMA_API_URL = f"{os.getenv('OLLAMA_HOST', 'http://localhost:11434')}/api/chat"
MODEL_NAME = "qwen3:latest"  # 도구 호출(tool calling)을 지원하는 모델이 필요합니다. gemma 계열은 미지원이므로 qwen3를 사용합니다.
BOOK_API_URL = "http://localhost:8000"

# 도구 스키마를 손으로 정의합니다 (Ollama function-calling 형식).
tools = [{
    "type": "function",
    "function": {
        "name": "search_books",
        "description": "도서 데이터베이스에서 제목 또는 카테고리에 쿼리가 포함된 도서를 검색합니다.",
        "parameters": {
            "type": "object",
            "properties": {"query": {"type": "string", "description": "검색어"}},
            "required": ["query"],
        },
    },
}]

# 도구 이름을 보고 해당 REST 엔드포인트로 직접 연결합니다 (수작업 디스패치).
def call_tool(name, arguments):
    if name == "search_books":
        response = httpx.get(f"{BOOK_API_URL}/search", params={"query": arguments["query"]}, timeout=30)
        return response.json()["result"]
    return f"알 수 없는 도구입니다: {name}"

def chat(messages, with_tools=False):
    payload = {"model": MODEL_NAME, "messages": messages, "stream": False}
    if with_tools:
        payload["tools"] = tools
    response = httpx.post(OLLAMA_API_URL, json=payload, timeout=120)
    return response.json()["message"]

messages = [{"role": "user", "content": "인공지능 관련 도서를 데이터베이스에서 추천해줘."}]

message_obj = chat(messages, with_tools=True)
if message_obj.get("tool_calls"):
    tool_call = message_obj["tool_calls"][0]["function"]
    print(f"[도구 호출] {tool_call['name']}({tool_call['arguments']})")

    result_text = call_tool(tool_call["name"], tool_call["arguments"])
    messages.append(message_obj)
    messages.append({"role": "tool", "name": tool_call["name"], "content": result_text})

    final_message = chat(messages)
    print(f"\n[최종 답변]\n{final_message['content']}")
else:
    print(f"[도구 미사용]\n{message_obj['content']}")

여기까지가 표준 프로토콜 없이 도구를 붙이는 방식입니다. 도구 스키마를 손으로 적고, 도구 이름을 엔드포인트에 직접 매핑하고, 호출 결과를 메시지에 다시 끼워 넣는 연결 코드를 우리가 모두 작성했습니다. 이 가운데 도구를 정의하고 노출·발견하는 부분을 MCP가 표준화합니다(스키마를 손으로 적거나 이름을 직접 매핑하는 일이 사라집니다). 다만 모델에게 도구 목록을 넘기고 호출 결과를 다시 메시지에 끼워 넣는 LLM 쪽 연결 코드는 3단계에서도 여전히 우리가 작성한다는 점을 미리 염두에 두세요.

32단계: 같은 파일을 MCP 서버로도 노출

1단계에서는 도구 스키마와 디스패치를 전부 손으로 작성했습니다. 여기서는 별도 서버 파일을 만들지 않고, 1단계의 book_api.pyfastmcpFastMCP.from_fastapi로 같은 앱을 MCP 서버로 노출하는 코드를 더합니다. 1단계 코드는 그대로 두고 끝에 MCP 노출·실행 블록만 더한 전체 파일을 다시 씁니다. MCP 서버 객체는 이 파일을 직접 실행할 때만 필요하므로 if __name__ == "__main__": 안에서 만듭니다(uvicorn으로 REST만 띄울 때는 만들어지지 않습니다).

%%writefile book_api.py
import os
import sqlite3
from fastapi import FastAPI
from fastmcp import FastMCP   # 'pip install fastmcp' 패키지 (아래 note 참고)

app = FastAPI(title="Book API")

def query_books(query: str) -> str:
    conn = sqlite3.connect("books.db")
    cursor = conn.cursor()
    cursor.execute(
        "SELECT title, author, category, summary FROM books WHERE title LIKE ? OR category LIKE ?",
        (f"%{query}%", f"%{query}%"),
    )
    rows = cursor.fetchall()
    conn.close()

    if not rows:
        return f"'{query}'에 대한 도서 검색 결과가 없습니다."

    result_str = "[도서 검색 결과]\n"
    for title, author, category, summary in rows:
        result_str += f"- 제목: {title} | 저자: {author} | 분류: {category}\n  요약: {summary}\n"
    return result_str.strip()

@app.get(
    "/search",
    operation_id="search_books",
    description="도서 데이터베이스에서 제목 또는 카테고리에 검색어가 포함된 책을 찾습니다. "
                "query에는 사용자 질문의 한국어 키워드를 그대로 넣습니다(영어로 번역하지 않습니다).",
)
def search(query: str):
    return {"result": query_books(query)}

# 위 FastAPI 앱은 uvicorn으로 그대로 REST 서빙할 수 있고,
# 이 파일을 직접 실행할 때만 같은 앱을 MCP 서버로 변환해 띄웁니다.
if __name__ == "__main__":
    mcp = FastMCP.from_fastapi(app=app)   # 엔드포인트 → MCP 도구 자동 변환

    # 기본은 stdio(로컬). 배포 시 MCP_TRANSPORT=http 로 주면 원격용 HTTP 서버로 실행됩니다.
    if os.getenv("MCP_TRANSPORT") == "http":
        mcp.run(transport="http", host="0.0.0.0", port=int(os.getenv("MCP_PORT", "8001")))
    else:
        mcp.run()

이제 book_api.py 한 파일이 실행 방식에 따라 세 가지 모드로 동작합니다.

다른 모듈이 import할 때는 __main__이 아니므로 위 어느 쪽도 실행되지 않습니다. from_fastapiapp을 in-process(ASGI)로 직접 호출하므로, 3단계에서 1단계 REST 서버(8000 포트)가 떠 있을 필요는 없습니다. 도구 이름은 엔드포인트의 operation_id에서 나오는데, 1단계에서 operation_id="search_books"를 지정해 두었으므로 search_books라는 이름이 그대로 잡힙니다(미지정 시 search_search_get 식으로 자동 생성됩니다). 도구 설명도 마찬가지로 엔드포인트의 description에서 도출됩니다. 설명이 빈약하면 모델이 도구를 잘못 쓰는데, 예를 들어 검색어를 영어로 번역해 넘기면 한국어 DB와 매칭되지 않습니다. 1단계에서 description에 "한국어 키워드를 그대로 넣으라"고 적어 둔 것은 이런 오작동을 막기 위함입니다.

3.1도구를 직접 정의하기

from_fastapi는 이미 FastAPI 앱이 있을 때 편리하지만, 기존 REST API 없이 MCP 서버를 새로 만들 때는 도구를 직접 정의하는 편이 일반적입니다. @mcp.tool() 데코레이터를 붙이면 함수 시그니처와 독스트링에서 도구 이름·입력 스키마·설명이 자동으로 만들어집니다.

from fastmcp import FastMCP

mcp = FastMCP("BookStore")

@mcp.tool()
def search_books(query: str) -> str:
    """도서 데이터베이스에서 제목 또는 카테고리에 검색어가 포함된 책을 찾습니다.

    Args:
        query: 검색할 한국어 키워드 (예: '인공지능', '딥러닝')
    """
    return query_books(query)

if __name__ == "__main__":
    mcp.run()

from_fastapi가 엔드포인트를 일괄 변환하는 것과 달리, 이 방식은 어떤 함수를 어떤 설명으로 노출할지 직접 고르므로 도구 표면을 정밀하게 통제할 수 있습니다. 3단계 클라이언트는 두 방식 중 어느 쪽으로 만든 서버든 똑같이 사용합니다.

43단계: MCP 클라이언트로 도구 발견·호출

마지막으로 MCP 클라이언트를 작성합니다. fastmcp의 고수준 Client로 위 book_api.py를 stdio MCP 서버로 띄우고, list_tools로 사용 가능한 도구를 동적으로 발견한 뒤 Ollama에 전달합니다.

1단계와 달리 도구 스키마를 코드에 적지 않습니다. 클라이언트는 서버가 알려 주는 도구 목록을 그대로 사용하므로, 서버 쪽 도구가 바뀌어도 클라이언트는 수정할 필요가 없습니다.

import os
import asyncio
import httpx
from fastmcp import Client

# Ollama 서버 주소. 기본은 localhost이며, 예컨대 WSL에서 Windows 호스트의 Ollama를
# 쓸 때처럼 다른 곳에 있으면 OLLAMA_HOST 환경 변수로 지정합니다.
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
OLLAMA_API_URL = f"{OLLAMA_HOST}/api/chat"
MODEL_NAME = "qwen3:latest"

# Ollama Chat 호출 유틸리티 (MCP가 알려 준 도구 목록을 Ollama 형식으로 변환)
async def call_ollama(messages, tools=None):
    payload = {"model": MODEL_NAME, "messages": messages, "stream": False}
    if tools:
        payload["tools"] = [{
            "type": "function",
            "function": {"name": t.name, "description": t.description, "parameters": t.inputSchema},
        } for t in tools]
    async with httpx.AsyncClient() as http:
        response = await http.post(OLLAMA_API_URL, json=payload, timeout=120.0)
        response.raise_for_status()
        return response.json()["message"]

async def run_agent(user_question: str):
    print(f"[User]: {user_question}\n")

    # fastmcp.Client는 'book_api.py' 경로를 보고 stdio MCP 서버로 자동 구동합니다.
    # (StdioServerParameters·ClientSession·initialize 보일러플레이트가 한 줄로 줄어듭니다.)
    async with Client("book_api.py") as client:
        tools = await client.list_tools()
        print(f"-> MCP 서버가 제공하는 도구 목록: {[t.name for t in tools]}")

        messages = [{"role": "user", "content": user_question}]

        # 모델이 더 이상 도구를 요청하지 않을 때까지 반복합니다.
        # 한 번의 응답에 여러 tool_calls가 담길 수 있으므로 전체를 순회하고,
        # range로 최대 단계를 제한해 무한 루프를 방지합니다.
        for step in range(5):
            print(f"-> Ollama 분석 요청 (step {step + 1})...")
            message_obj = await call_ollama(messages, tools)
            messages.append(message_obj)

            tool_calls = message_obj.get("tool_calls")
            if not tool_calls:
                break  # 도구 호출이 없으면 최종 답변이 나온 것

            for tool_call in tool_calls:
                tool_name = tool_call["function"]["name"]
                tool_args = tool_call["function"]["arguments"]
                print(f"\n[Agent Action] 도구 실행: {tool_name}({tool_args})")

                # Ollama는 arguments를 (OpenAI와 달리) JSON 문자열이 아닌 dict로 돌려주므로 그대로 넘깁니다.
                mcp_result = await client.call_tool(tool_name, tool_args)
                result_text = mcp_result.data["result"]   # search_books는 {"result": ...}를 반환
                print(f"[Tool Response]\n{result_text}")

                # 각 도구 호출 결과를 메시지에 추가합니다 (호출 개수만큼).
                messages.append({"role": "tool", "content": result_text, "name": tool_name})

        if message_obj.get("thinking"):
            print(f"\n[Agent Thinking]:\n{message_obj['thinking']}")
        print(f"\n[Agent Final Answer]:\n{message_obj['content']}")

# Jupyter Notebook 환경에서의 asyncio 실행 대응
try:
    import nest_asyncio
    nest_asyncio.apply()
except ImportError:
    pass

try:
    await run_agent("딥러닝 관련 도서를 데이터베이스에서 추천해줘.")
except Exception as e:
    print(f"\n에이전트 구동에 실패했습니다: {e}")
    print("Ollama 서버가 실행 중이고 qwen3:latest 모델이 당겨져(pull) 있는지, `fastmcp`가 설치되어 있는지 확인하세요.")

5정리

세 단계를 거치며 같은 도구를 점점 더 표준화된 방식으로 연결했습니다.

  1. FastAPI: 도구를 REST로 노출했지만, 스키마 정의·이름 매핑·결과 전달을 모두 우리가 손으로 작성했습니다.

  2. MCP 서버(자동 변환): 기존 FastAPI 앱을 from_fastapi로 그대로 MCP 서버로 변환했고, 도구·스키마는 엔드포인트에서 자동 도출됩니다.

  3. MCP 클라이언트: 도구를 코드에 적지 않고 서버에서 동적으로 발견하므로, 도구나 서버가 바뀌어도 클라이언트 로직은 그대로 유지됩니다.

이 표준화 덕분에 LLM 에이전트 프레임워크나 모델을 바꿔도 도구 연결 방식은 변하지 않으며, 로컬의 books.db 경로를 모델에 직접 노출하지 않고도 안전하게 검색 기능을 제공할 수 있습니다.

여기서는 로컬 호스트에 적합한 stdio 전송을 사용했습니다. 원격에서 여러 클라이언트가 공유하는 서비스로 만들 때는 서버를 MCP_TRANSPORT=http로 띄우고(위 book_api.py가 이를 지원합니다), 클라이언트는 Client("http://<host>:8001/mcp")처럼 URL로 접속하면 됩니다. 전송 방식만 바뀔 뿐 list_tools·call_tool 코드는 그대로입니다.