Перейти до змісту

Підписки

Машинний переклад

Цю сторінку перекладено автоматично з англомовної документації, і основною версією є англомовна сторінка. Якщо щось читається неправильно, на сторінці Переклади пояснено, як про це повідомити.

Каталог сервера не є сталим. Інструменти з'являються під час роботи, а вміст за URI ресурсу змінюється. Клієнт дізнається про це через client.listen(...): один запит subscriptions/listen, відповідь на який і є потоком. Він лишається відкритим і несе сповіщення про зміни, які клієнт попросив.

Ця сторінка — про клієнтський бік: як відкрити потік, стежити за ним поруч з основним потоком виконання й обробляти його завершення. Публікація змін, фільтрація та обслуговування методу — серверний бік історії, розказаний на сторінці Підписки у розділі Усередині обробника. Приклади тут спілкуються із сервером спринт-дошки, побудованим там.

Стеження за потоком

Підписка — це один контекстний менеджер. Вхід у нього надсилає запит із вашими іменованими аргументами як фільтром підписки й чекає на підтвердження від сервера, тож до початку блока потік уже активний.

client.py
from mcp import Client
from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged
from mcp.types import TextResourceContents

BOARD = "board://sprint"


async def read_board(client: Client, uri: str = BOARD) -> str:
    [contents] = (await client.read_resource(uri)).contents
    assert isinstance(contents, TextResourceContents)
    return contents.text


async def follow_board(client: Client) -> None:
    async with client.listen(tools_list_changed=True, resource_subscriptions=[BOARD]) as sub:
        async for event in sub:
            match event:
                case ResourceUpdated(uri=uri):
                    print(await read_board(client, uri))
                case ToolsListChanged():
                    tools = await client.list_tools()
                    print("tools:", [tool.name for tool in tools.tools])
                case _:
                    pass  # kinds the filter did not ask for never arrive


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        await follow_board(client)

Ітерація повертає чотири типізовані події: ToolsListChanged, PromptsListChanged, ResourcesListChanged і ResourceUpdated(uri=...).

Подія каже, що змінилося, і ніколи — як. Саме тому follow_board викликає read_resource і list_tools: подія — це сигнал перечитати дані. Читайте event.uri, а не припускайте, який ресурс змінився: фільтр може називати кілька URI, а сервер може повідомити про зміну підресурсу одного з них.

Дублікати подій, що чекають на споживання, згортаються в одну, а повторне читання все одно дає поточний стан. Згортаються лише ідентичні події: два ResourceUpdated для різних URI — це дві події.

Ще дві властивості дескриптора:

  • sub.honored — фільтр, який підтвердив сервер: SubscriptionFilter із полями, що ви передали, доступними як атрибути (sub.honored.prompts_list_changed). MCPServer задовольняє кожен вид, який ви просите, тож повертає ваш запит як є. Сервер, що підтримує менше видів, підтверджує менше, а підтверджений вид усе одно може ніколи не спрацювати. Сервер також може відхилити весь запит замість того, щоб підтвердити його (див. Хто може стежити на серверній сторінці), що проявляється як помилка запиту.
  • sub.subscription_id — ідентифікатор запиту listen, той самий, що проставлений на кожному кадрі цього потоку. Одночасно може бути відкрито кілька підписок, і кожна демультиплексується за власним ідентифікатором.

Стеження без блокування

follow_board працює, доки сервер не закриє потік, а цього може не статися ніколи, тож сама по собі вона захоплює всю програму. Реальним клієнтам спостерігач потрібен поруч з основним потоком виконання: агент викликає інструменти, а спостерігач тим часом підтримує кеш чи інтерфейс актуальними.

Спершу відкрийте підписку, потім запустіть спостерігача й продовжуйте свою роботу.

app.py
import asyncio

from mcp import Client
from mcp.client.subscriptions import Subscription

from .tutorial003 import BOARD, read_board


async def watch(client: Client, sub: Subscription) -> None:
    async for _event in sub:
        board = await read_board(client)
        print(board)
        if "[ ]" not in board:
            return  # sprint finished: the stream closes when run_sprint leaves the block


async def run_sprint(client: Client) -> None:
    async with client.listen(resource_subscriptions=[BOARD]) as sub:
        print(await read_board(client))  # snapshot: acknowledged, so nothing after this is missed
        watcher = asyncio.create_task(watch(client, sub))
        for task in ("design", "build", "ship"):
            await client.call_tool("complete_task", {"board": "sprint", "task": task})
        await watcher  # returns once the watcher has seen the finished board


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        await run_sprint(client)


if __name__ == "__main__":
    asyncio.run(main())
app.py
import trio

from mcp import Client
from mcp.client.subscriptions import Subscription

from .tutorial003 import BOARD, read_board


async def watch(client: Client, sub: Subscription) -> None:
    async for _event in sub:
        board = await read_board(client)
        print(board)
        if "[ ]" not in board:
            return  # sprint finished: the stream closes when run_sprint leaves the block


