docs: publish translated docs in twelve languages and the tool that maintains them (#3280)

This commit is contained in:
Max 2026-08-14 17:07:34 +01:00 • committed by GitHub
commit 52ad0a8876
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 89737 additions and 54 deletions

View file

@ -0,0 +1,123 @@
---
translation:
sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
tool: 1
---
# MCP Apps {#mcp-apps}
**MCP App** — это инструмент с собственным лицом: помимо данных, инструмент указывает на HTML-документ, который хост отображает как интерактивную поверхность.
Две части, всегда две:
1. **Инструмент**, который делает работу и возвращает данные, как любой другой инструмент.
2. **Ресурс `ui://`** с HTML, который хост показывает для этого инструмента.
Инструмент несёт ссылку на ресурс в `_meta.ui.resourceUri`. Хост получает его через `resources/read`, отображает в **изолированном iframe** (песочнице) и передаёт результат инструмента в этот iframe через `postMessage`. Ваш сервер никогда не отправляет и не принимает сообщений `ui/*`: этот трафик идёт между хостом и iframe. Вы отдаёте инструмент и HTML-документ, а всё представление устраивает хост.
В SDK это встроенное расширение `Apps` (`io.modelcontextprotocol/ui`). Если [расширения](extensions.md) вам в новинку, сначала пробегите ту страницу. Одна минута — и возвращайтесь.
## Часы с циферблатом {#a-clock-with-a-face}
```python title="server.py" hl_lines="19 22 30 32"
--8<-- "docs_src/apps/tutorial001.py"
```
Четыре шага:
* `Apps()`: один экземпляр хранит инструменты, привязанные к UI, и их ресурсы.
* `@apps.tool(resource_uri="ui://clock/app.html")`: обычный инструмент плюс отметка `_meta.ui.resourceUri`. Всё, что принимает `@mcp.tool()` (name, title, description, ...), передаётся дальше.
* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: парный ресурс, который отдаётся как `text/html;profile=mcp-app`. Именно этот MIME-тип говорит хосту: «это приложение, отобрази его».
* `MCPServer("clock", extensions=[apps])`: подключение расширения. Теперь сервер объявляет `io.modelcontextprotocol/ui` в `capabilities.extensions`.
Сам HTML слушает `postMessage` от хоста и показывает результат. В настоящих приложениях используйте внутри HTML официальный браузерный SDK [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps). Он даёт `ontoolresult`, `callServerTool`, `getHostContext` и `onhostcontextchanged` вместо сырых событий сообщений.
## Корректная деградация {#graceful-degradation}
Не каждый клиент отображает приложения. Спецификация прямо говорит, что это значит для вас:
> Инструменты **ДОЛЖНЫ** возвращать осмысленный массив `content`, даже когда UI доступен.
Модель читает `content`; iframe — для людей. Хост с поддержкой UI всё равно передаёт текстовый результат модели, а чисто текстовый клиент получает *только* его. Поэтому канонический паттерн — один инструмент, два ответа. Взгляните на `get_time` ещё раз:
```python title="server.py" hl_lines="23-27"
--8<-- "docs_src/apps/tutorial001.py"
```
`client_supports_apps(ctx)` возвращает `True`, только когда клиент объявил расширение `io.modelcontextprotocol/ui` **и** указал `text/html;profile=mcp-app` в настройке `mimeTypes`. Поле обязательное, так что клиент, который его опустил, не считается. Именно это объявляет `main()` в том же файле: клиентскую половину согласования — и в ответ приходит развёрнутый результат.
!!! warning
Никогда не возвращайте заглушку вроде `"[Rendered UI]"` в качестве единственного содержимого. Если запасной текст бесполезен, инструмент бесполезен для любого текстового клиента и для самой модели. Напишите нормальное предложение.
## Ограничение iframe {#locking-the-iframe-down}
Метаданные безопасности несёт ресурс: что iframe может загружать, какие разрешения браузера ему нужны, как его хотелось бы встроить:
```python title="server.py" hl_lines="9 19-22"
--8<-- "docs_src/apps/tutorial002.py"
```
`csp` и `permissions` — это **просьбы к хосту**, а не поведение сервера. Хост строит по ним Content-Security-Policy и Permissions-Policy для iframe и может отказать. Проверяйте наличие возможности в своём JS, а не рассчитывайте, что разрешение выдано.
`ResourceCsp`, поле за полем (имя в Python, ключ в передаваемых данных, что с ним делает хост):
| Python | В передаваемых данных (`_meta.ui.csp`) | Что контролирует |
|---|---|---|
| `connect_domains` | `connectDomains` | `connect-src`: куда могут обращаться `fetch`/XHR |
| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: статические ресурсы |
| `frame_domains` | `frameDomains` | `frame-src`: вложенные iframe |
| `base_uri_domains` | `baseUriDomains` | `base-uri`: на что может указывать `<base>` |
`ResourcePermissions`: каждое поле запрашивает для iframe разрешение браузера.
| Python | В передаваемых данных (`_meta.ui.permissions`) |
|---|---|
| `camera` | `camera` |
| `microphone` | `microphone` |
| `geolocation` | `geolocation` |
| `clipboard_write` | `clipboardWrite` |
!!! note
CSP и разрешения живут на **ресурсе**, никогда не на инструменте. В метаданных инструмента по спецификации для них нет места, и хосты их там игнорируют. SDK делает эту ошибку невозможной в принципе: у `@apps.tool()` просто нет параметра `csp`.
### Видимость {#visibility}
`visibility=["app"]` на инструменте говорит: «это существует для iframe, а не для модели»:
* `"model"`: модель может его вызывать.
* `"app"`: iframe может его вызывать (через `callServerTool`).
* Не указано: и то и другое, это значение по умолчанию.
Фильтрация — задача **хоста**. Сервер перечисляет инструменты только для приложения в `tools/list`, как и любые другие; хост скрывает их от модели. Не фильтруйте на стороне сервера.
## Правила, за которыми следит SDK {#the-rules-the-sdk-enforces}
Всё это падает при запуске, а не в рабочей среде:
* `resource_uri` или URI ресурса, не начинающийся с `ui://...`, — это `ValueError` в момент декорирования или регистрации.
* Инструмент, привязанный к URI **без соответствующего зарегистрированного ресурса**, — это `ValueError`, когда `MCPServer(extensions=[apps])` обрабатывает расширение. Инструмент, объявляющий HTML, который отдаёт 404 на `resources/read`, — это ошибка конфигурации, поэтому сервер отказывается создаваться.
* `meta={"ui": ...}` на `@apps.tool()` — это `ValueError`. Ключом `_meta["ui"]` владеет декоратор; выражайте это через `resource_uri=` и `visibility=`. Остальные ключи `meta=` спокойно объединяются рядом.
Ни TypeScript SDK ext-apps, ни FastMCP сегодня ничего из этого не ловят; лучше узнать об этом раньше, чем узнает хост.
## Не только встроенный HTML {#beyond-inline-html}
`add_html_resource` покрывает типичный случай: строку HTML. Для всего остального — HTML на диске или генерируемого содержимого — постройте ресурс сами и передайте его:
```python title="server.py" hl_lines="12 18"
--8<-- "docs_src/apps/tutorial003.py"
```
`add_resource` подставляет MIME-тип `text/html;profile=mcp-app`, когда ресурс не задаёт его явно, и отклоняет явное несоответствие: ресурс `ui://` с любым другим MIME-типом не отобразит ни один хост.
!!! tip
Ориентируетесь на хост, выпущенный до GA, который всё ещё читает устаревший плоский ключ `_meta["ui/resourceUri"]`? Добавьте его сами:
`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`.
Вложенный объект `ui` — это форма по спецификации; плоский ключ доживает последние дни.
## Запуск примера {#see-it-run}
Сценарий `apps` в `examples/stories/` — это эта страница в виде готовой к запуску пары: сервер с привязанным к UI инструментом-часами и клиент, который согласовывает Apps, читает `_meta.ui.resourceUri` инструмента, получает HTML и вызывает инструмент.
```bash
uv run python -m stories.apps.client
```

View file

@ -0,0 +1,172 @@
---
translation:
sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff]
tool: 1
---
# Расширения {#extensions}
**Расширение** — это набор поведения MCP, который включается только по желанию и объединён одним идентификатором.
На сервере оно может добавлять инструменты, ресурсы и новые методы запросов, а также оборачивать `tools/call`. На клиенте — заявлять дополнительные формы результата `tools/call` и наблюдать за вендорными уведомлениями. Каждая сторона объявляет его в собственном `capabilities.extensions`, и для тех, кто об этом не просил, ничего не меняется. Таков контракт ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), и у него одно золотое правило: **по умолчанию расширения выключены**.
## Использование расширения {#using-an-extension}
Передайте экземпляры при создании:
```python title="server.py"
--8<-- "docs_src/extensions/tutorial001.py"
```
Готово. Теперь сервер объявляет `io.modelcontextprotocol/ui` в `capabilities.extensions` и обслуживает всё, что добавляет расширение.
`Apps` — встроенное эталонное расширение, и ему посвящена отдельная страница: **[MCP Apps](apps.md)**.
!!! note
Расширения фиксируются при создании. Метода `add_extension`, который можно вызвать позже, нет: карта возможностей сервера не должна меняться, пока к нему подключены клиенты.
Карта возможностей передаётся через `server/discover`, а это путь версии **2026-07-28**. В рукопожатии `initialize` старого поколения для неё просто нет места, поэтому клиент старого поколения расширения не видит. Учитывайте это при проектировании: расширение *дополняет* сервер и не должно быть единственным способом им пользоваться.
## Написание собственного расширения {#writing-your-own}
Унаследуйтесь от `Extension` и переопределите только то, что нужно. У каждого метода есть реализация по умолчанию.
### Идентификатор {#the-identifier}
```python
--8<-- "docs_src/extensions/tutorial002.py"
```
Идентификатор — это строка вида `vendor-prefix/name`, подчиняющаяся грамматике ключей `_meta` из спецификации: метки, разделённые точками (каждая начинается с буквы и заканчивается буквой или цифрой), косая черта, затем имя. Он проверяется **в момент определения класса**, так что опечатка не ждёт запуска сервера:
```text
TypeError: Stamps.identifier must be a `vendor-prefix/name` string
(reverse-DNS prefix required), got 'stamps'
```
В качестве префикса используйте домен, которым вы управляете. `io.modelcontextprotocol/*` отведён для расширений, описанных самим проектом MCP.
### Добавление инструментов {#contributing-tools}
Самое маленькое полезное расширение — один инструмент и карта настроек:
```python title="server.py" hl_lines="17 19-20 22-23 26"
--8<-- "docs_src/extensions/tutorial003.py"
```
* `tools()` возвращает объекты `ToolBinding`. Сервер регистрирует каждый из них ровно так же, как если бы вы сами вызвали `mcp.add_tool(...)`: та же генерация схемы, то же внедрение `Context`, всё то же самое.
* `settings()` — значение, объявляемое в `capabilities.extensions["com.example/stamps"]`. Верните `{}` (значение по умолчанию), чтобы объявить расширение без настроек.
* Расширение никогда не получает сервер. Оно описывает свой вклад как данные; `MCPServer` их потребляет. Никакого `self.server`, который можно было бы менять, нет.
А `main()` служит доказательством: клиент в памяти, подключённый напрямую к `mcp`:
```python title="server.py" hl_lines="29-34"
--8<-- "docs_src/extensions/tutorial003.py"
```
### Обслуживание собственных методов {#serving-your-own-methods}
Расширение может регистрировать **новые методы запросов** — собственные глаголы, обслуживаемые рядом с методами спецификации:
```python title="server.py" hl_lines="16-22 31 40-48"
--8<-- "docs_src/extensions/tutorial004.py"
```
* `SearchParams` наследуется от `RequestParams`, поэтому конверт `_meta` версии 2026 разбирается единообразно, а обработчик получает провалидированные параметры, а не сырой словарь. Ограничивайте то, чем управляет клиент: `Field(ge=1, le=100)` отклонит абсурдный `limit` раньше, чем ваш код что-либо под него выделит.
* `require_client_extension(ctx, EXTENSION_ID)` — это пропускной пункт: клиент, не объявивший расширение, получает ошибку `-32021` (отсутствует обязательная возможность клиента) с машиночитаемой полезной нагрузкой `requiredCapabilities`, которую требует спецификация.
* `protocol_versions=frozenset({"2026-07-28"})` привязывает метод к одной версии протокола. На любой другой версии клиент получает `METHOD_NOT_FOUND` — ровно так, как если бы метода там не существовало. Для этого клиента его и нет.
Методы **строго аддитивны**. SDK проверяет это при создании, а не во время выполнения:
* `MethodBinding` для метода, определённого спецификацией (`tools/list`, `completion/complete`, ...), выбрасывает `ValueError` при создании привязки. Базовые глаголы принадлежат серверу.
* Если два расширения привязывают один и тот же метод, исключение выбрасывается при регистрации второго. Принцип «побеждает последняя запись» — это то, как плагины портят друг друга; мы так не делаем.
* Пустое множество `protocol_versions` тоже приводит к исключению: метод, который никогда нельзя обслужить, — это ошибка, а не конфигурация.
### Клиентская сторона {#the-client-side}
`main()` из того же файла — это вся клиентская часть, обе её половины:
```python title="server.py" hl_lines="54-58"
--8<-- "docs_src/extensions/tutorial004.py"
```
* `Client(..., extensions=[advertise(EXTENSION_ID)])` объявляет расширение. Объявления превращаются в `ClientCapabilities.extensions`: на подключении версии 2026-07-28 карта передаётся в конверте `_meta` каждого запроса, так что сервер видит её в **каждом** запросе; на подключении старого поколения она едет в рукопожатии `initialize`. Серверному коду всё равно, какой из вариантов: `require_client_extension(ctx, ...)` и `ctx.session.check_client_capability(...)` читают нужный источник на обоих путях.
* Вендорные методы спускаются на уровень ниже, к `client.session.send_request(...)`; `Client` обзаводится полноценными методами только для глаголов спецификации. `send_request` принимает любой подкласс `Request`, так что вендорный запрос проходит как есть.
### Перехват `tools/call` {#intercepting-toolscall}
Единственный перехватывающий хук. Переопределите `intercept_tool_call`, чтобы наблюдать за вызовом инструмента, завершать его досрочно или запрещать:
```python title="server.py" hl_lines="17-24"
--8<-- "docs_src/extensions/tutorial005.py"
```
* `params` — провалидированный `CallToolRequestParams`: `params.name` и `params.arguments` доступны без работы с сырым JSON. Он же определяет, какой вызов инструмента выполняется: передача переписанного контекста через `call_next` меняет то, что обработчик видит в `ctx`, но не сам вызов инструмента. Переписывание запросов на уровне протокола — задача [Middleware](middleware.md).
* `call_next(ctx)` выполняет остаток цепочки и возвращает результат обработчика. Верните его без изменений (наблюдение), верните что-то другое (замена) или выбросьте `MCPError` (отказ). Всё, что вы вернёте, сериализуется как любой результат обработчика, включая штамп идентичности `serverInfo` поколения 2026, так что перехватчик, завершающий вызов досрочно, никогда не выдаёт анонимный или не соответствующий схеме ответ.
* При нескольких расширениях перехватчики вкладываются друг в друга в порядке регистрации: первое расширение в `extensions=[...]` — самое внешнее.
* Реализация по умолчанию просто пропускает вызов дальше, и сервер, расширения которого не переопределяют этот хук, сохраняет голый обработчик `tools/call` нетронутым. За то, чем не пользуетесь, платить не приходится.
Хук оборачивает `tools/call` и ничего больше. Для задач, касающихся каждого сообщения, используйте [Middleware](middleware.md). Оно для этого и предназначено.
## Использование клиентского расширения {#using-a-client-extension}
**Клиентское расширение** — тот же контракт со стороны потребителя: набор клиентского поведения за одним идентификатором. Передайте экземпляры в `Client(extensions=[...])` и вызывайте инструменты как обычно:
```python title="client.py" hl_lines="66-68"
--8<-- "docs_src/extensions/tutorial006.py"
```
`call_tool("buy", ...)` возвращает обычный `CallToolResult`, как и любой другой вызов. Что изменило расширение: теперь сервер может ответить на `buy` **формой результата** `receipt` вместо окончательного результата, а `Receipts` доводит её до конца (здесь — погашая квитанцию дополнительным вызовом) до того, как `call_tool` вернёт управление. В месте вызова не меняется ничего.
Уберите расширение — и ничего этого не будет: пропускной пункт сервера отклонит клиент, который его не объявил (ошибка -32021), а заявленная форма от сервера, пропускающего эту проверку, не пройдёт валидацию — ровно так, как спецификация требует для нераспознанного `resultType`. Выключено по умолчанию, на обоих концах соединения.
Чтобы объявить идентификатор **без** какого-либо клиентского поведения (сервер проверяет наличие возможности, клиент ничего не делает — как в клиенте поиска выше), используйте `advertise()`:
```python
from mcp.client import advertise
client = Client(mcp, extensions=[advertise("com.example/search")])
```
## Написание клиентского расширения {#writing-a-client-extension}
Унаследуйтесь от `ClientExtension` и переопределите только то, что нужно. Три вида вклада, у каждого реализация по умолчанию: `settings()`, `claims()` и `notifications()`.
```python title="client.py" hl_lines="17-18 43-44 46-47"
--8<-- "docs_src/extensions/tutorial006.py"
```
* Идентификатор подчиняется той же грамматике, что и на сервере, и проверяется при определении класса.
* `claims()` возвращает объекты `ResultClaim`: тег в передаваемых данных, модель, которая его разбирает, и резолвер, который доводит результат до конца. Модель обязана зафиксировать тег через `result_type: Literal["receipt"]` и не должна наследоваться от базовых типов результата этого глагола; и то и другое проверяется при создании заявки. Вендорные поля вроде `receipt_token` передаются по сети как есть: подставленная форма доходит до клиента дословно.
* Резолвер получает разобранную модель и `ClaimContext`; `ctx.session` — тот же публичный дескриптор, что и `client.session`, так что последующие вызовы — это обычные вызовы сессии. Возвращает он обычный для глагола `CallToolResult`.
* `settings()` — значение, объявляемое в `ClientCapabilities.extensions[identifier]`; оно считывается один раз при создании `Client`.
`notifications()` объявляет вендорные уведомления сервера, за которыми нужно наблюдать:
```python
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
```
Обработчик получает провалидированные параметры по одному, в порядке диспетчеризации. Он наблюдает; запретить или ответить он не может.
Два негромких правила. Заявки действуют только на подключениях версии 2026-07-28, и объявление возможностей следует за ними: на подключении старого поколения заявки исчезают, а вместе с ними из объявления выпадает и идентификатор, так что клиент никогда не объявляет расширение, формы которого он бы отклонил. А если заявленная форма нужна вам самим, а не резолверу, вызывайте `client.session.call_tool(..., allow_claimed=True)`; без этого флага заявленная форма, дошедшая до вызывающего кода на уровне сессии, приводит к исключению `UnexpectedClaimedResult`.
### Глаголы расширения {#extension-verbs}
Собственные методы запросов расширения не требуют регистрации на стороне клиента. Тип вендорного запроса наследуется от `mcp.types.Request` и отправляется через `client.session.send_request`, как в разделе [Обслуживание собственных методов](#serving-your-own-methods). Одно дополнение: когда ключ из params должен передаваться в заголовке `Mcp-Name` (спецификации расширений, например tasks, требуют этого для своих глаголов), тип запроса объявляет `name_param`:
```python title="client.py" hl_lines="22-25 46-47"
--8<-- "docs_src/extensions/tutorial007.py"
```
Сессия дублирует `params["jobId"]` в `Mcp-Name` на каждом пути отправки, а отсутствующее значение приводит к явной ошибке, а не к молчаливому пропуску обязательного заголовка.
## Чего расширение не может {#what-an-extension-cannot-do}
Поверхность вклада **закрыта** намеренно. На сервере: настройки, инструменты, ресурсы, методы, один перехватчик `tools/call`. На клиенте: настройки, заявки на результаты, привязки уведомлений. Расширение не может:
* **Дотянуться до хоста.** Оно объявляет данные; ссылки на сервер или клиент у него нет.
* **Заменить базовое поведение.** Методы спецификации и базовые теги результатов отклоняются при создании (`initialize` и вовсе зарезервирован за механизмом запуска); привязка уведомления, перекрытая базовым словарём, вместо этого замолкает с предупреждением.
* **Зарегистрироваться с опозданием.** После того как `MCPServer(...)` или `Client(...)` вернул управление, набор расширений уже не меняется.
Если вы боретесь с этими стенами, вы пишете не расширение. Вы пишете форк. Стены и есть главное достоинство: тот, кто читает `extensions=[Apps(), Stamps()]`, знает *всё*, чего эти два расширения могли коснуться.

View file

@ -0,0 +1,34 @@
---
translation:
sections: [ca6988b7503cd2d3]
tool: 1
---
# Продвинутые темы {#advanced}
Всё, что нужно обычному серверу или клиенту, уже разобрано по темам в разделах выше.
Этот раздел — обходные пути, к которым обращаются, когда удобный слой `MCPServer`
начинает мешать:
* **[Низкоуровневый Server](low-level-server.md)**: класс, на котором построен `MCPServer`.
Схемы, написанные вручную, обработчики `on_*`, никаких проверок за вас и собственные
JSON-RPC-методы.
* **[Пагинация](pagination.md)** и **[Middleware](middleware.md)**: две вещи, которые
можно сделать *только* на низкоуровневом `Server`.
* **[Расширения](extensions.md)** и **[MCP Apps](apps.md)**: точки расширения
протокола. Подключайте пакеты расширений к серверу или пишите свои.
Кое-что из того, что логично было бы искать здесь, находится там, где это
действительно используется:
* **Авторизация** — в разделе **[Запуск сервера](../run/index.md)**, потому что
сервер защищают там, где его развёртывают.
* **OAuth**, **подтверждение идентичности**, подключение к **нескольким серверам** и
**кеш** ответов — всё это в разделе **[Клиенты](../client/index.md)**.
* **Многораундовые запросы** (multi-round-trip) и **Подписки** — в разделе
**[Внутри обработчика](../handlers/index.md)**, потому что и то и другое — это то,
что обработчик *делает*.
* **Шаблоны URI** — в разделе **[Серверы](../servers/index.md)**, рядом с ресурсами.
* У **[Версий протокола](../protocol-versions.md)** и
**[Устаревших возможностей](../deprecated.md)** есть собственные страницы верхнего уровня.
Если вы не уверены, нужен ли вам этот раздел, — он вам не нужен.

View file

@ -0,0 +1,207 @@
---
translation:
sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e]
tool: 1
---
# Низкоуровневый Server {#the-low-level-server}
`@mcp.tool()` — это слой. Под ним лежит второй класс сервера, `Server`, который говорит на чистом MCP: вы передаёте ему объекты протокола, и он отправляет их по сети без изменений.
`MCPServer` построен поверх него. Спускаться ниже стоит тогда, когда слой удобства мешает:
* Нужно отдать **точную** схему (загруженную из файла, сгенерированную из базы данных), а не выведенную из сигнатуры Python.
* Нужен полный контроль над результатом: `_meta`, `is_error`, каждый ключ `structured_content`.
* Нужно обработать метод, который MCP не определяет.
Во всех остальных случаях оставайтесь на `MCPServer`.
## Тот же инструмент, вручную {#the-same-tool-by-hand}
Это инструмент `search_books`, который на странице **[Инструменты](../servers/tools.md)** занимает девять строк с `@mcp.tool()`, — но без синтаксического сахара:
```python title="server.py" hl_lines="22 26 32"
--8<-- "docs_src/lowlevel/tutorial001.py"
```
Изменились три вещи, и это весь низкоуровневый API:
* **Обработчики — параметры конструктора.** `on_list_tools=` и `on_call_tool=` передаются в `Server(...)`. Декораторов здесь нет, и у каждого обработчика одна и та же форма: `async (ctx, params) -> result`.
* **Входную схему пишете вы.** `Tool.input_schema` — обычный `dict` с JSON Schema. Никто не выводит её из аннотаций типов, потому что аннотаций типов, из которых её можно было бы вывести, нет.
* **Результат собираете вы.** `CallToolResult(content=[TextContent(...)])`, вручную. Ничего не оборачивается, не преобразуется и не выводится из аннотации возвращаемого значения.
`params` — это разобранный запрос: `CallToolRequestParams` даёт `.name` и `.arguments`. `ctx` — это `ServerRequestContext`: `ctx.session` для обращения к клиенту, `ctx.lifespan_context`, `ctx.request_id` и `ctx.meta` — входящий `_meta` запроса.
!!! info
Если вы работали с FastAPI, это соотношение вам уже знакомо. `MCPServer` — слой с декораторами и аннотациями типов; `Server` — это Starlette под ним. Они не конкуренты: `MCPServer` создаёт `Server` и регистрирует на нём ровно такие же обработчики.
### Попробуйте сами {#try-it}
Inspector здесь не поможет: `mcp dev` и `mcp run` принимают только `MCPServer`. Клиенту `Client`, работающему в памяти, всё равно — он принимает низкоуровневый `Server` точно так же, как `MCPServer`:
```python title="main.py"
import asyncio
from mcp import Client
from server import server
async def main() -> None:
async with Client(server) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
print(result.content)
asyncio.run(main())
```
```text
[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)]
```
Тот же текст, что выдала версия с `@mcp.tool()`. Два честных отличия:
* `result.structured_content` равен `None`. Высокоуровневый сервер сам оборачивает `-> str` в `{"result": ...}`; здесь никто не соберёт то, чего не собрали вы.
* `list_tools` возвращает схему, которую набрали **вы**, символ в символ. В высокоуровневой версии у каждого свойства было `"title": "Query"`, а в корне — `"title": "search_booksArguments"`: артефакты Pydantic. Здесь всё, что есть в передаваемых данных, положили туда вы.
## За вас ничего не проверяют {#nothing-is-checked-for-you}
`MCPServer` отклоняет некорректный аргумент ещё до запуска вашей функции, проверяя вызов по сгенерированной им схеме (**[Инструменты](../servers/tools.md)**).
`Server` этого не делает. Ваша `input_schema` *объявляется* клиенту, но никогда не *применяется* к `params.arguments`.
!!! check
Вызовите `search_books` без `limit`, и ваше `args["limit"]` выбросит `KeyError`. Клиент увидит:
```text
MCPError: Internal server error
```
Ошибка JSON-RPC с кодом `-32603` и намеренно общим сообщением: SDK не станет выдавать вашу трассировку удалённому вызывающему. Модель так и не узнает, что сделала не так, и не сможет повторить попытку. (В тесте `raise_exceptions=True` вместо этого показывает настоящее исключение; см. **[Тестирование](../get-started/testing.md)**.)
Это обобщается. Исключение, выброшенное из низкоуровневого обработчика, — **всегда** ошибка протокола и никогда не результат инструмента с `is_error=True`. Если хотите, чтобы модель прочитала описание сбоя и восстановилась, проверяйте `params.arguments` сами и возвращайте `CallToolResult(content=[TextContent(...)], is_error=True)`. Этим двум видам сбоев посвящена страница **[Обработка ошибок](../servers/handling-errors.md)**.
## Два инструмента, один обработчик {#two-tools-one-handler}
`on_call_tool` — единственная точка входа для всех инструментов сервера. Маршрутизация идёт по `params.name`:
```python title="server.py" hl_lines="38-43"
--8<-- "docs_src/lowlevel/tutorial002.py"
```
* `list_tools` объявляет оба. `call_tool` выбирает ветку по имени.
* Ветка `else` важна: `Server` без возражений передаст `tools/call` с именем, которое вы никогда не объявляли, прямо в ваш обработчик. Исключение там превращает вызов в тот же `-32603`, что и выше.
## Структурированный вывод, вручную {#structured-output-by-hand}
Объявите `output_schema` в `Tool` и поместите `structured_content` в результат. И то и другое — ваше:
```python title="server.py" hl_lines="19-23 36"
--8<-- "docs_src/lowlevel/tutorial003.py"
```
Вызовите его, и результат несёт оба представления:
```json
{
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
"structuredContent": {"matches": 3, "query": "dune"},
"isError": false,
"resultType": "complete",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}}
}
```
Блок `_meta` — это идентификационная отметка сервера: SDK добавляет её в каждый результат поколения 2026, с `version` из конструктора (сервер, который её не задал, сообщает пустую строку). Сервер, который не должен себя называть, может убрать этот ключ с помощью middleware — оно владеет результатами, которые возвращает.
Сервер никогда не сравнивает эти два поля. А вот `Client` из этого SDK сравнивает: верните `structured_content`, не соответствующий объявленной вами `output_schema`, и `call_tool` выбросит `RuntimeError`, который начинается с `Invalid structured content returned by tool search_books` и дальше цитирует ошибку `jsonschema`. Пообещать схему легко; соблюдать её — ваша забота. Вся лестница возвращаемых типов и схем — на странице **[Структурированный вывод](../servers/structured-output.md)**.
## `_meta`: для приложения, не для модели {#\_meta-for-the-application-not-the-model}
`content` — это та часть ответа, которую читает модель. `structured_content` — тот же ответ в виде типизированных данных. `_meta` — третий канал: данные, которые едут вместе с результатом для **клиентского приложения** и вообще не являются частью ответа.
Используйте его для идентификаторов записей, идентификаторов трассировки — всего, что нужно вашему UI и не нужно промпту:
```python title="server.py" hl_lines="37"
--8<-- "docs_src/lowlevel/tutorial004.py"
```
* При создании вы пишете `_meta=` — имя, которое идёт по сети. Клиент читает его обратно как `result.meta`.
* Давайте ключам пространство имён (`bookshop/record_ids`). Ключи `io.modelcontextprotocol/*` зарезервированы протоколом.
!!! warning
`_meta` — это соглашение между вами и клиентским приложением, а не гарантия того, что дойдёт
до модели. Что отображать, решает хост. Никогда не помещайте секрет ни в одну часть результата инструмента.
## Возможности следуют за обработчиками {#capabilities-follow-your-handlers}
`Server` объявляет ровно те семейства методов, для которых вы передали обработчики. `Bookshop` выше передаёт `on_list_tools` и `on_call_tool` и больше ничего, поэтому подключившийся к нему клиент видит:
```json
{"tools": {"listChanged": false}}
```
Ни `resources`, ни `prompts`: их нечем обеспечить. Передайте `on_list_prompts` — появится `prompts`; передайте `on_completion` — появится `completions`.
`MCPServer` всегда объявляет инструменты, ресурсы и промпты, зарегистрировали вы что-нибудь или нет, потому что его менеджеры существуют всегда. Здесь же объявление — это *и есть* вызов конструктора.
## Дженерик жизненного цикла {#the-lifespan-generic}
`Server` — дженерик по типу, который отдаёт его жизненный цикл (lifespan). Аннотируйте его один раз, и объект будет типизирован везде, где появляется:
```python title="server.py" hl_lines="24-26 44-45 50"
--8<-- "docs_src/lowlevel/tutorial005.py"
```
* Жизненный цикл — это `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` на `async`-генераторе даёт ровно это.
* То, что он отдаёт через `yield`, становится `ctx.lifespan_context`, а поскольку обработчики аннотированы как `ServerRequestContext[Catalog]`, `.search(...)` автодополняется и проходит проверку типов.
* Вход в него происходит один раз при запуске сервера, выход — один раз при остановке. Запуск, завершение и версия той же идеи в `MCPServer` — на странице **[Жизненный цикл](../handlers/lifespan.md)**.
Без `lifespan=` значение `ctx.lifespan_context` — пустой `dict`.
## Собственный метод {#a-method-of-your-own}
Конструктор покрывает методы, которые определяет MCP. `add_request_handler` покрывает всё остальное:
```python title="server.py" hl_lines="35-36 39-40 43-44 48"
--8<-- "docs_src/lowlevel/tutorial006.py"
```
* Первый аргумент — строка метода. У уведомлений есть двойник, `add_notification_handler`.
* `params_type` — модель, по которой входящие `params` проверяются **до** запуска вашего обработчика, так что пользовательские методы *получают* ту проверку, которой нет у инструментов. Наследуйтесь от `RequestParams`, чтобы поле `_meta` разбиралось так же, как у любого другого метода.
* Обработчик возвращает `BaseModel`, `dict` или `None`. SDK сериализует это в результат JSON-RPC.
Одна честная оговорка: у высокоуровневого `Client` есть глаголы только для методов, определённых MCP, так что `client.reindex()` не существует. Вендорный метод предназначен для стороны, которая уже знает о его существовании: клиента, который вы тоже поставляете, или другого вашего сервиса, говорящего на JSON-RPC.
Один метод занять нельзя:
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
Рукопожатие принадлежит раннеру. `server/discover`, `ping` и все остальные встроенные методы можно заменять.
!!! tip
`Server.middleware`, упомянутый в этой ошибке, оборачивает **каждое** входящее сообщение, включая `initialize`. Если нужно наблюдать за трафиком или переписывать его, а не отвечать на новый метод, начните со страницы **[Middleware](middleware.md)**.
## Остальные обработчики {#the-other-handlers}
Каждый из них — одна идея, для которой у вас теперь есть словарь; у каждого своя страница.
* `on_call_tool`, `on_get_prompt` и `on_read_resource` могут вернуть `InputRequiredResult` вместо обычного результата, чтобы приостановить вызов и запросить ввод у клиента; см. **[Многораундовые запросы](../handlers/multi-round-trip.md)**. Верные духу этого уровня, они ничего не устанавливают за вас: там, где `MCPServer` по умолчанию запечатывает `requestState`, здесь заданный вами `request_state` идёт по сети ровно в том виде, в каком написан, пока вы не включите защиту явно: `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` — одна строка (оба имени импортируются из `mcp.server.request_state`) для точно такого же запечатывания и проверки, какие выполняет `MCPServer` (**[Защита `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**).
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` — та же форма `(ctx, params) -> result` для остальных примитивов.
* `on_subscriptions_listen` обслуживает поток `subscriptions/listen` версии 2026-07-28. Передайте `ListenHandler`, построенный поверх `SubscriptionBus`, и публикуйте события в шину из остальных обработчиков; полная схема компоновки — на странице **[Подписки](../handlers/subscriptions.md)**.
* `server.streamable_http_app()` возвращает то же Starlette-приложение, что и у `MCPServer`; разворачивайте его так же, как страница **[Запуск сервера](../run/index.md)** разворачивает любое другое ASGI-приложение. `server.run(transport=...)` здесь нет: `server.run(read_stream, write_stream, server.create_initialization_options())` ведёт одно подключение по паре потоков, и этой одной строкой всё исчерпывается.
## Итоги {#recap}
* Низкоуровневый `Server` принимает обработчики как **параметры конструктора** `on_*`; каждый обработчик — `async (ctx, params) -> result`.
* Словарь `input_schema` пишете вы, и `CallToolResult` собираете вы. Ничего не выводится, не оборачивается и не проверяется за вас.
* Исключение в обработчике — ошибка протокола `-32603`. Ошибка инструмента, которую может прочитать модель, — это `CallToolResult` с `is_error=True`, который возвращаете **вы**.
* `_meta` в результате адресован клиентскому приложению, а не модели.
* `Server[T]` — дженерик по тому, что отдаёт его жизненный цикл; `ctx.lifespan_context` — типизированный `T`.
* `add_request_handler(method, params_type, handler)` обслуживает любой метод. `initialize` зарезервирован.
* Возможности, которые объявляет `Server`, выводятся из того, какие обработчики вы зарегистрировали.
`Client(server)` обращался с обоими серверами одинаково, потому что это *и есть* один и тот же протокол — в этом весь смысл. Следующий уровень вниз — вообще не класс: это **[Middleware](middleware.md)**.

View file

@ -0,0 +1,84 @@
---
translation:
sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c]
tool: 1
---
# Middleware {#middleware}
**Middleware** (промежуточный слой) — это одна асинхронная функция, которая оборачивает каждое сообщение, приходящее на сервер.
Её пишут в виде `async (ctx, call_next)` и добавляют в `server.middleware`. Вот и весь API.
!!! warning
Список middleware в исходном коде помечен как **provisional** (предварительный): его сигнатура и семантика могут измениться в минорном выпуске 2.x. Используйте его, чтобы *наблюдать* (замер времени, логирование, трассировка) и *отклонять* сообщения; не делайте его фундаментом, на котором держится сервер.
`MCPServer` принимает список при создании (`MCPServer(name, middleware=[...])`) и предоставляет его как `mcp.middleware`; низкоуровневый `Server` предоставляет тот же список как `server.middleware`. В примере ниже используется низкоуровневый `Server`; если конструкция `Server(name, on_call_tool=...)` вам незнакома, сначала прочитайте **[Низкоуровневый Server](low-level-server.md)**.
## Middleware для замера времени {#a-timing-middleware}
Один сервер, один инструмент, один слой middleware, который пишет в лог, сколько заняло каждое сообщение:
```python title="server.py" hl_lines="39-45 49"
--8<-- "docs_src/middleware/tutorial001.py"
```
* `ctx` — тот же `ServerRequestContext`, который получают обработчики. `ctx.method` — строка метода как есть; `ctx.params` — параметры как есть, **до** какой-либо валидации.
* `call_next(ctx)` запускает остаток цепочки: валидацию, поиск обработчика, сам обработчик. Верните то, что вернул он, — и ответ останется нетронутым.
* `try`/`finally` здесь намеренно: обработчик, выбросивший исключение, всё равно замеряется, потому что сбой доходит до middleware в виде исключения из `call_next`.
* `server.middleware.append(...)` регистрирует его. Список выполняется начиная с внешнего слоя, так что `middleware[0]` — ближайший к сети.
### Попробуйте сами {#try-it}
Подключите клиент, запросите список инструментов, вызовите один из них. В логе **три** строки:
```text
server/discover took 18.3 ms
tools/list took 0.1 ms
tools/call took 0.1 ms
```
Вызовов было два, а строк три. Первая — `server/discover`: запрос, который клиент отправил, чтобы установить подключение, ещё до того, как вы что-либо запросили.
В этом и суть. Middleware оборачивает **каждое** входящее сообщение:
* Установку подключения: `server/discover` или, в сессии старого поколения, `initialize` и `notifications/initialized`.
* Каждый запрос и каждое уведомление. Для уведомления `ctx.request_id is None`, `call_next(ctx)` возвращает `None`, а всё, что вернёте вы, отбрасывается.
* Даже метод, для которого у сервера нет обработчика: `call_next` выбрасывает `MCPError(-32601, "Method not found")` *сквозь* middleware по пути к клиенту.
## Что можно делать внутри {#what-you-can-do-inside-one}
В порядке возрастания того, насколько стоит задуматься, прежде чем это делать:
* **Наблюдать.** Замерять, считать, логировать. Пример выше.
* **Отклонять.** Выбросьте `MCPError` *вместо* вызова `call_next(ctx)` — и на это одно сообщение придёт ответ с ошибкой JSON-RPC. Подключение не рвётся; следующее сообщение проходит. Именно так сервер ограничивает `subscriptions/listen` для каждого вызывающего: раздел **[Кому разрешено наблюдать](../handlers/subscriptions.md#deciding-who-may-watch)** на странице о подписках разбирает это пошагово.
* **Переписывать.** `ctx` — это dataclass: `await call_next(dataclasses.replace(ctx, params=...))` передаёт остальной цепочке не те параметры, что прислал клиент. Никогда не делайте этого с `initialize`: результат, который получает клиент, строится из переписанных параметров, но состояние подключения сервер фиксирует по исходным параметрам из сети. Стороны могут завершить рукопожатие, расходясь в том, о чём они договорились.
* **Отвечать.** Верните результат, не вызывая `call_next(ctx)`, — и он уйдёт клиенту как ваш ответ. `call_next` отдаёт готовую сетевую форму, а конвейер никогда не правит то, что вы возвращаете, так что вся обёртка целиком на вас: на подключении поколения 2026 сюда входит отметка `serverInfo` в `_meta`, которую SDK добавляет к результатам обработчиков, но не к вашим.
!!! check
`initialize` — одно из того, что оборачивает middleware, и это *единственный* хук для него. Попробуйте перехватить его через `add_request_handler` — и SDK откажет:
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
!!! warning
`initialize` обрабатывается на месте: сервер не читает дальнейшие входящие сообщения, пока цепочка middleware не вернёт управление. Поэтому ожидание запроса от сервера к клиенту (`ctx.session.send_request(...)`, элицитация (elicitation)) во время обработки `initialize` **приводит к взаимной блокировке подключения**: ответ, которого вы ждёте, никогда не будет прочитан. Уведомления по принципу «отправил и забыл» допустимы.
## Единственный слой middleware, включённый по умолчанию {#the-one-middleware-that-ships-on-by-default}
SDK поставляет ровно один слой middleware, и он уже в списке вашего сервера: тот, что создаёт спан OpenTelemetry для каждого сообщения. Его не нужно добавлять, и чаще всего о нём не приходится думать. Пока не установлен экспортёр, он ничего не делает, и у него есть своя страница: **[OpenTelemetry](../run/opentelemetry.md)**.
!!! info
Если вы писали ASGI middleware, эта форма вам уже знакома. `(scope, receive, send)` из Starlette превратилось в `(ctx, call_next)` и выполняется *после* транспорта — над декодированным сообщением, а не над сырым HTTP-запросом. Одно с другим сочетается: middleware Starlette поверх `streamable_http_app()` видит HTTP; этот слой видит MCP.
## Итоги {#recap}
* Middleware — это `async (ctx, call_next) -> result`; его передают как `MCPServer(middleware=[...])` (или добавляют в `mcp.middleware`), а в низкоуровневом `Server` добавляют в `server.middleware`.
* Middleware оборачивает **каждое** входящее сообщение (`server/discover`, `initialize`, запросы, уведомления, неизвестные методы) и выполняется начиная с внешнего слоя.
* `ctx.request_id is None` — так уведомление отличают от запроса.
* Чтобы отклонить одно сообщение, выбросьте исключение вместо вызова `call_next`; подключение это переживёт.
* Собственная трассировка OpenTelemetry в SDK — тоже middleware, уже в списке. См. **[OpenTelemetry](../run/opentelemetry.md)**.
* Весь этот интерфейс предварительный. Наблюдайте с его помощью; не стройте на нём.
Это всё, что оборачивает запрос. А решает, будет ли запрос вообще выполнен, **[Авторизация](../run/authorization.md)**.

View file

@ -0,0 +1,85 @@
---
translation:
sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9]
tool: 1
---
# Пагинация {#pagination}
Большинству серверов это никогда не понадобится.
`MCPServer` отвечает на каждый запрос `list_*` всем, что у него есть, одной страницей, с `next_cursor=None`. Для нескольких десятков инструментов, ресурсов или промптов это правильный ответ, и настраивать здесь нечего.
Пагинация нужна серверу, чей список ресурсов — это по сути база данных: тысячи строк, которые он не станет сериализовать в одном ответе. Протокол предлагает для этого **курсор**: сервер возвращает страницу и непрозрачный токен, а клиент отправляет этот токен обратно, чтобы получить следующую страницу.
У `@mcp.resource()` для этого нет никакой точки расширения. Чтобы отдавать данные постранично, обработчик списка пишется вручную — на **[низкоуровневом Server](low-level-server.md)**.
## Сервер с пагинацией {#a-server-that-pages}
```python title="server.py" hl_lines="12 15-16"
--8<-- "docs_src/pagination/tutorial001.py"
```
* На низкоуровневом `Server` обработчики — это аргументы конструктора, а не декораторы. `on_list_resources` отвечает на каждый запрос `resources/list`; вот и всё подключение.
* Каждый обработчик с пагинацией имеет тип параметра `params: PaginatedRequestParams | None`, и пример принимает оба варианта. Однако при работе через подключение SDK никогда не передаёт `None` (запрос без поля `params` доходит до обработчика как модель со значениями по умолчанию), поэтому значимый сигнал — это `params.cursor is None`: **начать с начала**.
* Что *такое* курсор, решаете вы. Здесь это смещение, записанное строкой. Временная метка, первичный ключ, blob в base64 — всё, что можно выдать на выходе и распознать на обратном пути.
* `next_cursor=None` — это способ сказать «это была последняя страница». Нет ни счётчика, ни общего количества, ни `has_more`. `None` — и есть весь сигнал.
!!! tip
`PAGE_SIZE`, равный 10, делает пример читаемым. Свой размер выбирайте отдельно для каждой конечной точки: список
однострочных ресурсов может позволить себе страницу на 500 элементов; список объёмных шаблонов промптов — нет.
Клиент на это никак не влияет, и так задумано.
### Попробуйте сами {#try-it}
`Client(server)` подключается к низкоуровневому `Server` в памяти точно так же, как к `MCPServer`.
Вызовите `list_resources()` без аргументов. Придут десять ресурсов, от `book-1` до `book-10`, а `next_cursor` будет строкой `"10"`.
Передайте его обратно через `list_resources(cursor="10")` — первым ресурсом окажется `book-11`, а новый `next_cursor` будет `"20"`.
Десятая страница приходит с `next_cursor`, равным `None`. Готово.
## Цикл на стороне клиента {#the-client-loop}
Каждый метод `list_*` у `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) принимает именованный аргумент `cursor=`. Вычитать список постранично целиком — это один `while True`:
```python title="client.py" hl_lines="26-32"
--8<-- "docs_src/pagination/tutorial002.py"
```
* `cursor` начинается с `None`, поэтому первый запрос уходит без курсора.
* Добавляйте элементы **до** того, как смотреть на `next_cursor`: на последней странице тоже есть ресурсы.
* `next_cursor is None` — условие выхода. Всё остальное без изменений отправляется обратно в `cursor=`.
Запустите его `main()`, и он напечатает `100 resources`: десять страниц по десять, сшитых циклом, который и не знал, что страниц было десять.
Это тот же цикл, который **[Клиент](../client/index.md)** показывает для каждого метода `list_*`, и против сервера без пагинации он ничего не стоит: `next_cursor` равен `None` уже в первом ответе, и цикл выполняется один раз.
## Три правила {#the-three-rules}
**Курсоры непрозрачны.** Клиент никогда не должен разбирать, собирать или угадывать курсор. Единственный законный источник курсора — `next_cursor` предыдущей страницы, дословно.
**Размер страницы выбирает сервер.** В протоколе нет `limit=`. Если нужен другой размер страницы, меняется сервер.
**Клиент, игнорирующий пагинацию, всё равно работает.** Он вызывает `list_resources()` один раз, получает первые десять и не замечает выброшенный `next_cursor`. Ничего не ломается; он просто видит меньше.
!!! check
Непрозрачный значит непрозрачный. Придумайте курсор (`list_resources(cursor="page-2")`) — и
протокол ничем не сможет помочь. Этот сервер пробует `int("page-2")`, обработчик выбрасывает исключение,
и клиенту приходит:
```text
MCPError(-32603, 'Internal server error', None)
```
Курсор, полученный не от сервера, — это ошибка, а не запрос на новую возможность.
## Итоги {#recap}
* `MCPServer` возвращает всё одной страницей. Пагинация включается по желанию, и включается она на низкоуровневом `Server`.
* `on_list_resources` (а также `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) получает `PaginatedRequestParams | None`; для первой страницы `params.cursor` равен `None`.
* Вы возвращаете страницу и `next_cursor`: любую строку, которую потом узнаете, или `None`, когда больше ничего не осталось.
* Цикл клиента: передать `cursor=`, накопить, повторять, пока не `next_cursor is None`.
* Курсоры непрозрачны, размер страницы — за сервером, а клиент без пагинации всё равно получает первую страницу.
Остальной API `Server`, который пишется вручную (`on_call_tool`, словари `input_schema`, `_meta`), — на странице **[Низкоуровневый Server](low-level-server.md)**.