2부 5장. Resources: 읽기 전용 데이터를 노출하다
2부 5장. Resources: 읽기 전용 데이터를 노출하다
지난 장까지 우리는 Tool 중심으로 PKM-MCP를 키워 왔다. search_notes는 꽤 유용했다. 하지만 여기서 한 번 멈춰서 생각해 보자. 모든 읽기 작업을 꼭 Tool로 만들어야 할까?
답은 아니다. 어떤 것은 Tool이 맞고, 어떤 것은 Resource가 더 잘 맞는다. 이 구분이 처음에는 사소해 보여도, 서버가 커질수록 차이가 크게 난다. Tool에 읽기와 쓰기와 검색과 문서 열람을 전부 밀어 넣으면, 모델은 "무엇을 실행해야 하는지"와 "무엇을 그냥 읽으면 되는지"를 구분하지 못한다. 반대로 읽을거리를 Resource로 분리해 두면, 모델의 추론은 단정해지고 서버 구조도 훨씬 말끔해진다.
이번 장에서는 그 경계를 분명히 잡는다. 그리고 PKM-MCP의 노트를 note://{id} 형태의 Resource로 노출해, AI가 메모를 "행동으로 호출"하는 대신 "문맥으로 읽을 수 있게" 바꿔 본다.

5.1 Tool과 Resource를 가르는 기준
아주 단순하게 비유해 보자. Tool은 비서에게 심부름을 시키는 버튼이고, Resource는 책장에서 책을 꺼내 읽는 일이다. 둘 다 정보를 얻는 데 도움이 되지만, 성격은 다르다. MCP에서 Tool은 모델이 상황을 보고 호출하는 행동이고, Resource는 호스트 애플리케이션이 불러와 문맥으로 제공하는 읽기 전용 데이터다.1
이 차이는 PKM 예제에 대입해 보면 더 또렷하다. search_notes(query="oauth")는 Tool이 맞다. 사용자가 어떤 단어를 찾고 싶은지 매번 달라지고, 검색이라는 작은 실행이 필요하며, 모델이 "지금 검색해야겠다"라고 판단해 호출하기 때문이다. 반면 note://oauth-refresh-token 같은 URI는 Resource가 더 자연스럽다. 이미 식별된 하나의 메모를 읽는 일이기 때문이다. 이때 모델 입장에서 중요한 것은 "실행"이 아니라 "정확한 본문을 보는 것"이다.
헷갈릴 때는 아래 네 가지 질문을 해 보면 된다.
이 기능이 시스템 상태를 바꾸는가? 바꾼다면 Tool이다.
같은 대상을 반복해서 읽는 성격인가? 그렇다면 Resource에 가깝다.
사용자가 목록에서 고르거나, 호스트가 배경 문맥으로 실어 나르기 좋은가? 그렇다면 Resource가 잘 맞는다.
자유 입력, 검색, 정렬, 외부 호출 같은 실행 로직이 핵심인가? 그렇다면 Tool이 낫다.
이 기준으로 보면 앞으로 만들 CRUD 기능은 전부 Tool 쪽이다. 노트를 추가하고, 수정하고, 삭제하는 순간 세상이 바뀌기 때문이다. 반대로 노트 본문, 노트 목록, 태그 목록, 최근 수정 메모 목록 같은 것은 Resource로 노출하기 좋다.
읽기 작업을 Tool과 Resource로 나누면 좋은 점도 분명하다.
모델이 "행동"과 "근거 문서"를 덜 헷갈린다.
읽기 전용 데이터는 캐시, 권한, 브라우징 방식을 따로 설계하기 쉽다.
Tool 설명은 더 짧고 선명해지고, Resource는 더 풍부한 문맥을 맡게 된다.
한마디로 정리하면 이렇다. 검색은 Tool일 수 있지만, 검색해서 찾아낸 문서 자체는 Resource인 경우가 많다. 이 선을 잘 그어 두면 나중에 RAG를 붙일 때도 구조가 무너지지 않는다.
5.2 정적 Resource와 URI 템플릿
Resource는 URI로 식별된다. 클라이언트는 resources/list로 고정된 Resource를 보고, resources/templates/list로 패턴 형태의 Resource를 발견하고, resources/read로 실제 내용을 읽는다. 또 서버는 필요하다면 resources/subscribe와 notifications/resources/updated, notifications/resources/list_changed까지 지원할 수 있다.2
여기서 중요한 구분이 하나 더 있다. Resource에는 정적 Resource와 URI 템플릿 기반 Resource가 있다.
정적 Resource는 주소가 고정되어 있다. 예를 들어 note://index는 언제 읽어도 "현재 PKM에 어떤 메모가 있는지"를 알려 주는 목록이다. 반면 URI 템플릿은 패턴만 먼저 공개한다. note://{id}는 "id만 채우면 특정 메모를 읽을 수 있다"는 뜻이다.2
PKM-MCP에서는 이렇게 나누면 깔끔하다.
note://index— 메모 목록을 반환하는 정적 Resourcenote://recent— 최근 수정 메모를 보여 주는 정적 Resourcenote://{id}— 특정 메모 본문을 읽는 URI 템플릿
오늘 구현에서는 핵심만 살려 note://index와 note://{id} 두 가지만 만든다. 하나는 서가의 목록이고, 다른 하나는 실제 책이다.
이 설계가 좋은 이유는 역할이 명확하기 때문이다. note://index는 브라우징용이다. 호스트나 사용자가 "무슨 메모가 있지?" 하고 둘러볼 때 좋다. note://{id}는 읽기용이다. 이미 메모 하나를 골랐다면 그 본문을 그대로 가져오면 된다. MIME 타입도 자연스럽게 나뉜다. 목록은 application/json, 메모 본문은 text/markdown로 두면 클라이언트가 더 잘 표시할 수 있다.2
FastMCP에서는 이 Resource를 함수에 @mcp.resource(...)를 붙여 간단하게 만들 수 있다. 함수는 실제로 해당 URI가 읽힐 때만 실행되므로, 평소에는 조용히 있다가 필요할 때만 데이터를 꺼내 준다.3
작은 주의점도 있다. note://{id}라고 해서 id를 파일 경로처럼 아무 값이나 받아 버리면 안 된다. Resource URI는 반드시 검증해야 한다. 그렇지 않으면 ../../secret 같은 값이 슬그머니 들어와 경로 탈출 문제가 생길 수 있다.2
5.3 Subscribe와 변경 알림
Resource는 읽기 전용이지만, 세상이 멈춰 있다는 뜻은 아니다. 메모는 여전히 수정될 수 있다. 단지 모델이 직접 고치지 못할 뿐이다. 그래서 MCP는 Resource 변경을 알리는 두 가지 장치를 둔다.2
첫째는 notifications/resources/list_changed다. 이건 "리소스 목록 자체가 바뀌었다"는 신호다. 새 메모가 추가되거나, 어떤 메모가 삭제되었거나, 접근 가능한 리소스 집합이 달라졌을 때 보낸다.
둘째는 notifications/resources/updated다. 이건 "특정 URI의 내용이 바뀌었다"는 신호다. 예를 들어 note://weekly-review를 구독하고 있던 클라이언트가 있다면, 그 메모가 수정되었을 때 서버는 해당 URI가 바뀌었다고 알려 줄 수 있다.2
여기서 중요한 포인트가 있다. 알림은 새 내용을 직접 실어 나르지 않는다. 보통은 "무언가 바뀌었다"는 사실과 URI만 알려 준다. 그러면 클라이언트는 그 URI를 다시 resources/read로 읽어 최신 내용을 가져와야 한다. 초인종이 울렸다고 택배 상자가 문을 열고 들어오지는 않는 셈이다.4
PKM-MCP에 대입하면 흐름은 이렇다.
새 메모 파일이 생겼다 →
notifications/resources/list_changed기존 메모
note://project-plan의 내용이 바뀌었다 →notifications/resources/updated클라이언트는 목록을 다시 가져오거나, 해당 메모를 다시 읽는다
아직 초반 단계에서는 이 기능을 꼭 구현하지 않아도 된다. 하지만 "나중에 파일 감시기를 붙이면 여기로 연결되겠구나"라는 감각은 지금 잡아 두는 편이 좋다. 클라이언트가 구독을 지원하지 않는다면, 주기적으로 다시 읽는 폴링 방식으로도 충분히 대응할 수 있다.4
만들기: PKM의 노트를 note://{id}로 노출하기
이제 직접 만들어 보자. 이번 구현의 목표는 두 가지다. 첫째, 메모 전체 목록을 보는 note://index Resource를 만든다. 둘째, 메모 하나를 읽는 note://{id} Resource 템플릿을 만든다.
편의를 위해 이번 장에서는 notes/ 디렉터리 바로 아래에 있는 마크다운 파일만 다룬다고 가정하겠다. 각 파일명에서 확장자를 뺀 값이 곧 메모 ID다. 예를 들어 notes/oauth-refresh-token.md는 note://oauth-refresh-token으로 읽을 수 있다.
from datetime import datetime, timezone
from pathlib import Path
import json
import re
from fastmcp import FastMCP
mcp = FastMCP("PKM-MCP")
NOTES_DIR = Path("./notes")
def build_registry() -> dict[str, Path]:
registry: dict[str, Path] = {}
if not NOTES_DIR.exists():
return registry
for path in sorted(NOTES_DIR.glob("*.md")):
registry[path.stem] = path
return registry
def read_markdown(path: Path) -> str:
return path.read_text(encoding="utf-8")
def extract_title(text: str, fallback: str) -> str:
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("#"):
return stripped.lstrip("#").strip()
return stripped[:60]
return fallback
@mcp.resource("note://index", mime_type="application/json")
def note_index() -> str:
"""PKM 노트 목록과 각 노트의 Resource URI를 반환합니다."""
notes = []
for note_id, path in build_registry().items():
text = read_markdown(path)
notes.append(
{
"id": note_id,
"title": extract_title(text, note_id),
"uri": f"note://{note_id}",
"mimeType": "text/markdown",
"lastModified": datetime.fromtimestamp(
path.stat().st_mtime, tz=timezone.utc
).isoformat(),
}
)
return json.dumps(
{"total": len(notes), "notes": notes},
ensure_ascii=False,
indent=2,
)
@mcp.resource("note://{id}", mime_type="text/markdown")
def note_by_id(id: str) -> str:
"""노트 ID로 메모 본문을 읽습니다. 읽기 전용입니다."""
if not re.fullmatch(r"[A-Za-z0-9._-]+", id):
raise ValueError("유효하지 않은 note id입니다.")
path = build_registry().get(id)
if path is None:
raise ValueError(f"노트를 찾을 수 없습니다: {id}")
return read_markdown(path)
if __name__ == "__main__":
mcp.run()이 코드에서 꼭 봐야 할 부분은 세 군데다.
첫째, note://index는 정적 Resource다. URI가 고정되어 있으니 클라이언트는 이 주소를 한 번만 알아도 된다. 읽으면 전체 메모 목록과 각 메모의 URI가 나온다. 쉽게 말해 PKM의 카드 서랍이다.
둘째, note://{id}는 URI 템플릿이다. id 값만 바꿔 여러 메모를 같은 패턴으로 읽는다. 별도의 Tool 없이도 note://meeting-notes, note://oauth-refresh-token, note://weekly-review 같은 URI를 일관되게 다룰 수 있다.
셋째, id를 정규식으로 검증한다. 이건 사소해 보여도 중요하다. Resource는 읽기 기능이라 방심하기 쉬운데, URI를 곧장 파일 경로로 바꾸는 순간 보안 문제가 생길 수 있다. 지금처럼 ID를 파일명 수준으로 제한하면 출발선이 훨씬 안전해진다.
실행 후 Inspector로 확인할 때는 보통 이런 순서로 보면 된다.
Resources목록에서note://index가 보이는지 확인한다.Resource Templates목록에서note://{id}가 보이는지 확인한다.note://index를 읽어서 어떤 메모들이 있는지 확인한다.목록에 나온 URI 하나를 골라 실제 메모 본문을 읽는다.
예를 들어 note://index의 결과가 아래처럼 나온다고 해 보자.
{
"total": 2,
"notes": [
{
"id": "mcp-overview",
"title": "MCP 개요",
"uri": "note://mcp-overview",
"mimeType": "text/markdown",
"lastModified": "2026-04-26T09:10:00+00:00"
},
{
"id": "oauth-refresh-token",
"title": "OAuth 토큰 갱신 메모",
"uri": "note://oauth-refresh-token",
"mimeType": "text/markdown",
"lastModified": "2026-04-25T18:20:00+00:00"
}
]
}그러면 다음 읽기는 아주 자연스럽다. note://oauth-refresh-token을 resources/read로 요청하면 해당 마크다운 본문이 돌아온다. 이제 AI는 검색 결과의 짧은 스니펫이 아니라, 메모 원문을 근거로 요약하거나 인용할 수 있다.
읽기 작업을 분리하면 추론 품질이 왜 좋아질까
이번 장의 핵심은 단순히 Resource를 하나 더 배웠다는 데 있지 않다. 읽기 전용 문서를 Tool과 분리하면 모델이 생각하는 방식 자체가 더 좋아진다는 데 있다.
예를 들어 사용자가 "OAuth 관련 메모를 찾아서 핵심만 정리해 줘"라고 말했다고 해 보자. 구조를 잘 나누면 흐름은 이렇게 된다.
search_notes(query="oauth")Tool이 후보 메모를 찾는다.검색 결과에는
id와uri가 함께 들어 있다.클라이언트는
note://oauth-refresh-token같은 Resource를 읽어 실제 본문을 가져온다.모델은 그 본문을 근거로 요약한다.
이 방식의 장점은 분명하다. 검색 Tool은 "무엇을 찾아야 하나"에 집중하고, Resource는 "정확히 무엇이 적혀 있나"를 담당한다. 역할이 분리되니 모델도 덜 헤매고, 개발자도 덜 꼬인다.
그래서 이번 장을 마친 뒤에는 3장에서 만든 search_notes 결과에 uri 필드를 하나 추가해 두는 것을 추천한다.
class NoteHit(BaseModel):
id: str
title: str
path: str
snippet: str
score: int
uri: str그리고 결과를 만들 때 이렇게 채워 넣으면 된다.
uri = f"note://{path.stem}"이 작은 연결고리 하나가 꽤 크다. 검색 Tool이 Resource를 가리키기 시작하면, PKM-MCP는 "검색만 하는 서버"에서 "문서를 탐색하고 읽을 수 있는 서버"로 한 단계 진화한다.
이번 장에서 우리는 AI에게 또 하나의 능력을 준 것이 아니다. 그보다 더 중요한 것을 했다. AI가 무엇을 실행하고, 무엇을 읽어야 하는지 구분할 수 있게 만들었다. 다음 장에서는 이 위에 Prompts를 얹어, 자주 쓰는 워크플로우를 재사용 가능한 템플릿으로 묶어 볼 것이다.
참고
1MCP Resources and URI Templates
2Resources - Model Context Protocol
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.