async def run_sprint(client: Client) -> None:
    async with client.listen(resource_subscriptions=[BOARD]) as sub:
        print(await read_board(client))  # snapshot: acknowledged, so nothing after this is missed
        async with trio.open_nursery() as nursery:
            nursery.start_soon(watch, client, sub)
            for task in ("design", "build", "ship"):
                await client.call_tool("complete_task", {"board": "sprint", "task": task})


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        await run_sprint(client)


if __name__ == "__main__":
    trio.run(main)
app.py
import anyio

from mcp import Client
from mcp.client.subscriptions import Subscription

from .tutorial003 import BOARD, read_board


async def watch(client: Client, sub: Subscription) -> None:
    async for _event in sub:
        board = await read_board(client)
        print(board)
        if "[ ]" not in board:
            return  # sprint finished: the stream closes when run_sprint leaves the block


async def run_sprint(client: Client) -> None:
    async with client.listen(resource_subscriptions=[BOARD]) as sub:
        print(await read_board(client))  # snapshot: acknowledged, so nothing after this is missed
        async with anyio.create_task_group() as tg:
            tg.start_soon(watch, client, sub)
            for task in ("design", "build", "ship"):
                await client.call_tool("complete_task", {"board": "sprint", "task": task})


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        await run_sprint(client)


if __name__ == "__main__":
    anyio.run(main)

Note

app.py імпортує BOARD і read_board з першого прикладу, який у цьому репозиторії збережено як tutorial003.py. Якщо ви зберігаєте показані файли поруч як client.py і app.py, напишіть натомість from client import BOARD, read_board. Приклад watch.py нижче імпортує read_board так само.

Уся суть — у порядку. Нічого не відтворюється повторно, тож подію, опубліковану до появи вашого потоку, буде пропущено. Вхід у client.listen(...) чекає на підтвердження, тому кожна зміна від цієї миті доходить до спостерігача, а знімок, зроблений усередині блока, не може жодної пропустити.

Запити вільно виконуються поруч із відкритим потоком — із завдання спостерігача чи будь-якого іншого, на тому самому клієнті. Оскільки дублікати неспожитих подій зливаються, завантажений основний потік виконання може дати одне повторне читання замість трьох. Події, що відрізняються, не зливаються: фільтр, який називає багато URI, ставить у чергу по одній відкладеній події на кожен URI.

Щоб припинити стеження, вийдіть із блока: виклику unsubscribe немає. Скасування завдання, якому належить блок, робить це за вас, а SDK скасовує запит listen так, як очікує транспорт: через Streamable HTTP — закриваючи потік цього запиту. Спостерігач, що працює весь час життя застосунку, сам ніколи не повертається, тож скасуйте його або область його групи завдань під час завершення роботи.

Потоки закінчуються

Потік закінчується одним із двох способів, і обидва — звичайний хід виконання. Коректне закриття з боку сервера завершує async for; раптовий обрив викидає SubscriptionLost.

Різниця — діагностична, а не в тому, що робити далі: потоку вже немає, нічого не відтворено повторно, і спостерігач, якому це досі важливо, підписується знову й перечитує дані.

watch.py
import anyio

from mcp import Client
from mcp.client.subscriptions import SubscriptionLost

from .tutorial003 import read_board


async def keep_following(client: Client) -> None:
    while True:
        try:
            async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
                print(await read_board(client))  # refetch: no replay across streams
                async for _event in sub:
                    print(await read_board(client))
        except SubscriptionLost:
            pass
        # Either ending means the stream is gone. Back off before re-listening:
        # a graceful close may be the server shedding load.
        await anyio.sleep(1)

Сервери коректно закривають потоки з власних причин, зокрема щоб позбутися підписника, чий беклог занадто виріс, тож чисте завершення — не сигнал припиняти стеження. Витримайте паузу, перш ніж підписуватися знову.

SubscriptionLost має й одну локальну причину. Клієнт тримає щонайбільше 1024 неспожиті події, і споживач, який відстав настільки, втрачає підписку, замість того щоб рости без меж. Тримайте тіло async for коротким, а повільну роботу виконуйте деінде.

keep_following перехоплює лише SubscriptionLost. Вхід у listen() може також викинути MCPError (з'єднання не вдалося або сервер не обслуговує цей метод), TimeoutError (підтвердження не надійшло) і ListenNotSupportedError (з'єднання до версії 2026). Вирішіть, які з них ваш спостерігач має повторювати: остання ніколи не минає сама.

Підсумки

  • Увійдіть у async with client.listen(...); вхід чекає на підтвердження, тож нічого опублікованого після нього не буде пропущено.
  • Ітеруйте через async for event in sub. Події — це сигнали перечитати дані, а не корисне навантаження.
  • Відкрийте підписку, потім запустіть спостерігача як завдання — і виклики інструментів ідуть далі поруч із ним.
  • Чисте завершення зупиняє цикл; обрив викидає SubscriptionLost. У будь-якому разі: підпишіться знову, перечитайте дані, але спершу витримайте паузу.
  • Вихід із блока — це і є відписка.

Публікація цих подій, звуження фільтра та масштабування за межі одного процесу — історія сервера: Підписки. Ці самі події також підтримують актуальність клієнтського кешу, і наступна сторінка — Кешування.