Перейти до змісту

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} або змінна з explode-модифікатором ({/var*}, {.var*}, {;var*}) на шаблон. Дві — за своєю природою неоднозначні: немає обґрунтованого способу вирішити, яка з них поглине зайвий сегмент.
  • Звичайні синтаксичні помилки: незакрита фігурна дужка, двічі використане ім'я змінної або можливість RFC 6570, яку SDK не підтримує, як-от модифікатор префікса {var:3} чи explode у параметрах запиту {?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 самі.