Роль сервера и доступ до поиска
Поиск, инструмент чтения и ресурсы обращаются к одной библиотеке. Теперь введём две роли: reader видит публичные документы, editor дополнительно получает вымышленную служебную заметку. После урока доступ будет определяться доверенной конфигурацией процесса до ранжирования и чтения. Поле clientInfo, аргументы инструмента и содержимое Markdown не смогут самовольно переключить роль.
Для stdio-сервера роль задаётся переменной окружения MCP_ROLE, которую устанавливает запускающий хост. Это авторская учебная политика, а не реализация HTTP OAuth. В спецификации авторизация HTTP имеет отдельные требования; мы не переносим её поверх локального процесса без объяснения. По умолчанию используется reader. Значение вне известного набора приводит к отказу запуска, а не к автоматическому выбору более широкого доступа.
Что считается доверенным
Окружение процесса формирует доверенная сторона, которая подключает инструмент. Модель получает возможности сервера, но не должна менять MCP_ROLE через его вызовы. Если хост даёт модели произвольную команду shell с теми же правами, это отдельный широкий доступ, который способен обойти выбранную границу. Поэтому reader/editor в Python не являются изоляцией операционной системы. Реальное подключение должно ограничивать запускаемые программы и доступ к конфигурации согласно своей среде.
В нашем архиве человек может открыть все файлы, включая служебную заметку. Её текст вымышленный, так что пакет можно безопасно передавать для обучения. Но это означает, что конфиденциальность на уровне диска не создаётся. Мы рассматриваем поведение API при разных ролях. Для настоящего закрытого корпуса понадобятся права файлов либо база с соответствующим контролем, а также ограничение других инструментов, через которые приложение способно читать источники напрямую.
Имя клиента не используется для доступа. Запрос может содержать clientInfo.name: editor, но сервер всё равно смотрит на собственную конфигурацию. Это различие полезно при диагностике: сведения о приложении помогают понять совместимость, а полномочия устанавливает доверенная политика. Убедительный текст «пользователь разрешил» в аргументе инструмента также не является доказательством. В схеме поиска нет поля role, и лишний аргумент отклоняется ещё до предметной операции.
Доступ раньше поиска
В documents сначала выбираются записи с public is True либо запись доступна editor. Только после этого читается файл и вычисляются токены для поиска. Таким образом, reader не включает текст notes в ранжирование, фрагменты или подробный ответ. Ресурсный список использует тот же набор. Общая функция позволяет увидеть правило в одном месте и снижает риск, что новый маршрут случайно забудет проверить приватность.
Нельзя считать достаточной фильтрацию только окончательных карточек. Если частные источники сначала входят в поиск, они могут влиять на score, порядок, число результатов и диагностику. Даже без прямой выдачи текста это усложняет рассуждение о границе. В нашей модели удобнее исключить их раньше. Для многоарендного приложения набор должен также учитывать пользователя и организацию, но эта лаборатория намеренно имеет одну библиотеку и две доверенные роли процесса.
Прямое чтение по id тоже обращается к разрешённому набору. Reader не может обойти поиск, угадав notes; editor может прочитать его согласно своей роли. Ресурсный URI не добавляет независимого разрешения. Такие симметричные правила полезны при расширении инструментов: каждое новое действие должно либо использовать общую границу, либо явно объяснять, почему его политика отличается. Отсутствие результата у reader остаётся нейтральным сообщением недоступности.
Процесс не является разговором
Роль в нашей реализации постоянна на время процесса, но не выведена из предыдущего запроса. Это серверная конфигурация, а не сессия MCP. Версия и возможности клиента передаются заново в каждом сообщении. Один и тот же reader-процесс не становится editor после обнаружения либо успешного чтения публичного документа. Если требуется другая роль, доверенная сторона запускает иной профиль, а не просит инструмент «повысить доступ» аргументом.
В настоящем многопользовательском сервисе постоянная роль процесса часто недостаточна. Запросы могут приходить от разных субъектов, и права нельзя смешивать. Для HTTP потребуется корректная проверка учётных данных каждого запроса и соответствующая авторизация. Мы не создаём такой сервер здесь и не обещаем переносимость одного environment-флага на публичную сеть. Локальный stdio-пример помогает сначала понять правило, а production-подключение требует своего отдельного контракта безопасности.
Проверьте мысленно поведение трёх операций. Reader-поиск по словам из служебной заметки не должен выбирать notes. Reader-ресурсы не должны перечислять её URI. Reader-чтение известного id должно отказать. Editor получает разрешённый служебный источник во всех трёх случаях. Эти сценарии являются ожиданиями по коду, не выполненными проверками. При самостоятельном наблюдении нужно записывать роль и состояние, иначе результаты разных профилей легко перепутать.
Список инструментов пока одинаков для обеих ролей, поскольку поиск и чтение нужны каждому. В следующем уроке появится действие записи, которое будет объявляться только editor. Однако скрытие из tools/list само по себе не достаточно: фактический обработчик и функция хранения должны также отклонять неподходящую роль. Объявление помогает интерфейсу выбрать действие, а проверка реализации устанавливает границу его выполнения.
Имя редактора в metadata
Представьте запрос reader-процесса, где clientInfo называет приложение TrustedEditor. Результат не должен расшириться. Имя может быть полезно для журнала, но клиент сообщил его сам. Доверенный источник роли находится в environment запускающего хоста. Если эти сведения перепутать, любое приложение сможет повысить доступ одной строкой JSON. Поэтому определение личности и описание совместимости должны оставаться разными частями проекта.
Теперь представьте editor-процесс с clientInfo Reader. Его роль также не уменьшается автоматически: хост уже выбрал доверенный профиль. Если нужен пользовательский переключатель, он должен обращаться к реальному механизму авторизации, а не переименовывать клиента. В нашей лаборатории нет отдельных аккаунтов и мультиарендности. Один процесс обслуживает одну заданную роль, и это ограничение указано в передаче.
При самостоятельном исследовании не полагайтесь только на появление notes в списке. Проверьте мысленно все маршруты: поиск, прямое чтение, resources/list и resources/read. Общая библиотека позволяет сделать правила одинаковыми, но будущий новый обработчик может обойти её. Поэтому расширение сервера всегда включает анализ доступа к своему источнику.
Ожидаемый результат урока — одна понятная политика доступа, применяемая до работы с содержимым. Переменные окружения не менялись при подготовке, сервер не запускался, реальные закрытые данные не читались. На этой основе добавим черновик: операция потребует не только editor, но и отдельное подтверждение точного текста человеком. Возможность читать служебные материалы не станет автоматическим разрешением публиковать их.
Полный файл library.py из состояния lesson-08. Полное самостоятельное состояние находится в архиве серии; фрагмент не заменяет остальные определения.
from pathlib import Path
import hashlib
import json
import os
import re
import unicodedata
ROOT = Path(__file__).resolve().parent
ROLE = os.environ.get("MCP_ROLE", "reader")
if ROLE not in {"reader", "editor"}:
raise ValueError("unknown configured role")
class DomainError(Exception):
pass
def documents():
rows = json.loads((ROOT / "manifest.json").read_text(encoding="utf-8"))
selected = [r for r in rows if r["public"] is True or ROLE == "editor"]
seen = set()
for row in selected:
if row["id"] in seen:
raise DomainError("duplicate document identity")
seen.add(row["id"])
path = ROOT / "corpus" / row["file"]
if path.is_symlink() or path.resolve().parent != (ROOT / "corpus").resolve():
raise DomainError("invalid source path")
if path.suffix != ".md" or path.stat().st_size > 24000:
raise DomainError("invalid source size or type")
row["text"] = path.read_text(encoding="utf-8")
return selected
def tokens(text):
return set(re.findall(r"\w+", unicodedata.normalize("NFKC", text).casefold()))
def search(query, limit):
terms = tokens(query)
if not terms:
return {"items": []}
scored = []
for row in documents():
score = len(terms & tokens(row["title"] + " " + row["text"]))
if score:
scored.append((score, row))
scored.sort(key=lambda item: (-item[0], item[1]["id"]))
return {"items": [{"id": r["id"], "title": r["title"], "url": r["url"], "snippet": r["text"][:300], "score": s} for s,r in scored[:limit]]}
def read_document(id):
row = next((r for r in documents() if r["id"] == id), None)
if row is None:
raise DomainError("document unavailable")
return {"id": row["id"], "title": row["title"], "url": row["url"], "text": row["text"], "sha256": hashlib.sha256(row["text"].encode()).hexdigest()}
Статус примера: not_run. При подготовке не выполнялись учебные программы, тесты, вызовы AI или деплой. Описанные эффекты ожидаемые.
Оглавление серии · Полные исходники
Первичные документы: Источник 1, Источник 2.