URI 템플릿과 경로 안전성
기계 번역
이 페이지는 영어 문서를 자동으로 번역한 것이며, 영어 페이지가 기준이 되는 정식 버전입니다. 어색하거나 잘못된 부분이 있다면 번역 페이지에서 제보하는 방법을 확인하세요.
이 페이지는 @mcp.resource가 받아들이는 URI 템플릿 문법과, 추출된 값에 SDK가 적용하는 경로 안전성 정책을 다루는 레퍼런스입니다. 리소스가 무엇이고 언제 사용하는지에 관한 소개는 리소스에서 먼저 살펴보세요. 이 페이지는 리소스를 선언하는 데 이미 익숙하고, 전체 연산자 집합이나 보안 설정, 저수준 연결 방법을 알고 싶은 경우를 가정합니다.
템플릿 문법은 RFC 6570을 따릅니다. SDK는 들어오는 resources/read URI를 매칭하기 위해 선택한 부분 집합을 지원하며, 여기에 더해 서비스하려는 디렉터리 바깥으로 해석될 수 있는 값을 거부하는 보안 계층을 제공합니다. 프로토콜 수준의 세부 사항(메시지 형식, 생명 주기, 페이지네이션)은 MCP 리소스 명세를 참고하세요.
전체 연산자 집합
단순 플레이스홀더인 {user_id}는 리소스에서 소개한 형태입니다. 연산자 형태는 네 가지가 더 있으며, 나란히 비교할 수 있도록 하나의 서버에 모아 두었습니다.
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
BOOKS = {
"978-0441172719": {"title": "Dune", "author": "Frank Herbert"},
"978-0553293357": {"title": "Foundation", "author": "Isaac Asimov"},
}
MANUALS = {
"printing/setup.md": "# Printer setup\n\nLoad paper, then power on.",
"returns.md": "# Returns policy\n\nThirty days with a receipt.",
}
@mcp.resource("books://{isbn}")
def get_book(isbn: str) -> dict[str, str]:
"""A single book by ISBN."""
return BOOKS[isbn]
@mcp.resource("orders://{order_id}")
def get_order(order_id: int) -> dict[str, object]:
"""An order by its numeric id."""
return {"order_id": order_id, "next_order": order_id + 1, "status": "shipped"}
@mcp.resource("manuals://{+path}")
def read_manual(path: str) -> str:
"""A staff manual page. The path keeps its slashes."""
return MANUALS[path]
@mcp.resource("reviews://{isbn}{?limit,sort}")
def list_reviews(isbn: str, limit: int = 10, sort: str = "newest") -> str:
"""Reviews of a book, optionally limited and sorted."""
return f"{limit} {sort} reviews of {BOOKS[isbn]['title']}"
@mcp.resource("shelves://browse{/path*}")
def browse_shelf(path: list[str]) -> str:
"""A shelf in the category tree, addressed by segments."""
return " > ".join(["catalog", *path])
강조 표시된 데코레이터는 각각 URI를 다른 방식으로 분해합니다. 아래 섹션에서 위에서부터 차례로 살펴봅니다.
단순 확장: {name}
books://{isbn}은 평범하고 일상적인 형태입니다. 플레이스홀더는 isbn 매개변수에 대응하므로, 클라이언트가 books://978-0441172719를 읽으면 get_book("978-0441172719")이 호출됩니다.
단순한 {name}은 첫 번째 /에서 멈춥니다. books://978/extra는 매칭되지 않습니다. 978 뒤의 슬래시에서 캡처가 끝나고 /extra가 남기 때문입니다.
타입 변환
추출된 값은 문자열로 들어오지만, 더 구체적인 타입을 선언하면 SDK가 변환합니다. orders://{order_id}는 매개변수가 order_id: int인 함수로 전달되므로, orders://12345를 읽으면 get_order("12345")가 아니라 get_order(12345)가 호출됩니다. 핸들러는 형 변환 없이 바로 산술 연산(order_id + 1)을 수행합니다.
여러 세그먼트에 걸친 경로: {+name}
슬래시가 포함된 값을 캡처하려면 {+name}을 사용하세요. manuals://{+path}의 경우 다음과 같습니다.
manuals://returns.md는path = "returns.md"를 줍니다.manuals://printing/setup.md는path = "printing/setup.md"를 줍니다.
값이 계층 구조를 가질 때는 언제든 {+name}을 사용하세요. 파일시스템 경로, 중첩된 객체 키, 프록시하는 URL 경로가 여기에 해당합니다.
쿼리 매개변수: {?a,b,c}
reviews://{isbn}{?limit,sort}는 limit과 sort를 ? 뒤에 둡니다. 경로는 어떤 책인지를 식별하고, 쿼리는 어떻게 읽을지를 조정합니다.
쿼리 매개변수는 느슨하게 매칭됩니다. 순서는 상관없고, 추가된 항목은 무시되며, 생략된 매개변수는 함수의 기본값으로 처리됩니다. 따라서 reviews://978-0441172719는 limit=10, sort="newest"를 사용하고, reviews://978-0441172719?sort=top은 sort만 덮어씁니다.
경로 세그먼트를 리스트로: {/name*}
각 경로 세그먼트를 슬래시가 포함된 하나의 문자열이 아니라 별개의 리스트 항목으로 받고 싶다면 {/name*}을 사용하세요. shelves://browse{/path*}의 경우, 클라이언트가 shelves://browse/fiction/sci-fi를 읽으면 browse_shelf(["fiction", "sci-fi"])가 호출됩니다.
템플릿 레퍼런스
가장 흔한 패턴은 다음과 같습니다.
| 패턴 | 예시 입력 | 얻는 값 |
|---|---|---|
{name} |
alice |
"alice" |
{name} |
docs/intro.md |
매칭 안 됨(/에서 멈춤) |
{+path} |
docs/intro.md |
"docs/intro.md" |
{.ext} |
.json |
"json" |
{/segment} |
/v2 |
"v2" |
{?key} |
?key=value |
"value" |
{?a,b} |
?a=1&b=2 |
"1", "2" |
{/path*} |
/a/b/c |
["a", "b", "c"] |
파서가 거부하는 것
몇 가지 템플릿 형태는 첫 요청에서 실패하는 대신 미리 잡아냅니다. @mcp.resource는 데코레이터가 실행될 때 템플릿을 파싱하므로, 아래 경우는 실행 중인 서버에 도달하지 않습니다.
UriTemplate.parse()는 다음 경우에 InvalidUriTemplate을 발생시킵니다.
- 두 변수 사이에 아무것도 없는 경우.
manuals://{+path}{ext}는 거부됩니다. 매칭 과정에서path가 어디서 끝나고ext가 어디서 시작하는지 알 수 없기 때문입니다. 사이에 리터럴을 두거나(manuals://{+path}/{ext}), 자체 구분자를 제공하는 연산자를 사용하세요.manuals://{+path}{.ext}는{.ext}가 직접.을 제공하므로 허용됩니다. - 여러 세그먼트에 걸친 변수가 둘 이상인 경우. 템플릿 하나에
{+var},{#var}, 또는 전개 변수({/var*},{.var*},{;var*})는 최대 하나만 허용됩니다. 둘이면 본질적으로 모호합니다. 어느 쪽이 추가 세그먼트를 흡수해야 하는지 결정할 원칙적인 방법이 없습니다. - 일반적인 문법 오류. 닫히지 않은 중괄호, 두 번 사용된 변수 이름, 또는
{var:3}접두사 수정자나{?vars*}쿼리 전개처럼 SDK가 지원하지 않는 RFC 6570 기능이 여기에 해당합니다.
이에 더해 @mcp.resource는 핸들러 매개변수가 템플릿 끝의 {?...}/{&...} 구간에 있는 쿼리 변수에 바인딩되어 있으면서 Python 기본값이 없는 경우 ValueError를 발생시킵니다. 이 변수들은 느슨하게 매칭되므로(클라이언트가 어느 것이든 생략할 수 있습니다), 기본값이 없는 매개변수는 이를 생략한 첫 요청에서 불투명한 내부 오류로만 드러나게 됩니다. 위 서버의 reviews://{isbn}{?limit,sort}는 올바른 형태입니다. limit과 sort 모두 기본값을 갖고 있습니다.
보안
템플릿 매개변수는 클라이언트에서 옵니다. 이 값이 검증 없이 파일시스템이나 데이터베이스 연산으로 흘러가면, ../../etc/passwd 같은 값이 서비스하려던 디렉터리 바깥으로 해석될 수 있습니다.
SDK가 기본으로 검사하는 것
핸들러가 실행되기 전에 SDK는 다음에 해당하는 매개변수를 거부합니다.
..구성 요소를 통해 시작 디렉터리를 벗어나는 경우- 절대 경로(
/etc/passwd,C:\Windows)나 Windows 드라이브 상대 경로(C:foo)처럼 보이는 경우. 드라이브 상대 경로 값과x:y같은 네임스페이스 식별자는 문자열로는 구별할 수 없으므로, 한 글자 뒤에 콜론이 오는 값은 기본적으로 모두 거부됩니다. 그런 값을 정당하게 받는 매개변수라면 검사에서 제외하세요. - 널 바이트(
\x00)를 포함하는 경우
.. 검사는 부분 문자열 스캔이 아니라 구성 요소 기반입니다. v1.0..v2.0이나 HEAD~3..HEAD 같은 값은 ..가 독립된 경로 세그먼트가 아니므로 통과합니다.
이 검사는 디코딩된 값에 적용되므로, URI에서 어떻게 인코딩되었든 경로 탐색 시도를 잡아냅니다(../etc, ..%2Fetc, %2E%2E/etc, ..%5Cetc, %00 모두 잡힙니다).
Check
위 서버에서 manuals://../etc/passwd를 읽으면 요청은 즉시 거부됩니다. 템플릿 매칭은 첫 번째 실패에서 멈추므로, 이후의(더 관대할 수도 있는) 템플릿을 대체 수단으로 시도하지 않습니다. 클라이언트는 어떤 템플릿에도 매칭되지 않는 URI와 동일한 -32602 "Unknown resource" 오류를 받고, read_manual은 실행되지 않습니다.
파일시스템 핸들러: safe_join 사용
내장 검사는 흔한 경우를 막아 주지만 샌드박스 경계까지는 알 수 없습니다. 파일시스템에 접근할 때는 safe_join으로 경로를 해석하고 기준 디렉터리 안에 머무르는지 확인하세요.
from pathlib import Path
from mcp.server import MCPServer
from mcp.shared.path_security import safe_join
mcp = MCPServer("Bookshop")
DOCS_ROOT = Path("./manuals")
@mcp.resource("manuals://{+path}")
def read_manual(path: str) -> str:
"""A staff manual page, served from a directory on disk."""
return safe_join(DOCS_ROOT, path).read_text()
safe_join은 단순 문자열 검사로는 놓칠 수 있는 심볼릭 링크 탈출, .. 시퀀스, 절대 경로 트릭을 잡아냅니다. 해석된 경로가 DOCS_ROOT를 벗어나면 PathEscapeError를 발생시키고, 이는 클라이언트에 ResourceError로 전달됩니다.
기본값이 방해가 될 때
검사가 정당한 값을 막는 경우도 있습니다. 카탈로그 가져오기 도구는 의도적으로 절대 경로를 받을 수 있고, 어떤 매개변수는 핸들러가 파일시스템을 건드리지 않고 안전하게 해석하는 ../sibling 같은 상대 참조일 수 있습니다. 해당 매개변수를 검사에서 제외하거나, 서버 전체의 정책을 완화하세요.
from mcp.server import MCPServer
from mcp.server.mcpserver import ResourceSecurity
mcp = MCPServer("Bookshop")
@mcp.resource(
"imports://preview/{+source}",
security=ResourceSecurity(exempt_params={"source"}),
)
def preview_import(source: str) -> str:
"""Preview a catalog import. `source` may be an absolute path."""
return f"Would import from {source}"
relaxed = MCPServer(
"Bookshop",
resource_security=ResourceSecurity(reject_path_traversal=False),
)
@relaxed.resource("imports://preview/{+source}")
def preview_import_relaxed(source: str) -> str:
"""The server-wide flag exempts every resource on `relaxed`."""
return f"Would import from {source}"
- 데코레이터의
security=ResourceSecurity(exempt_params={"source"})는 해당 리소스의 해당 매개변수 하나에 대해서만 검사를 건너뜁니다. 서버의 나머지 부분은 기본 정책을 유지합니다. MCPServer생성자의resource_security=는 모든 리소스의 기본값을 설정합니다. 여기서relaxed는..검사를 완전히 끕니다.
설정 가능한 검사는 다음과 같습니다.
| 설정 | 기본값 | 동작 |
|---|---|---|
reject_path_traversal |
True |
시작 디렉터리를 벗어나는 .. 시퀀스를 거부합니다 |
reject_absolute_paths |
True |
/foo, C:\foo, UNC 경로, 드라이브 상대 경로 C:foo를 거부합니다(x:y도 잡힙니다) |
reject_null_bytes |
True |
\x00을 포함하는 값을 거부합니다 |
exempt_params |
비어 있음 | 검사를 건너뛸 매개변수 이름 |
이 검사는 휴리스틱 사전 필터입니다. 파일시스템 접근에서는 safe_join이 여전히 격리 경계입니다.
Tip
핸들러가 요청을 처리할 수 없다면(파일이 없거나, id를 알 수 없는 경우) 예외를 발생시키세요. SDK가 이를 오류 응답으로 바꿉니다. 프로토콜 오류와 도구 오류의 차이는 오류 처리에서 확인하세요.
저수준 Server의 리소스
저수준 Server 위에서 구축하는 경우(저수준 Server 참고), resources/list와 resources/read 프로토콜 메서드의 핸들러를 직접 등록합니다. 데코레이터는 없으며, 프로토콜 타입을 직접 반환합니다.
정적 리소스
고정 URI의 경우 레지스트리를 두고 정확히 일치하는지에 따라 분기하세요.
from mcp.server import Server, ServerRequestContext
from mcp.types import (
ListResourcesResult,
PaginatedRequestParams,
ReadResourceRequestParams,
ReadResourceResult,
Resource,
TextResourceContents,
)
RESOURCES = {
"config://shop": '{"currency": "USD", "tax_rate": 0.08}',
"status://health": "ok",
}
async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult:
return ListResourcesResult(resources=[Resource(name=uri, uri=uri) for uri in RESOURCES])
async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
if (text := RESOURCES.get(params.uri)) is not None:
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
raise ValueError(f"Unknown resource: {params.uri}")
server = Server("Bookshop", on_list_resources=list_resources, on_read_resource=read_resource)
list 핸들러는 클라이언트에게 사용 가능한 것을 알려 주고, read 핸들러는 콘텐츠를 제공합니다. 먼저 레지스트리를 확인하고, 템플릿이 있다면 템플릿(아래)으로 넘기고, 그 외에는 예외를 발생시키세요.
템플릿
MCPServer가 사용하는 템플릿 엔진은 mcp.shared.uri_template에 있으며 독립적으로 동작합니다. 동일한 파싱과 매칭을 얻되, 라우팅과 보안 정책은 직접 연결합니다.
from mcp.server import Server, ServerRequestContext
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
from mcp.shared.uri_template import UriTemplate
from mcp.types import (
ListResourceTemplatesResult,
PaginatedRequestParams,
ReadResourceRequestParams,
ReadResourceResult,
ResourceTemplate,
TextResourceContents,
)
TEMPLATES = {
"manuals": UriTemplate.parse("manuals://{+path}"),
"books": UriTemplate.parse("books://{isbn}"),
}
MANUALS = {"printing/setup.md": "# Printer setup", "returns.md": "# Returns policy"}
BOOKS = {"978-0441172719": "Dune by Frank Herbert"}
def read_manual_safely(path: str) -> str:
if contains_path_traversal(path) or is_absolute_path(path):
raise ValueError("rejected")
return MANUALS[path]
async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
if (matched := TEMPLATES["manuals"].match(params.uri)) is not None:
text = read_manual_safely(str(matched["path"]))
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
if (matched := TEMPLATES["books"].match(params.uri)) is not None:
text = BOOKS[str(matched["isbn"])]
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
raise ValueError(f"Unknown resource: {params.uri}")
async def list_resource_templates(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourceTemplatesResult:
return ListResourceTemplatesResult(
resource_templates=[
ResourceTemplate(name=name, uri_template=str(template)) for name, template in TEMPLATES.items()
]
)
server = Server(
"Bookshop",
on_read_resource=read_resource,
on_list_resource_templates=list_resource_templates,
)
강조 표시된 줄에서는 세 가지 일이 일어납니다.
- 한 번 파싱하고, 요청마다 매칭합니다.
UriTemplate.parse()가 템플릿을 만들고,template.match(uri)는 추출된 변수를dict로 반환하거나 URI가 맞지 않으면None을 반환합니다. URL 디코딩은match()안에서 일어나며, 디코딩된 값은 경로 안전성 검증 없이 그대로 반환됩니다. 값은 문자열로 나오므로 직접 변환하세요(int(matched["id"]),Path(matched["path"])). - 안전성 검사를 직접 적용합니다.
MCPServer가 기본으로 실행하는..검사와 절대 경로 검사는mcp.shared.path_security에 있습니다.read_manual_safely는MANUALS를 건드리기 전에 이를 호출합니다. 매개변수가 파일시스템 경로가 아니라면(ISBN, 검색 쿼리 등) 해당 값의 검사는 건너뛰세요. 정책은 설정 객체가 아니라 핸들러마다 직접 제어합니다. - 같은 출처에서 템플릿을 나열합니다. 클라이언트는
resources/templates/list를 통해 템플릿을 발견합니다.str(template)은 원래 템플릿 문자열을 돌려주므로, 목록과 매처가 하나의 단일 출처를 공유합니다.
요약
{name}은 세그먼트 하나를 매칭하고,{+name}은 슬래시를 유지하며,{?a,b}는 쿼리 문자열에서 값을 가져오고,{/name*}은 세그먼트를 리스트로 나눕니다.- 사이에 아무것도 없는 두 변수, 또는 여러 세그먼트에 걸친 두 번째 변수는 파싱 시점에 거부됩니다. 끝의
{?...}/{&...}쿼리 변수에 바인딩된 매개변수는 Python 기본값을 선언해야 합니다. - 매개변수에 타입을 표기하면(
order_id: int) SDK가 변환합니다. - 기본 보안 정책은 핸들러가 실행되기 전에
.., 절대 경로, 널 바이트를 거부합니다. 리소스별로는security=ResourceSecurity(...)로, 서버 전체로는resource_security=로 재정의하세요. - 파일시스템 접근에서는
safe_join이 격리 경계입니다. - 저수준
Server에서는UriTemplate.parse()로 파싱하고,.match()로 매칭하며,mcp.shared.path_security를 직접 적용하세요.