Roots
Сервер, который читает файлы, должен знать, где ему можно читать. Хост это знает — у него открыт конкретный проект, — а сервер запускается как отдельный процесс и не знает ничего. Roots закрывают этот разрыв: клиент сообщает серверу список папок, с которыми имеет смысл работать. К концу урока вы будете понимать, что именно roots обещают, как их запросить и чем их заменяют в новых серверах.
Что такое root
Root («корень») — это URI папки или файла плюс необязательное имя для отображения:
{
"uri": "file:///Users/anna/work/shop-frontend",
"name": "shop-frontend"
}В текущей спецификации uri обязан быть file://-адресом. Клиент может отдать один корень (открытый проект) или несколько — например, репозитории фронтенда и бэкенда, которые разработчик держит в одном окне.
Откуда клиент берёт этот список, спецификация не диктует. Обычно это открытая в редакторе рабочая область, папка, из которой запущен Claude Code, или явный выбор пользователя в настройках.
Что roots гарантируют и чего нет
Здесь важно быть точным, потому что название обманывает. Roots — это информация, а не ограничение. Спецификация прямо говорит: протокол не следит за тем, чтобы сервер оставался внутри корней. Сервер, который получил file:///Users/anna/work/shop-frontend, технически может прочитать /etc/passwd, и протокол ему не помешает.
Так что roots работают в две стороны как договорённость. Клиент обещает отдавать только те папки, к которым у пользователя есть права, и проверять URI на подмену пути. Сервер обещает уважать границы: проверять каждый путь, с которым работает, на вхождение в один из корней, и корректно обрабатывать ситуацию, когда корень исчез. Хороший сервер файловой системы делает это; плохой — нет, и roots ему не помеха.
Из этого следствие для проектирования: если вам нужно настоящее ограничение доступа, оно должно быть на стороне сервера и не зависеть от доброй воли протокола — запуск в контейнере, отдельный пользователь, явный список разрешённых путей в конфигурации.
Как это выглядит на проводе
Клиент, который поддерживает roots, объявляет возможность roots. Сервер запрашивает список методом roots/list; параметров у него нет. Ответ:
{
"roots": [
{ "uri": "file:///Users/anna/work/shop-frontend", "name": "shop-frontend" },
{ "uri": "file:///Users/anna/work/shop-api", "name": "shop-api" }
]
}Как и у sampling, способ доставки зависит от ревизии. В 2025-11-25 roots/list — запрос сервера к клиенту по обратному каналу. Клиент при этом мог объявить roots.listChanged и присылать уведомление notifications/roots/list_changed, когда пользователь открыл другую папку; сервер в ответ перезапрашивал список. В 2026-07-28 сервер не отправляет запросов, поэтому roots/list вкладывается в результат input_required точно так же, как sampling/createMessage в уроке «Sampling»: клиент отвечает списком в inputResponses и повторяет исходный вызов. Уведомление об изменении списка в новой ревизии исчезло — у сервера всё равно нет долгоживущего состояния, которое нужно было бы обновлять.
Если сервер попросит roots у клиента, который возможность не объявил, вызов инструмента завершится ошибкой -32021.
Roots в SDK
Механизм тот же, что и для sampling: резолвер возвращает маркер ListRoots(), а параметр инструмента получает ListRootsResult:
from pathlib import Path
from typing import Annotated
from urllib.parse import urlparse
from mcp.server import MCPServer
from mcp.server.mcpserver import ListRoots, Resolve
from mcp.types import ListRootsResult
mcp = MCPServer("project-search")
def workspace_roots() -> ListRoots:
return ListRoots()
def root_paths(roots: ListRootsResult) -> list[Path]:
"""Превратить file://-URI в пути. Всё, что не file://, отбрасываем."""
paths = []
for root in roots.roots:
parsed = urlparse(str(root.uri))
if parsed.scheme == "file":
paths.append(Path(parsed.path).resolve())
return paths
@mcp.tool()
async def count_todos(
roots: Annotated[ListRootsResult, Resolve(workspace_roots)],
) -> str:
"""Посчитать пометки TODO в исходниках рабочей области."""
paths = root_paths(roots)
if not paths:
return "Клиент не сообщил рабочих папок."
total = 0
for base in paths:
for file in base.rglob("*.py"):
total += file.read_text(errors="ignore").count("TODO")
return f"Найдено пометок TODO: {total} в {len(paths)} папках."Резолвер workspace_roots не принимает аргументов — ему просто нечего спрашивать у инструмента. SDK выполняет его, получает список от клиента и подставляет результат. root.uri в SDK v2 — строка; мы разбираем её через urlparse и берём только file://.
Старый способ — await ctx.session.list_roots() — сохранился для совместимости, но выдаёт MCPDeprecationWarning и работает только на соединениях с клиентами ревизии 2025-11-25.
Проверка путей
Если инструмент принимает путь от модели, а не берёт его из roots, проверка вхождения обязательна:
def inside_roots(path: Path, roots: list[Path]) -> bool:
resolved = path.resolve()
return any(resolved == r or r in resolved.parents for r in roots)resolve() здесь не для красоты: он раскрывает символические ссылки и .., без него shop-frontend/../../etc пройдёт проверку по строковому префиксу. Такую функцию стоит вызывать в каждом инструменте, который получает путь снаружи, и возвращать понятную ошибку, а не читать файл.
Статус: устарело
Roots помечены устаревшими в ревизии 2026-07-28 (SEP-2577), с тем же горизонтом, что sampling и логирование: остаются в спецификации минимум до июля 2027 года. Рекомендованная замена — три варианта на выбор. Передавать папку аргументом инструмента: модель знает, над каким проектом работает пользователь, и может передать путь явно. Использовать URI ресурсов, которые сервер сам публикует. Или задавать разрешённые папки в конфигурации сервера при запуске — например, аргументом командной строки в конфигурации хоста, как это делает эталонный сервер файловой системы.
Третий вариант для ограничения доступа честнее всех: путь задан оператором, а не пришёл по протоколу, и сервер может проверять по нему любой ввод. Roots в этой картине остаются удобной подсказкой для хостов, которые их поддерживают, — не более.
Попробуйте сами
10–15 мин на рабочем месте- Посмотрите, какие серверы в вашем хосте работают с файлами, и выясните из их документации, откуда они узнают разрешённые папки: из roots, из аргументов запуска или из конфигурации.
- Напишите функцию
inside_rootsиз урока и проверьте её на трёх путях: внутри корня, с..наружу и через символическую ссылку, ведущую наружу. Последний случай особенно показателен. - Прочитайте раздел Roots спецификации и найдите, что обязан делать клиент перед тем, как отдать корень серверу.
Коротко
- Root —
file://-URI папки или файла с необязательным именем; клиент отдаёт список запросомroots/list. - Roots — информация, а не ограничение: протокол не мешает серверу выходить за их пределы; сервер обязан проверять пути сам.
- В
2025-11-25roots/list— запрос сервера по обратному каналу; в2026-07-28— вложен в результатinput_required(MRTR). - В SDK v2: резолвер возвращает
ListRoots(), инструмент получаетListRootsResultсо спискомroots. - Проверка вхождения пути — через
resolve(), а не строковый префикс. - Возможность устарела с
2026-07-28; замена — аргумент инструмента, URI ресурса или конфигурация сервера.
Видеоверсия
Сценарий озвучки · 463 слова, ≈ 4 мин
Сервер, который читает файлы, должен знать, где ему можно читать. Хост это знает: у него открыт конкретный проект. А сервер запускается отдельным процессом и не знает ничего. Roots, или корни, закрывают этот разрыв: клиент сообщает серверу список папок, с которыми имеет смысл работать.
Что такое корень. Это адрес папки или файла в виде file-URI плюс необязательное имя для отображения. Клиент может отдать один корень, например открытый проект, или несколько — репозитории фронтенда и бэкенда, которые разработчик держит в одном окне. Откуда клиент берёт список, спецификация не диктует: обычно это открытая рабочая область, папка, из которой запущен Claude Code, или выбор пользователя.
Теперь самое важное, потому что название обманывает. Корни — это информация, а не ограничение. Спецификация прямо говорит: протокол не следит, чтобы сервер оставался внутри корней. Сервер, получивший папку проекта, технически может прочитать любой файл на диске. Поэтому корни работают как договорённость в две стороны. Клиент обещает отдавать только те папки, к которым у пользователя есть права, и проверять адреса на подмену пути. Сервер обещает уважать границы и проверять каждый путь. Хороший сервер так делает, плохой — нет. Вывод для проектирования: если нужно настоящее ограничение доступа, оно должно быть на стороне сервера и не зависеть от протокола — контейнер, отдельный пользователь, явный список разрешённых путей в конфигурации.
Как это выглядит на проводе. Клиент объявляет возможность roots. Сервер запрашивает список методом roots/list без параметров и получает массив корней. Способ доставки зависит от ревизии, и здесь всё так же, как с sampling. В старой ревизии это запрос сервера к клиенту по обратному каналу, и клиент мог присылать уведомление, что список изменился. В новой ревизии сервер не отправляет запросов, поэтому запрос корней вкладывается в результат «нужен ввод», клиент отвечает списком и повторяет вызов инструмента. Уведомление об изменении списка в новой ревизии исчезло.
В Python SDK механизм тот же, что для sampling. Пишете резолвер, который возвращает маркер ListRoots, и объявляете параметр инструмента как зависимость от него. SDK получает список от клиента и подставляет результат. В примере из урока инструмент считает пометки TODO во всех питоновских файлах рабочих папок: превращает адреса в пути, берёт только file-адреса и обходит их.
Отдельно про проверку путей. Если инструмент принимает путь от модели, обязательно проверьте, что он лежит внутри одного из корней. И проверяйте не по строковому префиксу, а после раскрытия символических ссылок и переходов на уровень выше. Иначе путь «проект, две точки, две точки, etc» пройдёт проверку по префиксу и выведет наружу.
И статус. Корни помечены устаревшими в последней ревизии вместе с sampling и логированием, остаются в спецификации минимум год. Замена — три варианта: передавать папку аргументом инструмента, использовать адреса ресурсов, которые сервер сам публикует, или задавать разрешённые папки в конфигурации сервера при запуске. Последний вариант честнее всех: путь задан оператором, а не пришёл по протоколу.
В следующем уроке построим сервер, который получает корни от клиента, и проверим, что он не выходит за их пределы.
