URI 템플릿과 경로 안전 (URI templates and path safety)

URI 템플릿과 경로 안전 (URI templates and path safety)

이 페이지는 @mcp.resource가 받아들이는 URI-템플릿 문법과, SDK가 추출된 값에 적용하는 경로-안전 정책의 레퍼런스예요. 리소스가 무엇이고 언제 쓰는지에 대한 소개는 **리소스(Resources)**부터 시작하고요, 이 페이지는 이미 리소스 선언에 익숙하고 전체 연산자 집합, 보안 노브, 저수준 배선을 원한다고 가정해요.

템플릿 문법은 RFC 6570이에요. SDK는 들어오는 resources/read URI를 매칭하기 위해 고른 부분집합을 지원하고, 의도한 디렉터리 밖으로 풀리는 값을 거부하는 보안 레이어를 얹어요. 프로토콜 수준의 세부(메시지 형식, 생애주기, 페이지네이션)는 MCP resources specification을 보세요.

전체 연산자 집합

평범한 자리표시자 {user_id}는 **리소스(Resources)**가 소개하는 것이에요. 연산자 형태가 네 개 더 있어요. 한 서버에 모아두면 나란히 볼 수 있어요:

# docs_src/uri_templates/tutorial001.py
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.mdpath = "returns.md"
  • manuals://printing/setup.mdpath = "printing/setup.md"

값이 계층적일 때마다 {+name}을 써요: 파일시스템 경로, 중첩 객체 키, 프록시하는 URL 경로.

쿼리 파라미터: {?a,b,c}

reviews://{isbn}{?limit,sort}limitsort? 뒤에 둬요. 경로가 어떤 책인지, 쿼리가 어떻게 읽을지 정해요.

쿼리 파라미터는 관대하게 매칭돼요: 순서는 무관하고, 초과분은 무시되며, 생략된 파라미터는 함수 기본값으로 내려가요. 그래서 reviews://978-0441172719limit=10, sort="newest"를 쓰고, reviews://978-0441172719?sort=topsort만 덮어써요.

경로 세그먼트를 리스트로: {/name*}

슬래시가 있는 하나의 문자열 대신 각 경로 세그먼트를 별도 리스트 항목으로 원한다면 {/name*}을 써요. shelves://browse{/path*}에서 shelves://browse/fiction/sci-fi를 읽는 클라이언트는 browse_shelf(["fiction", "sci-fi"])를 호출해요.

템플릿 레퍼런스

가장 흔한 패턴:

