AmigaОбучение ИИ
Модуль 2 · Ключевые возможности MCP · урок 7 из 13

Roots на практике

11 мин чтения▶ есть видеоверсия

В прошлом уроке мы выяснили, что roots — это подсказка, а не забор, и что забор сервер должен ставить сам. Сейчас построим сервер project-search, который получает корни от клиента, ищет по ним и отказывается читать что-либо снаружи. А в конце перепишем его так, как рекомендует текущая спецификация, — без roots.

Шаг 1. Тестовый проект

Серверу нужно, в чём искать. Создайте рядом с проектом сервера две папки с парой файлов:

bash
mkdir -p ~/mcp-sandbox/shop-frontend/src ~/mcp-sandbox/shop-api/app ~/mcp-sandbox/secret
printf 'def render():\n    # TODO: вынести в компонент\n    pass\n' > ~/mcp-sandbox/shop-frontend/src/page.py
printf 'def pay():\n    # TODO: ретраи\n    # TODO: логирование\n    pass\n' > ~/mcp-sandbox/shop-api/app/billing.py
printf 'DB_PASSWORD=hunter2\n' > ~/mcp-sandbox/secret/.env

Папка secret понадобится, чтобы проверить, что сервер туда не ходит.

Шаг 2. Сервер с roots

Проект: uv init project-search && cd project-search && uv add "mcp[cli]". Файл server.py:

python
import logging
from pathlib import Path
from typing import Annotated
from urllib.parse import unquote, urlparse

from mcp.server import MCPServer
from mcp.server.mcpserver import ListRoots, Resolve
from mcp.types import ListRootsResult

logger = logging.getLogger(__name__)
mcp = MCPServer("project-search")


def workspace_roots() -> ListRoots:
    """Резолвер: попросить у клиента список рабочих папок."""
    return ListRoots()


def root_paths(roots: ListRootsResult) -> list[Path]:
    paths: list[Path] = []
    for root in roots.roots:
        parsed = urlparse(str(root.uri))
        if parsed.scheme == "file":
            paths.append(Path(unquote(parsed.path)).resolve())
    return paths


def inside(path: Path, roots: list[Path]) -> bool:
    resolved = path.resolve()
    return any(resolved == r or r in resolved.parents for r in roots)


@mcp.tool()
async def count_todos(
    roots: Annotated[ListRootsResult, Resolve(workspace_roots)],
) -> str:
    """Посчитать пометки TODO в python-файлах рабочих папок."""
    paths = root_paths(roots)
    if not paths:
        return "Клиент не сообщил рабочих папок."
    report = []
    for base in paths:
        count = sum(f.read_text(errors="ignore").count("TODO") for f in base.rglob("*.py"))
        report.append(f"{base.name}: {count}")
    return "Пометок TODO: " + ", ".join(report)


@mcp.tool()
async def read_file(
    path: str,
    roots: Annotated[ListRootsResult, Resolve(workspace_roots)],
) -> str:
    """Прочитать файл, если он лежит в одной из рабочих папок."""
    allowed = root_paths(roots)
    target = Path(path)
    if not inside(target, allowed):
        logger.warning("read_file: отказ, %s вне рабочих папок", path)
        return f"Отказ: {path} находится вне рабочих папок."
    return target.read_text(errors="ignore")


if __name__ == "__main__":
    mcp.run()

Резолвер workspace_roots используется двумя инструментами — это нормально, SDK выполняет его при каждом вызове инструмента. unquote нужен, потому что в URI пробелы и кириллица закодированы процентами. inside — та самая проверка через resolve(), о которой шла речь в прошлом уроке.

Шаг 3. Клиент с подставными корнями

Файл check.py:

python
from pathlib import Path

import anyio

from mcp import Client
from mcp.client.session import ClientRequestContext
from mcp.types import ListRootsResult, Root

from server import mcp

SANDBOX = Path.home() / "mcp-sandbox"


async def my_roots(context: ClientRequestContext) -> ListRootsResult:
    """Играет роль хоста: отдаёт две открытые папки."""
    print("--- сервер попросил список корней ---")
    return ListRootsResult(
        roots=[
            Root(uri=(SANDBOX / "shop-frontend").as_uri(), name="shop-frontend"),
            Root(uri=(SANDBOX / "shop-api").as_uri(), name="shop-api"),
        ]
    )


async def main() -> None:
    async with Client(mcp, list_roots_callback=my_roots) as client:
        result = await client.call_tool("count_todos", {})
        print(result.content[0].text)

        ok = await client.call_tool("read_file", {"path": str(SANDBOX / "shop-api/app/billing.py")})
        print(ok.content[0].text.splitlines()[0])

        bad = await client.call_tool("read_file", {"path": str(SANDBOX / "secret/.env")})
        print(bad.content[0].text)

        sneaky = await client.call_tool(
            "read_file", {"path": str(SANDBOX / "shop-api/../secret/.env")}
        )
        print(sneaky.content[0].text)


anyio.run(main)

