Перейти к содержанию

Шаблоны URI и безопасность путей

Машинный перевод

Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.

Это справочник по синтаксису шаблонов URI, который принимает @mcp.resource, и по политике безопасности путей, которую SDK применяет к извлечённым значениям. Чтобы разобраться, что такое ресурсы и когда их использовать, начните со страницы Ресурсы; здесь предполагается, что вы уже уверенно объявляете ресурсы и хотите получить полный набор операторов, настройки безопасности или низкоуровневую реализацию.

Синтаксис шаблонов — это RFC 6570. SDK поддерживает подмножество, подобранное для сопоставления входящих URI в resources/read, плюс слой безопасности, который отклоняет значения, ведущие за пределы каталога, который вы собираетесь отдавать. Подробности уровня протокола (форматы сообщений, жизненный цикл, пагинация) описаны в спецификации ресурсов MCP.

Полный набор операторов

Простой заполнитель {user_id} — тот, что представлен на странице Ресурсы. Есть ещё четыре формы операторов; вот они на одном сервере, чтобы их можно было сравнить:

server.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.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*}). Две такие переменные неоднозначны по своей природе: нет обоснованного способа решить, какая из них заберёт лишний сегмент.
  • Обычные синтаксические ошибки: незакрытая фигурная скобка, дважды использованное имя переменной или возможность RFC 6570, которую SDK не поддерживает, например модификатор префикса {var:3} или раскрытие в запросе {?vars*}.

Кроме того, @mcp.resource выбрасывает ValueError, если параметр обработчика привязан к переменной запроса в завершающей группе {?...}/{&...} шаблона, но не имеет значения по умолчанию в Python. Эти переменные сопоставляются нестрого (клиент может опустить любую из них), поэтому параметр без значения по умолчанию проявился бы лишь как непонятная внутренняя ошибка на первом запросе, где он опущен. 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 с сервера выше, и запрос будет отклонён сразу: сопоставление шаблонов останавливается на первой неудаче, поэтому никакой последующий (возможно, более мягкий) шаблон не пробуется как запасной. Клиент видит ту же ошибку -32602 «Unknown resource», что и для URI, не совпадающего ни с одним шаблоном, а read_manual так и не запускается.

Обработчики файловой системы: используйте safe_join

Встроенные проверки отсекают типичные случаи, но не знают границ вашей песочницы. Для доступа к файловой системе используйте safe_join, чтобы разрешить путь и убедиться, что он остаётся внутри базового каталога:

server.py
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, которую обработчик безопасно интерпретирует, не обращаясь к файловой системе. Исключите этот параметр из проверки или ослабьте политику для всего сервера:

server.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"}) в декораторе отключает проверки для одного этого параметра на одном этом ресурсе. Остальной сервер сохраняет политику по умолчанию.
  • resource_security= в конструкторе MCPServer задаёт значение по умолчанию для каждого ресурса. Здесь 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

Если обработчик не может выполнить запрос (файла нет, идентификатор неизвестен), выбросьте исключение. SDK превратит его в ответ с ошибкой. О разнице между ошибкой протокола и ошибкой инструмента см. Обработка ошибок.

Ресурсы на низкоуровневом Server

Если вы строите на низкоуровневом классе Server (см. Низкоуровневый Server), обработчики для методов протокола resources/list и resources/read регистрируются напрямую. Декоратора нет; протокольные типы возвращаются вручную.

Статические ресурсы

Для фиксированных URI ведите реестр и диспетчеризуйте по точному совпадению:

server.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)

Обработчик списка сообщает клиентам, что доступно; обработчик чтения отдаёт содержимое. Сначала проверьте реестр, затем перейдите к шаблонам (ниже), если они есть, а для всего остального выбрасывайте исключение.

Шаблоны

Движок шаблонов, который использует MCPServer, находится в mcp.shared.uri_template и работает сам по себе. Разбор и сопоставление те же; маршрутизацию и политику безопасности вы подключаете сами.

server.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 или None, если URI не подходит. Декодирование 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 сами.