2부 3장. 첫 Tool: AI에게 동사를 가르치다
2부 3장. 첫 Tool: AI에게 동사를 가르치다
지난 장에서 우리는 MCP 서버의 빈 골격을 세웠다. 이제 그 서버에 처음으로 "할 일"을 넣을 차례다. 서버가 아무리 반듯하게 서 있어도, 실제로 할 수 있는 일이 없으면 AI에게는 그냥 조용한 상자에 불과하다. 도구 하나가 생기는 순간, 그 상자는 비로소 행동하는 존재가 된다.
이번 장의 핵심은 단순하다. Tool은 AI에게 동사를 가르치는 장치라는 점이다. 메모를 "읽을" 수 있고, "찾을" 수 있고, 나중에는 "추가하고", "수정하고", "삭제할" 수도 있다. 그중 첫 번째 동사로 우리는 PKM-MCP에 search_notes를 넣는다. 아직은 읽기 전용이다. 하지만 바로 이 작은 도구 하나가, MCP가 단순한 API 포장지가 아니라 모델이 이해하기 쉬운 인터페이스라는 사실을 가장 선명하게 보여준다.1
사람은 대충 적힌 함수 이름을 봐도 감으로 이해한다. 하지만 모델은 다르다. 모델은 README를 끝까지 정독하지 않는다. 대신 tools/list로 전달받은 도구 이름, 설명, 입력 스키마를 보고 어떤 도구를 쓸지 판단한다. 그리고 실제 실행은 tools/call로 일어난다.2 즉, 우리가 작성하는 함수는 동시에 "실행 코드"이면서 "모델에게 보여줄 설명서"다.
3.1 FastMCP의 @mcp.tool() 한 줄
FastMCP의 매력은 출발선이 아주 낮다는 데 있다. 보통 "프로토콜"이라고 하면 JSON 스키마를 손으로 쓰고, 요청과 응답 형식을 맞추고, 유효성 검사를 따로 붙여야 할 것 같지만, FastMCP는 파이썬 함수를 거의 그대로 도구로 바꿔 준다. 핵심은 @mcp.tool 데코레이터 한 줄이다.1
가장 단순한 예는 이렇다.
from fastmcp import FastMCP
mcp = FastMCP(name="CalculatorServer")
@mcp.tool
def add(a: int, b: int) -> int:
"""두 정수를 더합니다."""
return a + b이 짧은 코드에서 FastMCP는 여러 가지를 자동으로 처리한다. 함수 이름 add는 도구 이름이 되고, docstring은 도구 설명이 되며, 매개변수와 타입 힌트는 입력 스키마가 된다. a와 b가 정수라는 사실도, 둘 다 필수라는 사실도 함수 시그니처에서 바로 읽어낸다.1
이걸 처음 보면 "그냥 함수 노출이네?" 싶을 수 있다. 하지만 중요한 차이가 있다. 이 함수는 더 이상 개발자만 보는 내부 함수가 아니다. 이제는 모델이 발견하고 호출할 수 있는 공개된 능력이다. 다시 말해, add()라는 파이썬 함수가 add라는 MCP Tool로 승격된 것이다.
조금 더 감각적으로 말하면 이렇다.
메서드나 함수를 쓰는 개발자는 보통 문맥을 안다. add(a, b)가 뭘 하는지 대충 안다. 그러나 LLM은 그렇지 않다. 모델은 이 도구를 처음 본다. 그러니 도구의 이름과 설명이 불친절하면, 모델은 망설이거나 잘못 호출한다. 첫 Tool을 만들 때 우리가 배우는 것은 "함수를 노출하는 법"이 아니라 함수를 모델이 고를 수 있게 만드는 법이다.
FastMCP가 이 과정을 편하게 만들어 주는 이유는, 도구 정의를 별도의 선언문이 아니라 파이썬 함수 자체에서 뽑아내기 때문이다.1 그래서 처음 도구를 만들 때는 복잡한 설계보다 먼저, 아주 정상적인 파이썬 함수를 한 개 잘 쓰는 것이 중요하다.
파일 검색처럼 간단한 작업이라면 async def가 아니라 일반 def로 시작해도 괜찮다. FastMCP는 동기 함수를 스레드풀에서 실행할 수 있어서 이벤트 루프를 막지 않도록 배려한다.1 초반에는 "비동기냐 동기냐"보다 "도구가 잘 설명되어 있느냐"가 훨씬 더 중요하다.
3.2 타입 힌트와 docstring이 곧 스펙이다
MCP 도구를 처음 만들 때 많은 사람이 구현부터 생각한다. 하지만 실제로는 구현보다 먼저 스펙이 생긴다. 그리고 FastMCP에서는 그 스펙이 놀랍도록 익숙한 곳에서 나온다. 바로 타입 힌트와 docstring이다.1
예를 들어 이런 함수가 있다고 해보자.
def search_notes(query: str, limit: int = 5) -> list[dict]:
"""메모에서 키워드를 검색합니다."""여기에는 이미 중요한 정보가 들어 있다.
query는 기본값이 없으므로 필수 입력이다. limit은 기본값이 있으므로 선택 입력이다. query는 문자열이어야 하고, limit은 정수여야 한다. 모델은 이런 정보를 바탕으로 어떤 값을 넣어야 하는지 판단한다. 결국 함수 시그니처 자체가 도구의 입력 계약서 역할을 하는 셈이다.1
여기서 한 걸음 더 나가면, 단순한 타입 힌트만으로는 부족한 순간이 온다. 예를 들어 limit이 1 이상 20 이하였으면 좋겠고, query는 두 글자 이상이었으면 좋겠다고 해보자. 또는 매개변수가 정확히 무엇을 의미하는지 모델에게 더 친절하게 알려 주고 싶을 수도 있다. 그럴 때 Annotated와 Field를 붙이면 된다.1
from typing import Annotated
from pydantic import Field
def search_notes(
query: Annotated[
str,
Field(description="검색할 키워드 또는 짧은 구문", min_length=2)
],
limit: Annotated[
int,
Field(description="반환할 최대 결과 수", ge=1, le=20)
] = 5,
) -> list[dict]:
"""메모에서 키워드를 검색합니다."""이제 query는 단지 문자열이 아니라 "검색어"가 되고, limit은 단지 정수가 아니라 "1에서 20 사이의 결과 수"가 된다. 모델 입장에서는 훨씬 덜 헷갈리는 도구가 된다.
여기서 실무적으로 꼭 기억할 점이 하나 있다. 도구의 전체 설명은 docstring에 쓰고, 매개변수 설명은 Annotated + Field로 명시하는 편이 더 안정적이다. FastMCP와 Python SDK 쪽 논의에서도 함수 docstring에서 인자 설명을 기대만큼 자동 추출하지 못하는 문제가 이야기된 적이 있다.3 즉, "문서만 예쁘게 쓰면 되겠지"라고 생각하지 말고, 모델이 꼭 알아야 하는 인자 설명은 아예 스키마에 박아 넣는 편이 안전하다.
이런 관점에서 보면, 타입 힌트와 docstring은 주석이 아니다. 실행 가능한 계약이다. 사람이 읽는 설명이면서, 동시에 모델이 호출 결정을 내릴 때 참고하는 신호다.
또 하나 재미있는 점은 반환 타입도 힌트가 된다는 것이다. MCP 사양은 도구가 필요하다면 outputSchema와 structuredContent를 제공할 수 있도록 정의한다.2 초반에는 문자열이나 간단한 딕셔너리만 반환해도 충분하지만, 나중에 결과를 다른 도구나 클라이언트가 이어서 활용해야 한다면 구조화된 반환이 점점 중요해진다. 이번 장에서도 우리는 너무 무겁지 않은 선에서, 검색 결과를 모델이 다루기 좋은 형태로 돌려주는 방향을 선택할 것이다.
3.3 도구가 잘 호출되게 만드는 글쓰기 — 좋은 docstring과 나쁜 docstring
이제 정말 중요한 이야기를 해보자. 첫 Tool을 만들 때 가장 많이 놓치는 것은 로직이 아니라 글쓰기다.
LLM은 도구를 호출하기 전에 늘 같은 질문을 한다. "이 도구가 지금 필요한가?" 그 판단의 재료가 도구 이름과 설명이다. 그러니 이름이 흐리고 설명이 짧으면, 모델은 도구를 잘못 고르거나 아예 안 고른다. 반대로 이름이 선명하고 설명이 구체적이면, 모델은 꽤 똑똑하게 행동한다.4
예를 들어 아래처럼 쓰면 나쁘지 않아 보이지만, 실제로는 정보가 너무 적다.
@mcp.tool
def search(query: str) -> list[dict]:
"""Search notes."""문제는 세 가지다. 첫째, search는 너무 넓다. 웹 검색인지, 메모 검색인지, 파일 검색인지 알 수 없다. 둘째, "언제 써야 하는지"가 없다. 셋째, 어떤 결과가 나오는지도 짐작하기 어렵다.
같은 기능도 이렇게 쓰면 훨씬 낫다.
@mcp.tool
def search_notes(query: str, limit: int = 5) -> list[dict]:
"""Search local markdown notes with exact keyword matching.
Use this tool when the user wants to find notes containing a specific word or phrase.
This is a read-only search over the PKM vault.
Prefer this tool for fast lookup when exact keywords are known.
"""이 설명은 모델에게 네 가지를 알려 준다.
무엇을 검색하는가: 로컬 마크다운 메모
어떤 방식인가: 정확한 키워드 검색
어떤 상황에서 쓰는가: 특정 단어나 구문을 찾고 싶을 때
어떤 제약이 있는가: 읽기 전용
좋은 도구 설명은 화려할 필요가 없다. 대신 선택 기준이 들어 있어야 한다. 모델이 "아, 지금은 이 도구를 써야겠구나"라고 판단할 수 있게 해 주는 문장이어야 한다.4
첫 Tool을 설계할 때는 다음 네 가지를 습관처럼 확인해 보자.
이름은 동사+대상으로 구체적으로 쓴다.
search_notes는search보다 낫다.첫 문장은 기능을 한 줄로 요약한다.
그 다음 문장에는 언제 이 도구를 써야 하는지 적는다.
가능하면 범위와 한계를 밝혀 둔다. 예를 들면 read-only인지, 정확 검색인지, 의미 검색은 아닌지 같은 것들이다.
개발자끼리 보는 내부 함수 설명과, 모델이 읽는 도구 설명은 목적이 다르다. 내부 함수 설명이 "무엇을 했는가" 중심이라면, 도구 설명은 "언제 선택해야 하는가"까지 포함해야 한다. 이 차이가 생각보다 크다. 모델은 코드를 읽고 감탄하지 않는다. 대신 스키마와 설명을 읽고 행동한다.
만들기: PKM에 search_notes 도구 추가
이제 메인 프로젝트인 PKM-MCP에 첫 도구를 넣어 보자. 이번 장의 목표는 화려한 검색 엔진이 아니다. AI가 메모를 읽을 수 있는 첫 동사를 만드는 것이다. 아직은 의미 검색도, 벡터 DB도 없다. 그냥 로컬 메모를 훑어보며 키워드가 들어 있는 파일을 찾아 주기만 하면 된다. 대신 결과 형식과 설명은 최대한 모델 친화적으로 만든다.
이번 구현은 다음 원칙을 따른다.
메모는 로컬
notes/디렉터리 아래의 마크다운 파일이라고 가정한다.검색은 정확한 키워드 포함 여부를 기반으로 한다.
도구는 읽기 전용이다. 파일을 생성하거나 수정하지 않는다.
결과는 제목, 경로, 짧은 스니펫, 단순 점수를 포함해 모델이 바로 답변에 활용할 수 있게 한다.
먼저 전체 코드를 보자.
from pathlib import Path
from typing import Annotated
from fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP(name="PKM-MCP")
NOTES_DIR = Path("./notes")
class NoteHit(BaseModel):
id: str = Field(description="파일명에서 만든 메모 고유 ID")
title: str = Field(description="메모 제목")
path: str = Field(description="notes 디렉터리 기준 상대 경로")
snippet: str = Field(description="검색어 주변의 짧은 본문 발췌")
score: int = Field(description="단순 키워드 매칭 점수")
class SearchNotesResult(BaseModel):
query: str = Field(description="사용자가 요청한 원래 검색어")
total: int = Field(description="반환된 검색 결과 수")
results: list[NoteHit] = Field(description="점수 순으로 정렬된 메모 목록")
def read_note(path: Path) -> str:
return path.read_text(encoding="utf-8")
def make_snippet(text: str, keyword: str, radius: int = 60) -> str:
lowered = text.lower()
needle = keyword.lower()
idx = lowered.find(needle)
if idx == -1:
return text[: radius * 2].replace("n", " ").strip()
start = max(0, idx - radius)
end = min(len(text), idx + len(keyword) + radius)
return text[start:end].replace("n", " ").strip()
@mcp.tool
def search_notes(
query: Annotated[
str,
Field(
description="메모 제목과 본문에서 찾을 키워드 또는 짧은 구문. 예: 'oauth', '회의 메모', 'vector search'",
min_length=2,
),
],
limit: Annotated[
int,
Field(
description="반환할 최대 결과 수. 빠르게 훑어볼 때는 작은 값을 사용합니다.",
ge=1,
le=20,
),
] = 5,
) -> SearchNotesResult:
"""로컬 마크다운 메모에서 정확한 키워드 검색을 수행합니다.
사용자가 특정 단어, 태그, 구문이 들어 있는 메모를 찾고 싶을 때 사용합니다.
이 도구는 PKM 저장소를 읽기만 하며, 메모를 생성·수정·삭제하지 않습니다.
빠른 탐색용 키워드 검색에 적합하며, 의미 검색은 이후 장에서 별도 도구로 추가합니다.
"""
query = query.strip()
if not NOTES_DIR.exists():
return SearchNotesResult(query=query, total=0, results=[])
hits: list[NoteHit] = []
for path in sorted(NOTES_DIR.rglob("*.md")):
text = read_note(path)
lowered = text.lower()
title = path.stem.replace("-", " ").replace("_", " ")
score = lowered.count(query.lower())
score += title.lower().count(query.lower()) * 2
if score == 0:
continue
hits.append(
NoteHit(
id=path.stem,
title=title,
path=str(path.relative_to(NOTES_DIR)),
snippet=make_snippet(text, query),
score=score,
)
)
hits.sort(key=lambda item: (-item.score, item.title))
hits = hits[:limit]
return SearchNotesResult(
query=query,
total=len(hits),
results=hits,
)
if __name__ == "__main__":
mcp.run()겉보기엔 코드가 조금 길어 보이지만, 실제 핵심은 search_notes() 함수 하나다. 나머지는 결과를 깔끔하게 만들기 위한 보조 장치다. 이 구현에서 눈여겨볼 부분을 차근차근 짚어보자.
함수 이름이 곧 모델의 첫인상이다
우리는 도구 이름을 search_notes로 잡았다. 그냥 search라고 하지 않은 이유는 아주 단순하다. 앞으로 서버가 커지면 검색 대상이 많아질 수 있기 때문이다. 메모 검색, GitHub 이슈 검색, 벡터 검색, 링크 검색이 생길 수 있다. 이때 search는 너무 흐리다. 반면 search_notes는 "메모를 검색하는 도구"라는 사실을 이름만으로 드러낸다.4
docstring에는 사용 시점을 써 준다
docstring은 단순한 기능 설명이 아니다. 모델이 이 도구를 고를지 말지 판단하는 재료다. 그래서 우리는 "정확한 키워드 검색", "읽기 전용", "의미 검색은 아니다" 같은 문장을 넣었다. 이런 정보는 사람에게도 도움이 되지만, 모델에게는 특히 더 중요하다. 비슷한 도구가 여러 개 생길수록 이런 구분선이 필요해진다.
매개변수 설명은 Field에 직접 적는다
query와 limit에는 모두 설명이 달려 있다. query에는 예시도 넣었고, limit에는 범위 제한도 넣었다. 이렇게 하면 모델이 엉뚱하게 limit=1000 같은 값을 던질 가능성이 줄어든다.1
결과는 "읽기 좋은 텍스트"보다 "쓰기도 좋은 구조"를 우선한다
우리는 문자열 한 덩어리 대신 SearchNotesResult와 NoteHit 모델을 반환했다. 이렇게 하면 결과가 query, total, results로 나뉘고, 각 결과마다 title, path, snippet, score가 분명하게 들어간다. 모델은 이런 구조를 좋아한다. 왜냐하면 어떤 필드를 인용해야 하는지, 어떤 순서로 정리해야 하는지가 명확하기 때문이다.2
예를 들어 사용자가 "OAuth 관련 메모 찾아줘"라고 물었을 때, 모델은 검색 결과의 title과 snippet만 뽑아 자연스럽게 요약할 수 있다. 만약 나중에 "가장 관련도 높은 메모 하나만 열어 줘" 같은 흐름으로 이어진다면, id나 path를 다음 도구 호출의 입력으로도 쓸 수 있다. 작은 구조화가 나중의 조합 가능성을 크게 높여 준다.
스니펫은 작지만 중요하다
검색 결과에 제목만 있으면 모델은 다시 전체 파일을 읽고 싶어 한다. 반대로 본문 전체를 싣자니 너무 길다. 그래서 검색어 주변만 잘라낸 snippet이 유용하다. 이것은 사람에게는 미리보기고, 모델에게는 "정말 이 메모가 맞는지" 판단할 실마리다. 좋은 MCP 도구는 종종 이렇게 다음 행동을 쉽게 만드는 힌트를 함께 준다.4
연결하고 시험해 보기
서버를 실행한 뒤 Inspector나 MCP 클라이언트에서 도구 목록을 보면 search_notes가 보여야 한다. 그리고 입력 스키마에는 대략 이런 정보가 잡힌다.
query: 문자열, 필수limit: 정수, 선택, 기본값 5
이때 꼭 해 봐야 하는 테스트는 "코드가 실행되는가"가 아니다. 모델이 도구를 올바르게 선택하는가다. 다음처럼 자연어 프롬프트를 던져 보자.
"지난주 회의 메모 찾아줘."
"oauth라는 단어가 들어간 메모 3개만 보여줘."
"vector search 관련 메모가 있나?"
모델이 search_notes를 스스로 고르고, query와 limit을 적절하게 채워 호출한다면 첫 Tool은 성공이다. 여기서 중요한 건 검색 정확도가 완벽한가가 아니라, 도구 인터페이스가 모델에게 이해 가능한가다.
만약 호출이 잘 안 된다면 구현보다 설명을 먼저 의심해 보자. search_notes 대신 search라고 이름 붙여 두지는 않았는지, docstring이 "Search stuff" 같은 수준으로 짧지는 않은지, query가 정확히 무엇을 의미하는지 드러나 있는지 살펴보면 된다. 첫 Tool의 실패는 종종 검색 알고리즘이 아니라 글쓰기에서 시작한다.
첫 Tool이 주는 감각
이번 장에서 만든 search_notes는 대단한 검색 엔진이 아니다. 사실상 파일 몇 개를 훑어보는 소박한 read-only 도구에 가깝다. 그런데도 이 도구는 아주 중요한 감각을 준다.
서버에 Tool이 하나 생기면, 모델은 단순히 "아는 척"하는 존재에서 "행동하는 존재"로 바뀐다. 사용자가 "내 메모에서 찾아봐"라고 했을 때, 이제 모델은 상상으로 때우지 않고 실제 메모를 뒤질 수 있다. 그것이 바로 MCP의 손맛이다.
그리고 더 중요한 사실은 이것이다. 좋은 첫 Tool을 만드는 일은 곧 좋은 MCP 서버를 만드는 일의 축소판이라는 점이다. 이름은 명확해야 하고, 설명은 선택 기준을 담아야 하며, 입력은 타입으로 제약되어야 하고, 출력은 다음 행동에 유리한 구조를 가져야 한다. 이 네 가지 원칙은 앞으로 CRUD 도구를 만들 때도, RAG 검색을 붙일 때도, 외부 시스템을 연결할 때도 계속 반복된다.
이번 장에서 우리는 AI에게 첫 동사 하나를 가르쳤다. 다음 장에서는 그 동사가 실패할 때도 똑똑하게 반응하도록, 에러를 잘 내는 도구를 만들어 볼 것이다.
참고
3Improving how function docstring gets converted to tool's jsonschema for FastMCP · Issue #226
4MCP Tool Design Patterns: Building Agent-Friendly, Composable Tools
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.