패턴 예시 입력 결과
{name} alice "alice"
{name} docs/intro.md no match (/에서 멈춤)
{+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을 raise 해요:

  • 사이에 아무것도 없는 변수 두 개. manuals://{+path}{ext}는 거부돼요: 매칭은 path가 끝나고 ext가 시작하는 곳을 알 수 없어요. 사이에 리터럴을 넣거나(manuals://{+path}/{ext}), 자체 구분자를 제공하는 연산자를 쓰세요. manuals://{+path}{.ext}는 받아들여져요. {.ext}. 자체를 기여하거든요.
  • 멀티 세그먼트 변수 둘 이상. 템플릿당 {+var}, {#var}, 또는 explode된 변수({/var*}, {.var*}, {;var*})는 최대 하나예요. 둘이면 본질적으로 모호해요: 어느 쪽이 추가 세그먼트를 흡수할지 원리적으로 정할 방법이 없어요.
  • 평범한 문법 에러: 닫히지 않은 중괄호, 변수 이름 두 번 쓰기, SDK가 지원하지 않는 RFC 6570 기능(예: {var:3} 접두사 수식자, {?vars*} 쿼리 explode).

그 위에, @mcp.resource는 핸들러 파라미터가 템플릿의 끝 {?...}/{&...} 블록의 쿼리 변수에 묶였는데 파이썬 기본값이 없을 때 ValueError를 raise 해요. 그 변수들은 관대하게 매칭되니(클라이언트는 아무거나 빠뜨릴 수 있어요) 기본값 없는 파라미터는 그것을 빠뜨리는 첫 요청에서 불투명한 내부 에러로만 드러날 거예요. 위 서버의 reviews://{isbn}{?limit,sort}가 잘 만들어진 형태예요: limitsort 둘 다 기본값을 지녀요.

보안

템플릿 파라미터는 클라이언트에서 와요. 체크 없이 파일시스템이나 DB 연산으로 흘러 들어가면 ../../etc/passwd 같은 값이 의도한 디렉터리 밖으로 풀릴 수 있어요.

SDK가 기본으로 검사하는 것

핸들러가 실행되기 전에 SDK가 다음 어느 것이든 파라미터를 거부해요:

  • .. 컴포넌트로 시작 디렉터리를 벗어나는 것
  • 절대 경로(/etc/passwd, C:\Windows)나 Windows 드라이브 상대(C:foo)처럼 보이는 것. 드라이브 상대 값과 x:y 같은 네임스페이스 식별자는 문자열로 구분이 안 돼서, single-letter-plus-colon 값은 기본으로 거부돼요. 그런 값을 정당하게 받는다면 파라미터를 면제하세요.
  • 널 바이트(\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으로 경로를 풀고 기본 디렉터리 안에 머무는지 확인하세요:

# docs_src/uri_templates/tutorial002.py
from pathlib import Path

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
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."""
    file = safe_join(DOCS_ROOT, path)
    if not file.is_file():
        raise ResourceNotFoundError(f"No manual at {path!r}.")
    return file.read_text(encoding="utf-8")

safe_join은 단순 문자열 검사가 놓칠 수 있는 심볼릭 링크 이스케이프, .. 시퀀스, 절대 경로 트릭을 잡아요. 해석된 경로가 DOCS_ROOT를 벗어나면 PathEscapeError를 raise 하고, 그게 ResourceError로 클라이언트에게 드러나요.

기본값이 방해할 때

때로 검사가 정당한 값을 막아요. 카탈로그-import 도구는 의도적으로 절대 경로를 받을 수 있고, 파라미터는 핸들러가 파일시스템을 건드리지 않고 안전하게 해석하는 ../sibling 같은 상대 참조일 수 있어요. 그 파라미터를 면제하거나, 서버 전체에 대해 정책을 완화하세요:

# docs_src/uri_templates/tutorial003.py
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 empty 검사를 건너뛸 파라미터 이름

이 검사들은 휴리스틱 사전 필터예요. 파일시스템 접근에 대해 safe_join이 여전히 containment 경계예요.

!!! tip 핸들러가 요청을 충족할 수 없다면(파일이 없거나, id를 모르면) 위 read_manual처럼 ResourceNotFoundError를 raise 해요. 클라이언트는 여러분의 메시지와 URI와 함께 -32602를 받아요. 예상치 못한 예외는 대신 일반 -32603이 돼요. **에러 처리**를 보세요.

저수준 서버의 리소스

저수준 Server 위에 구축한다면(The low-level Server 참고) resources/listresources/read 프로토콜 메소드에 핸들러를 직접 등록해요. 데코레이터는 없고 프로토콜 타입을 직접 반환해요.

정적 리소스

고정 URI에 대해서는 레지스트리를 유지하고 정확한 일치로 디스패치해요:

# docs_src/uri_templates/tutorial004.py
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 핸들러가 콘텐츠를 제공해요. 먼저 레지스트리를 확인하고, 있으면 템플릿(아래)으로 넘어간 뒤 그 외의 것은 raise 해요.

템플릿

MCPServer가 쓰는 템플릿 엔진은 mcp.shared.uri_template에 있고 자체적으로 동작해요. 같은 파싱과 매칭을 얻지만, 라우팅과 보안 정책은 직접 연결해요.

# docs_src/uri_templates/tutorial005.py
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_safelyMANUALS를 건드리기 전에 그걸 호출해요. 파라미터가 파일시스템 경로가 아니면(ISBN, 검색 쿼리) 그 값에 대한 검사는 건너뛰어요: 구성 객체가 아니라 핸들러마다 정책을 통제해요.
  • 같은 소스에서 템플릿 나열. 클라이언트는 resources/templates/list를 통해 템플릿을 발견해요. str(template)이 원래 템플릿 문자열을 돌려주니, 나열과 매처가 하나의 source of truth를 공유해요.

요약

  • {name}은 세그먼트 하나, {+name}은 슬래시 유지, {?a,b}는 쿼리 문자열에서, {/name*}는 세그먼트를 리스트로 분리.
  • 사이에 아무것도 없는 변수 두 개, 두 번째 멀티 세그먼트 변수는 파싱 시점에 거부돼요. 끝 {?...}/{&...} 쿼리 변수에 묶인 파라미터는 파이썬 기본값을 선언해야 해요.
  • 파라미터를 애너테이션(order_id: int)하면 SDK가 변환해요.
  • 기본 보안 정책은 핸들러 실행 전에 .., 절대 경로, 널 바이트를 거부하고, security=ResourceSecurity(...)로 리소스별 또는 resource_security=로 서버 전역 오버라이드해요.
  • 파일시스템 접근에 대해 safe_join이 containment 경계예요.
  • 저수준 Server에서는 UriTemplate.parse()로 파싱, .match()로 매칭, mcp.shared.path_security는 직접 적용해요.