Roots на практике
В прошлом уроке мы выяснили, что roots — это подсказка, а не забор, и что забор сервер должен ставить сам. Сейчас построим сервер project-search, который получает корни от клиента, ищет по ним и отказывается читать что-либо снаружи. А в конце перепишем его так, как рекомендует текущая спецификация, — без roots.
Шаг 1. Тестовый проект
Серверу нужно, в чём искать. Создайте рядом с проектом сервера две папки с парой файлов:
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:
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:
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, ожидаемый вывод:
--- сервер попросил список корней ---
Пометок 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:
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()Клиент для проверки запускает сервер как подпроцесс и передаёт папки аргументами — ровно так, как это сделает хост:
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 мин на рабочем месте- В
server.pyсделайтеread_fileустойчивым к символическим ссылкам: создайте вshop-apiссылку на../secret/.envи убедитесь, что чтение через неё отклоняется. Если нет — найдите, где вinsideтеряетсяresolve(). - Объедините оба подхода: пусть
server2.pyберёт папки из аргументов, а если их нет — запрашивает roots. Резолвер может вернуть готовыйListRootsResultвместо маркераListRoots(), когда список уже известен. - Зарегистрируйте
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 выполняет каждый резолвер не более одного раза за раунд, сколько бы параметров от него ни зависело.
Шаг четвёртый — убираем колбэк корней из клиента. Первый же вызов возвращает ошибку минус тридцать две тысячи двадцать один: клиент не объявил возможность. Хосты без поддержки корней ведут себя именно так. Сервер, который целиком полагается на корни, в таком хосте бесполезен. Это одна из причин, почему возможность объявили устаревшей.
Шаг пятый — тот же сервер без корней. Спецификация рекомендует брать папки из конфигурации. Хост запускает сервер командой из своего конфига, и в неё можно передать аргументы. Переписываем: разрешённые папки читаем из аргументов командной строки при старте. Инструменты те же, только ничего не спрашивают у клиента. Клиент для проверки запускает сервер как подпроцесс и передаёт папки аргументами — ровно так, как сделает хост. Вывод тот же, без строк про запрос корней.
Сравним. Вариант с корнями гибче: пользователь открыл другую папку — сервер узнает при следующем вызове. Вариант с конфигурацией надёжнее: список задан оператором, работает в любом хосте, и его нельзя поменять по протоколу. Для ограничения доступа — второй. Корни хороши как подсказка, где искать, когда хост их даёт.
На этом первый модуль закончен. Дальше спускаемся на уровень провода: в следующем уроке разберём, как устроены сами сообщения протокола.
