MCP의 Resource·Prompt 활용법과 실측 함정 3가지
MCP의 Tool 외에 Resource와 Prompt 원시 요소의 작성법과 사용 시점을 정리하고, Python SDK v2로 직접 테스트한 함정 세 가지를 소개한다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
앞선 몇 편에서 MCP의 Tool을 충분히 다뤘다. 모델이 호출할지 말지, 어떤 인자를 넘길지, 결과를 어떻게 처리할지를 결정한다. 하지만 MCP에는 사실 두 개의 원시 요소(primitive)가 더 있는데, 많은 튜토리얼이 이들을 한 줄로만 언급하고 넘어간다.
- Resource: 데이터를 애플리케이션/호스트에 노출하고, 애플리케이션이 언제 컨텍스트에 로드할지를 결정한다;
- Prompt: 메시지 템플릿으로, 사용자가 메뉴에서 선택하고 파라미터를 채운 뒤 대화에 집어넣는다.
한마디로 세 가지를 구분하면 다음과 같다.
원시 요소 | 누가 사용을 결정하나 | 전형적 용도 Tool | 모델이 호출을 결정 | 날씨 조회, 파일 쓰기, 검사 실행 Resource | 애플리케이션/호스트가 로드를 결정 | 설정, 프로필, 문서, 실시간 통계 Prompt | 사용자가 메뉴에서 템플릿 선택 | 코드 리뷰, 디버깅 도입부, 고정된 화법
이 글에서는 Python SDK v2로 Resource와 Prompt를 선언부터 클라이언트 호출까지 한 번 돌려보고, 마지막으로 실제로 부딪힌 3가지 함정을 정리한다.
환경: Linux, Python 3.13.5, mcp 2.3.0(MCP Python SDK v2). 아래 출력은 모두 실제 실행 결과다.
一、환경 준비
python3 -m venv .venv && source .venv/bin/activate pip install "mcp>=2,<3"
二、서버: 고정 Resource, 템플릿 Resource, 두 가지 Prompt
"""server.py: Resource + Prompt 데모(MCP Python SDK v2)""" import sys from mcp.server import MCPServer from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage mcp = MCPServer( "resource-prompt-demo" , version= "0.1.0" ) # 카운터: list 시에는 함수가 실행되지 않고 read 시에만 실행됨을 증명 READ_COUNT = 0 def log ( msg: str ): # stdio 모드에서 stdout은 프로토콜 채널이므로 로그는 stderr에만 써야 함 print ( f"[server] {msg} " , file=sys.stderr, flush= True ) # ---------- Resources ---------- @mcp.resource( "config://app" ) def get_config () -> str : """현재 애플리케이션 설정(고정 URI).""" global READ_COUNT READ_COUNT += 1 log( f"get_config 가 호출됨, 누적 read= {READ_COUNT} " ) return "theme=dark\nlanguage=zh\nmax_retries=3" @mcp.resource( "users://{user_id}/profile" ) def get_user_profile ( user_id: str ) -> str : """user_id로 사용자 프로필 조회(URI 템플릿).""" log( f"get_user_profile( {user_id!r} ) 가 호출됨" ) return f"User {user_id} : 12 orders since 2024." @mcp.resource( "stats://catalog" ) # 일부러 mime_type을 쓰지 않음, 기본값 text/plain def catalog_stats () -> dict [ str , int ]: """카탈로그 통계(dict를 반환하면 SDK가 JSON 텍스트로 직렬화함).""" return { "books" : 1204 , "authors" : 391 } @mcp.resource( "docs://readme" , mime_type= "text/markdown" ) def readme () -> str : """프로젝트 설명(mime_type을 명시적으로 선언).""" return "# Demo\n\n用 Resource 暴露配置,用 Prompt 暴露模板。" # ---------- Prompts ---------- @mcp.prompt() def review_code ( code: str ) -> str : """Review a piece of code.(str 반환 → user 메시지 하나)""" return f"Please review this code:\n\n {code} " @mcp.prompt() def debug_error ( error: str ) -> list [Message]: """Start a debugging conversation.(여러 메시지 반환)""" return [ UserMessage( "I'm seeing this error:" ), UserMessage(error), AssistantMessage( "I'll help debug that. What have you tried so far?" ), ] if __name__ == "__main__" : mcp.run() # 기본값 stdio
몇 가지 설명:
- Resource는 URI로 주소를 지정하며, 클라이언트가 읽는 것은 함수명 get_config 가 아니라 config://app 이다;
- URI에 {user_id} 가 있으면 함수 파라미터도 반드시 user_id 라고 불러야 하며, 이것이 템플릿 Resource다; 이것은 resources/list 에 나타나지 않고 resources/templates/list 에 나타난다;
- str은 그대로 텍스트로 처리되고, dict / Pydantic 등은 JSON 텍스트로 직렬화되며, bytes는 blob이 된다. mime_type의 기본값은 text/plain이며, SDK는 반환값을 보고 추측하지 않는다.
- Prompt가 str을 반환하면 → user 메시지 하나가 되고, list[Message]를 반환하면 → 여러 턴을 미리 채울 수 있으며, AssistantMessage를 포함해 「모델 대신 첫 문장을 말하게」 할 수 있다.
三、클라이언트: list / read / get
"""client_demo.py: Resource 목록 조회/읽기, Prompt 목록 조회/가져오기""" import asyncio import sys from mcp import Client, StdioServerParameters params = StdioServerParameters(command=sys.executable, args=[ "server.py" ]) def dump_text_contents ( contents ): out = [] for c in contents: out.append({ "uri" : str (c.uri) if c.uri is not None else None , "mime_type" : c.mime_type, "text" : c.text, }) return out async def main (): async with Client(params) as client: resources = await client.list_resources() print ( "=== list_resources ===" ) for r in resources.resources: print ( f" name= {r.name} uri= {r.uri} mime= {r.mime_type} desc= {r.description} " ) templates = await client.list_resource_templates() print ( "=== list_resource_templates ===" ) for t in templates.resource_templates: print ( f" name= {t.name} uriTemplate= {t.uri_template} mime= {t.mime_type} desc= {t.description} " ) print ( "=== list again (should NOT call get_config) ===" ) await client.list_resources() cfg = await client.read_resource( "config://app" ) print ( "=== read config://app ===" ) print ( " " , dump_text_contents(cfg.contents)) cfg2 = await client.read_resource( "config://app" ) print ( "=== read config://app again ===" ) print ( " " , dump_text_contents(cfg2.contents)) profile = await client.read_resource( "users://42/profile" ) print ( "=== read users://42/profile ===" ) print ( " " , dump_text_contents(profile.contents)) stats = await client.read_resource( "stats://catalog" ) print ( "=== read stats://catalog ===" ) print ( " " , dump_text_contents(stats.contents)) md = await client.read_resource( "docs://readme" ) print ( "=== read docs://readme ===" ) print ( " " , dump_text_contents(md.contents)) prompts = await client.list_prompts() print ( "=== list_prompts ===" ) for p in prompts.prompts: args = [(a.name, a.required) for a in (p.arguments or [])] print ( f" name= {p.name} desc= {p.description} args= {args} " ) got = await client.get_prompt( "review_code" , { "code" : "def add(a, b): return a + b" }) print ( "=== get_prompt review_code ===" ) print ( f" description= {got.description} " ) for m in got.messages: print ( f" role= {m.role} text= {m.content.text!r} " ) got2 = await client.get_prompt( "debug_error" , { "error" : "TypeError: 'int' object is not iterable" }) print ( "=== get_prompt debug_error ===" ) print ( f" description= {got2.description} " ) for m in got2.messages: print ( f" role= {m.role} text= {m.content.text!r} " ) asyncio.run(main())
실행 결과(stdout):
=== list_resources === name=get_config uri=config://app mime=text/plain desc=현재 애플리케이션 설정(고정 URI). name=catalog_stats uri=stats://catalog mime=text/plain desc=카탈로그 통계(dict를 반환하며, SDK가 JSON 텍스트로 직렬화함). name=readme uri=docs://readme mime=text/markdown desc=프로젝트 설명(mime_type을 명시적으로 선언). === list_resource_templates === name=get_user_profile uriTemplate=users://{user_id}/profile mime=text/plain desc=user_id로 사용자 프로필을 가져옴(URI 템플릿). === list again (should NOT call get_config) === === read config://app === [{'uri': 'config://app', 'mime_type': 'text/plain', 'text': 'theme=dark\nlanguage=zh\nmax_retries=3'}] === read config://app again === [{'uri': 'config://app', 'mime_type': 'text/plain', 'text': 'theme=dark\nlanguage=zh\nmax_retries=3'}] === read users://42/profile === [{'uri': 'users://42/profile', 'mime_type': 'text/plain', 'text': 'User 42: 12 orders since 2024.'}] === read stats://catalog === [{'uri': 'stats://catalog', 'mime_type': 'text/plain', 'text': '{\n "books": 1204,\n "authors": 391\n}'}] === read docs://readme === [{'uri': 'docs://readme', 'mime_type': 'text/markdown', 'text': '# Demo\n\n用 Resource 暴露配置,用 Prompt 暴露模板。'}] === list_prompts === name=review_code desc=Review a piece of code.(str 반환 → user 메시지 하나) args=[('code', True)] name=debug_error desc=Start a debugging conversation.(여러 메시지 반환) args=[('error', True)] === get_prompt review_code === description=Review a piece of code.(str 반환 → user 메시지 하나) role=user text='Please review this code:\n\ndef add(a, b): return a + b' === get_prompt debug_error === description=Start a debugging conversation.(여러 메시지 반환) role=user text="I'm seeing this error:" role=user text="TypeError: 'int' object is not iterable" role=assistant text="I'll help debug that. What have you tried so far?"
같은 라운드의 서버 측 stderr(read 시에만 나타남):
[server] get_config가 호출됨, 누적 read=1 [server] get_config가 호출됨, 누적 read=2 [server] get_user_profile('42')가 호출됨
출력을 대조해 보면, 몇 가지가 이미 분명해진다:
- users://{user_id}/profile 은 list_resource_templates 에만 나타나고, list_resources 에는 없다;
- 두 번의 list_resources 동안 get_config 는 한 번도 실행되지 않았고, 두 번의 read 가 되어서야 카운트가 1, 2로 올라갔다;
- stats://catalog 는 JSON 텍스트를 반환하지만 mime_type 은 여전히 기본값인 text/plain 이다; docs://readme 는 명시적으로 선언했기 때문에 text/markdown 이다;
- review_code 는 user 하나로 렌더링되고; debug_error 는 user / user / assistant 세 개로 렌더링된다.
四、Resource를 언제 쓰고, Prompt를 언제 쓰는가
위 예시를 결합하면, 선택 기준을 좀 더 구체화할 수 있다:
- 설정, 프로필, 문서, 실시간 통계 → Resource. 호스트(IDE, Agent 프런트엔드, 직접 만든 오케스트레이션 계층)가 필요에 따라 read_resource 하고, 그다음 모델 컨텍스트에 넣을지 결정한다. 모델 자신은 '메뉴'를 볼 수 없고, URI를 능동적으로 읽지도 않는다(별도로 Tool로 한 겹 감싸지 않는 한).
- 고정된 오프닝 멘트, 리뷰 템플릿, 디버깅 스캐폴딩 → Prompt. 이는 사람 이 채우는 양식이다: 매개변수는 평면적인 문자열 목록이며, Tool처럼 JSON Schema가 없다. 렌더링 결과는 곧바로 대화로 들어가며, '모델이 호출할지 말지'를 거치는 단계가 없다.
- 부작용이 필요하고, 모델이 스스로 시점을 판단해야 한다면 → 여전히 Tool이다. Resource와 Prompt는 몰래 DB에 쓰거나 요청을 보내서는 안 되며, 읽기 쪽 효과는 최대한 절제해야 한다.
한 문장으로: Tool은 「모델의 손」, Resource는 「애플리케이션이 준비해 둔 자료」, Prompt는 「사용자가 클릭하는 단축 문구」다.
5. 실제 테스트에서 밟은 3가지 함정
함정 1: URI 플레이스홀더 이름과 함수 파라미터 이름이 불일치 → import 단계에서 터짐
"""pitfall1_uri_mismatch.py""" from mcp.server import MCPServer mcp = MCPServer( "mismatch-demo" ) @mcp.resource( "users://{user_id}/profile" ) def get_user_profile ( user: str ) -> str : # 일부러 user_id가 아니라 user로 작성 """플레이스홀더와 파라미터 이름이 불일치.""" return f"User {user} "
ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'}
이 오류는 데코레이터가 실행될 때, 즉 import / 시작 단계에서 발생하며, 클라이언트는 아예 접속할 수 없다. 이건 오히려 좋은 일이다. 플레이스홀더가 맞지 않으면 반드시 버그이므로 SDK가 곧바로 막아준다. 이름을 바꿀 때는 URI와 파라미터를 함께 바꿔야 한다.
함정 2: list가 Resource 함수를 실행한다고 착각
많은 사람이 Resource를 「등록 시점에 이미 계산해 둔 캐시」로 생각한다. 실제로는 resources/list가 메타데이터(name / uri / mime / description)만 반환하고, 함수 본문은 resources/read 시점에야 실행된다. 카운터로 따로 검증해 보자:
pitfall2_server.py 핵심 COUNT = 0 @mcp.resource( "config://app" ) def get_config () -> str : global COUNT COUNT += 1 print ( f"[server] get_config CALLED, count= {COUNT} " , file=sys.stderr, flush= True ) return f"read_count= {COUNT} "
클라이언트가 list를 두 번 호출하고, 이어서 read를 두 번 호출한다:
--- first list_resources --- listed: ['config://app'] --- second list_resources --- listed: ['config://app'] --- read_resource --- text: read_count=1 --- read_resource again --- text: read_count=2
stderr:
[server] get_config CALLED, count=1 [server] get_config CALLED, count=2
의미: Resource를 천 개 노출해도 상관없다. 열린 것에 대해서만 비용을 낸다. 반대로 함수에 「DB 접속 / 디스크 스캔」 같은 무거운 작업이 있다면 list 시점에 미리 데워지기를 기대하지 말고, 직접 캐시를 만들거나 애플리케이션 시작 시 능동적으로 한 번 read 해야 한다.
함정 3: Prompt에 required 파라미터가 없을 때 tool 스타일의 isError가 오지 않음
Tool에 파라미터가 없을 때 클라이언트가 받는 것은 정상 반환으로, is_error=True이고 오류 텍스트가 content 안에 있어 Agent가 모델에 다시 넣어 파라미터를 수정하게 할 수 있다. Prompt는 이 경로가 아니다. required 파라미터가 없으면 요청 전체가 예외를 던진다.
같은 프로세스에서 비교(pitfall3_server.py에 echo tool과 review_code prompt를 함께 등록):
=== tool에 required 파라미터 없음 === is_error: True content: Error executing tool echo: 1 validation error for echoArguments text Field req... === prompt에 required 파라미터 없음 === raised: MCPError code: -32603 message: Internal server error repr: MCPError(-32603, 'Internal server error', None)
두 가지에 주의:
- Prompt 쪽에는 풀어낼 is_error 결과가 없으므로 반드시 try/except MCPError를 써야 한다;
- 내 mcp 2.3.0 실측 오류 코드는 -32603(Internal server error)이다 — 서버 내부에서 먼저 ValueError: Missing required arguments: {'code'}를 던지고, 그것이 다시 내부 오류로 감싸진 것이다. 공식 문서 예시에는 -32602 Invalid params로 쓰여 있다. 클라이언트를 작성할 때는 「MCPError를 던질 것」로 처리하면 되고, 오류 코드를 하드코딩하지 말고 Tool의 isError 경로로 받지도 말아야 한다.
6. 소결
- Tool / Resource / Prompt 삼분법: 모델이 호출, 애플리케이션이 읽기, 사용자가 선택;
- @mcp.resource(uri)는 고정 주소; URI에 {placeholder}가 있으면 템플릿이 되어 list_resource_templates를 탄다;
- @mcp.prompt()는 str 또는 UserMessage / AssistantMessage 목록을 반환한다;
- 클라이언트 대응: list_resources / list_resource_templates / read_resource / list_prompts / get_prompt;
- 플레이스홀더 이름은 반드시 파라미터 이름과 같아야 한다; list는 함수를 실행하지 않는다; Prompt에 파라미터가 빠진 것은 예외이며 isError가 아니다.
전체 코드: server.py, client_demo.py, 그리고 세 가지 함정 스크립트 pitfall1_uri_mismatch.py, pitfall2_list_vs_read.py (+ pitfall2_server.py), pitfall3_missing_prompt_arg.py (+ pitfall3_server.py)를 같은 디렉터리에 복사하면 바로 실행할 수 있다.
저자는 MCP 관련 제품을 만들고 있으며, 여기 기록된 것은 실제 개발 과정에서 겪은 함정들이다.