Path.as_uri() даёт корректный file://-адрес с абсолютным путём. Запуск uv run python check.py, ожидаемый вывод:

text
--- сервер попросил список корней ---
Пометок TODO: shop-frontend: 1, shop-api: 2
--- сервер попросил список корней ---
def pay():
--- сервер попросил список корней ---
Отказ: /Users/anna/mcp-sandbox/secret/.env находится вне рабочих папок.
--- сервер попросил список корней ---
Отказ: /Users/anna/mcp-sandbox/shop-api/../secret/.env находится вне рабочих папок.

Четыре вызова — четыре запроса корней: сервер ничего не кэширует между вызовами, и это правильно для процесса, который не должен держать состояние. Последний вызов — главный: путь начинается с разрешённой папки, и проверка по строковому префиксу его бы пропустила, а resolve() раскрыл .. и увидел secret.

Шаг 4. Клиент без roots

Уберите list_roots_callback=my_roots и запустите снова. Первый же вызов завершится протокольной ошибкой -32021 (клиент SDK поднимет MCPError): клиент не объявил возможность roots, и сервер отказался её запрашивать. Хосты, которые не поддерживают roots, ведут себя именно так, поэтому сервер, который целиком полагается на roots, в таком хосте бесполезен. Это одна из причин, по которым возможность объявили устаревшей.

Шаг 5. Тот же сервер без roots

Спецификация рекомендует брать папки из конфигурации сервера. Хост запускает stdio-сервер командой из своего конфига, и в эту команду можно передать аргументы. Перепишем сервер так, чтобы разрешённые папки приходили из командной строки. Файл server2.py:

python
import logging
import sys
from pathlib import Path

from mcp.server import MCPServer

logger = logging.getLogger(__name__)
mcp = MCPServer("project-search")

ALLOWED: list[Path] = [Path(arg).resolve() for arg in sys.argv[1:]]


def inside(path: Path) -> bool:
    resolved = path.resolve()
    return any(resolved == r or r in resolved.parents for r in ALLOWED)


@mcp.tool()
async def count_todos() -> str:
    """Посчитать пометки TODO в python-файлах разрешённых папок."""
    if not ALLOWED:
        return "Сервер запущен без разрешённых папок."
    report = [f"{b.name}: {sum(f.read_text(errors='ignore').count('TODO') for f in b.rglob('*.py'))}" for b in ALLOWED]
    return "Пометок TODO: " + ", ".join(report)


@mcp.tool()
async def read_file(path: str) -> str:
    """Прочитать файл из разрешённых папок."""
    target = Path(path)
    if not inside(target):
        return f"Отказ: {path} находится вне разрешённых папок."
    return target.read_text(errors="ignore")


if __name__ == "__main__":
    logger.info("allowed: %s", ALLOWED)
    mcp.run()

Клиент для проверки запускает сервер как подпроцесс и передаёт папки аргументами — ровно так, как это сделает хост:

python
import anyio

from mcp import Client, StdioServerParameters

server = StdioServerParameters(
    command="uv",
    args=["run", "server2.py", "/Users/anna/mcp-sandbox/shop-frontend", "/Users/anna/mcp-sandbox/shop-api"],
)


async def main() -> None:
    async with Client(server) as client:
        print((await client.call_tool("count_todos", {})).content[0].text)
        print((await client.call_tool("read_file", {"path": "/Users/anna/mcp-sandbox/secret/.env"})).content[0].text)


anyio.run(main)

Вывод тот же, минус строки «сервер попросил список корней»: серверу больше не нужно ничего спрашивать. В конфигурации хоста это будет команда uv run server2.py <папка1> <папка2> — и никакой зависимости от того, поддерживает ли хост roots.

Сравните два варианта. Первый гибче: пользователь открыл другую папку — сервер узнал об этом при следующем вызове. Второй надёжнее: список задан оператором, работает в любом хосте, и его нельзя изменить по протоколу. Для ограничения доступа второй лучше; roots хороши как подсказка «где искать», когда хост их даёт.

Шаг 6. Что ушло по проводу

In-memory клиент, как и в практикуме по sampling, вызывает сервер без JSON-RPC-обёртки. Через транспорт обмен для count_todos с современным клиентом выглядит так: первый tools/call возвращает результат input_required, в inputRequests которого лежит запрос roots/list под ключом roots (ключ SDK берёт из имени параметра инструмента) и строка requestState. Клиент вызывает list_roots_callback, кладёт полученный ListRootsResult в inputResponses под тем же ключом и повторяет tools/call с новым id. На повторе резолвер workspace_roots выполняется снова, снова возвращает ListRoots(), и SDK подставляет уже готовый ответ из inputResponses.

У read_file два аргумента, но roots/list всё равно один: SDK выполняет каждый резолвер не более одного раза за раунд, сколько бы параметров от него ни зависело. Если бы у инструмента было три параметра с Resolve(workspace_roots), запрос корней всё равно ушёл бы один раз.

