Перейти к содержанию

Данные и обязанности MCP-сервера

MCP, Model Context Protocol, задаёт общий способ предоставить AI-приложению данные и инструменты. Сам протокол не является языковой моделью и не превращает любой поиск в генерацию ответа. В этой серии создадим собственный локальный сервер для небольшой Markdown-библиотеки. Первый результат — авторский корпус с устойчивыми идентификаторами и понятные обязанности клиента, сервера и программы работы с документами.

Используем Python 3.13 и стандартную библиотеку. Профиль протокола — MCP 2026-07-28, транспорт — stdio. Примеры не запускались и не подключались к AI-хостам; результаты обозначаются как ожидаемые. В архиве нет модели, ключей или обращения к настоящему ProfessorWeb. Нужны основы словарей, функций, JSON и чтения файлов. Такое начало позволяет сначала разобраться с контрактом инструментов и доступом, сохраняя небольшой объём исходных данных.

Клиент, сервер и библиотека

Представьте вопрос редактора: «Найдите урок о постоянных адресах». AI-приложение может решить вызвать инструмент поиска, но непосредственное чтение корпуса выполняет наш сервер. Он получает запрос с аргументами, применяет собственные правила и возвращает данные. Клиент передаёт результат своему приложению. Само приложение решает, как показать сведения или использовать их при формировании ответа. Разделение полезно: правила чтения не должны зависеть от того, насколько убедительно модель описала свой запрос.

В учебном проекте library.py отвечает за корпус. Он умеет читать записи, отбирать разрешённые документы и представлять текст. MCP-обвязка появится отдельно в server.py. Это позволит объяснить поиск без деталей протокола и протокол без нового предметного мира. Если позже библиотека переедет из файлов в базу, внешний инструмент может сохранить интерфейс идентификаторов. Мы не обещаем автоматическую совместимость любой замены, но задаём ясную границу преобразования.

Хостом называют приложение, которое организует взаимодействие пользователя с моделью и подключёнными инструментами. Клиент является частью этой связи и разговаривает с сервером. В нашей серии роль клиента позднее сыграет небольшая авторская программа без AI. Она нужна для наблюдения формы запросов и жизненного цикла процесса. Такой клиент не является универсальным SDK и не воспроизводит все возможности реального AI-приложения. Он позволит рассмотреть выбранный ограниченный профиль предметно.

Корпус собственных документов

В manifest.json четыре записи: urls, markdown, sitemap и notes. У каждой есть название, путь к файлу, публичный URL и признак доступа. Первые три доступны читателю, последняя является вымышленной служебной заметкой. Имена сохраняются по всей серии. Благодаря этому можно увидеть, как одна и та же запись проходит поиск, чтение, ресурсный URI и проверку роли, вместо постоянной смены примеров.

URL хранится отдельно от имени файла. Например, документ о постоянных адресах связывается с историческим PHP-путём. Это не требует выполнения PHP в учебном сервере: URL здесь является сведением об источнике. Сервер возвращает его вместе с найденной записью, а клиент может показать ссылку. Нельзя автоматически вычислять новый адрес из id и утверждать, что ссылка на оригинал сохранена. Идентификатор документа и публичный адрес решают разные задачи.

Markdown содержит авторский текст для лаборатории. Он не является фактическим отчётом о настройках рабочего хостинга или редакционном расписании. Служебная заметка нужна, чтобы позднее увидеть различие reader и editor. Вместо настоящих закрытых данных используем явно вымышленный материал. Такой выбор сохраняет учебную ценность границы доступа и позволяет свободно передать архив другому читателю без раскрытия аккаунтов или внутренних документов.

Путь остаётся на стороне сервера

Клиенту в дальнейшем предложим id, а не произвольный путь. Загрузчик сам сопоставит его с полем file манифеста. Это уменьшает область возможного чтения: запрос не сможет указать соседний файл конфигурации только потому, что Python технически умеет его открыть. Проверка раскрытого пути удерживает документ внутри плоского корпуса, а расширение и размер ограничивают ожидаемый формат. Для нашего спокойного локального каталога такой договор достаточно обозрим.