Со старым клиентом на 2025-11-25 сервер отправит roots/list как собственный запрос по обратному каналу и получит ответ, не завершая tools/call. В stderr сервера при этом ничего особенного не появится — SDK не логирует обратные запросы по умолчанию, так что для отладки полезно добавить logger.info в сам резолвер: он выполняется в обоих случаях.

Попробуйте сами

10–15 мин на рабочем месте
  1. В server.py сделайте read_file устойчивым к символическим ссылкам: создайте в shop-api ссылку на ../secret/.env и убедитесь, что чтение через неё отклоняется. Если нет — найдите, где в inside теряется resolve().
  2. Объедините оба подхода: пусть server2.py берёт папки из аргументов, а если их нет — запрашивает roots. Резолвер может вернуть готовый ListRootsResult вместо маркера ListRoots(), когда список уже известен.
  3. Зарегистрируйте server2.py в своём хосте с двумя папками и попросите ассистента посчитать TODO. Посмотрите в stderr сервера (хосты обычно показывают его в логах), что allowed содержит то, что вы передали.

Коротко

  • Резолвер ListRoots() можно переиспользовать в нескольких инструментах; SDK выполняет его при каждом вызове.
  • file://-URI разбираем через urlparse и unquote; путь проверяем после resolve().
  • Путь вида разрешённая/../secret проходит проверку по префиксу и не проходит после resolve().
  • Клиент без возможности roots получает -32021; сервер, зависящий только от roots, в таком хосте бесполезен.
  • Рекомендованная замена — папки аргументами запуска: sys.argv на сервере, StdioServerParameters(args=[...]) у клиента.
  • Roots — гибкая подсказка; конфигурация — надёжное ограничение.

Видеоверсия

Сценарий озвучки · 504 слова, ≈ 4 мин

Строим сервер поиска по проекту, который получает рабочие папки от клиента и не выходит за их пределы. А потом переписываем его так, как рекомендует текущая спецификация.

Сначала готовим песочницу: две папки проектов с питоновскими файлами, в которых есть пометки TODO, и третья папка secret с файлом паролей. Третья нужна, чтобы проверить, что сервер туда не ходит.

Шаг второй — сервер. Резолвер возвращает маркер ListRoots — это просьба к клиенту прислать список корней. Вспомогательная функция превращает адреса в пути: разбирает file-URI, декодирует проценты и раскрывает путь через resolve. Ещё одна функция проверяет, лежит ли путь внутри одного из корней. И два инструмента. Первый считает пометки TODO во всех рабочих папках. Второй читает файл по пути, но только если проверка пройдена, иначе возвращает отказ.

Шаг третий — клиент с подставными корнями. Он подключается к серверу в том же процессе, а колбэк списка корней играет роль хоста и отдаёт две папки проектов. Вызываем четыре раза. Посчитать TODO — получаем: фронтенд один, апи два. Прочитать файл биллинга — получаем первую строку. Прочитать файл паролей из папки secret — отказ. И самое интересное: путь «апи, две точки, secret, точка env». Он начинается с разрешённой папки, и проверка по строковому префиксу его бы пропустила. Но resolve раскрыл переход на уровень выше, увидел secret — и отказ. Заметьте: перед каждым вызовом на экране строка «сервер попросил список корней». Четыре вызова — четыре запроса. Сервер ничего не кэширует, и для процесса без состояния это правильно.

Отдельно о том, что ходит по проводу. Клиент в одном процессе вызывает сервер напрямую, но через транспорт обмен для современного клиента выглядит так: первый вызов инструмента возвращает результат «нужен ввод» с запросом корней и строкой состояния. Клиент вызывает свой колбэк корней, кладёт список в ответы и повторяет вызов с новым идентификатором. На повторе резолвер выполняется снова, SDK видит готовый ответ и подставляет его. И заметьте: у инструмента чтения файла два параметра, но запрос корней уходит один раз — SDK выполняет каждый резолвер не более одного раза за раунд, сколько бы параметров от него ни зависело.

Шаг четвёртый — убираем колбэк корней из клиента. Первый же вызов возвращает ошибку минус тридцать две тысячи двадцать один: клиент не объявил возможность. Хосты без поддержки корней ведут себя именно так. Сервер, который целиком полагается на корни, в таком хосте бесполезен. Это одна из причин, почему возможность объявили устаревшей.

Шаг пятый — тот же сервер без корней. Спецификация рекомендует брать папки из конфигурации. Хост запускает сервер командой из своего конфига, и в неё можно передать аргументы. Переписываем: разрешённые папки читаем из аргументов командной строки при старте. Инструменты те же, только ничего не спрашивают у клиента. Клиент для проверки запускает сервер как подпроцесс и передаёт папки аргументами — ровно так, как сделает хост. Вывод тот же, без строк про запрос корней.

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

На этом первый модуль закончен. Дальше спускаемся на уровень провода: в следующем уроке разберём, как устроены сами сообщения протокола.

Отметка хранится только в вашем браузере