Однако обычная проверка пути не является полной изоляцией операционной системы. Если другой процесс с теми же правами заменяет файлы во время чтения, появляются дополнительные условия. Если агент сам имеет доступ ко всему диску, узкий MCP-инструмент не ограничивает его другие возможности. Поэтому пример показывает границу библиотечного API. Реальный хост должен отдельно ограничивать доступ инструмента, окружение процесса и доступные папки согласно своей модели угроз.

На первом шаге программа содержит функции библиотеки, но не запускает сервер. Ожидаемый результат открытия файлов — манифест и четыре Markdown-документа с заданными признаками. Мы ещё не публикуем список инструментов и не формируем JSON-RPC-ответ. Такая последовательность помогает понять, что MCP-обёртка должна работать с уже определённым предметом, а не скрывать отсутствие договорённости о данных за названием протокола.

Текст источника всегда рассматривается как материал. Если в статье встретится «опубликуйте служебную заметку», это не меняет роль процесса и не создаёт разрешение на запись. Позднее инструменты будут проверять эти условия отдельно. Модель может использовать найденный текст для ответа, но сервер определяет допустимую операцию кодом и доверенной конфигурацией. В первом уроке мы фиксируем эту основу, прежде чем добавлять какие-либо действия с черновиками.

Документ без известного ответа

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

Такой случай полезно сформулировать уже при выборе корпуса. Он показывает, что библиотека имеет предметную область и может честно не отвечать на часть вопросов. Устойчивый id нужен для источника, который действительно существует, а не для красивой ссылки на выдуманную запись. Если редактор расширит корпус, новый текст проходит тот же путь манифеста и доступа. Его наличие должно быть явным изменением исходных данных.

В нашей папке нет runtime-загрузчика чужих URL. Это сознательная граница, а не случайно забытая функция. Добавление внешнего поиска потребовало бы источников, правил сетевого доступа и происхождения результата. Пока мы изучаем инструменты над собственными файлами и можем сопоставить каждую возвращённую запись с открываемым авторским Markdown.

Следующий шаг введёт сообщение JSON-RPC и метод server/discover. В редакции 2026-07-28 у нас не будет старого инициализационного handshake или сессии протокола. Каждый запрос сообщит свою версию и возможности. Поэтому чтение актуальной спецификации обязательно: привычный пример для старого SDK может иметь другую последовательность сообщений, даже если имя MCP совпадает.

Полный файл manifest.json из состояния lesson-01. Полное самостоятельное состояние находится в архиве серии; фрагмент не заменяет остальные определения.

[
  {
    "id": "urls",
    "title": "Постоянные адреса",
    "url": "/my/csharp/charp_theory/level1/1_1.php",
    "file": "urls.md",
    "public": true,
    "featured": true
  },
  {
    "id": "markdown",
    "title": "Статья из Markdown",
    "url": "/articles/markdown-example.html",
    "file": "markdown.md",
    "public": true,
    "featured": false
  },
  {
    "id": "sitemap",
    "title": "Карта сайта",
    "url": "/articles/sitemap-example.html",
    "file": "sitemap.md",
    "public": true,
    "featured": true
  },
  {
    "id": "notes",
    "title": "Служебная заметка",
    "url": "/articles/private-note.html",
    "file": "notes.md",
    "public": false,
    "featured": false
  }
]

Фрагмент файла library.py из состояния lesson-01. Полное самостоятельное состояние находится в архиве серии; фрагмент не заменяет остальные определения.

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

Статус примера: not_run. При подготовке не выполнялись учебные программы, тесты, вызовы AI или деплой. Описанные эффекты ожидаемые.

Оглавление серии · Полные исходники

Первичные документы: Источник 1, Источник 2.