{"slug": "build-an-mcp-server-with-real-time-resource-subscriptions", "title": "Build an MCP Server with Real-Time Resource Subscriptions", "summary": "Model Context Protocol (MCP) server developers can now push real-time resource-change notifications to clients using the Python SDK v2's subscriptions/listen stream, replacing the older resources/subscribe method. The tutorial, published by Mariana Souza, demonstrates building a server that emits notifications/resources/updated events from both tool calls and background tasks, with the 2026-07-28 protocol revision requiring clients to open a subscriptions/listen stream to receive updates. The example uses mcp 2.0.0 and mcp-types 2.0.0, verified on Python 3.13.5, and includes an InMemorySubscriptionBus for publishing from outside request contexts.", "body_md": "# Build an MCP Server with Real-Time Resource Subscriptions\n\nPush live resource-change notifications to MCP clients with the Python SDK v2 subscriptions/listen stream instead of polling.\n\n[Mariana Souza](https://sourcefeed.dev/u/mariana_souza)\n\n## What you'll build\n\nYou'll take a Python [MCP](https://modelcontextprotocol.io/) server that exposes deployment state as resources and extend it so connected clients get pushed `notifications/resources/updated`\n\nevents the instant something changes — from a tool call *and* from a background task — instead of re-reading resources on a timer. You'll finish with a running HTTP server, a subscriber client that refetches on every event, and the exact wire frames to prove it.\n\n## Prerequisites\n\n**Python 3.10+**(verified on 3.13.5). The SDK is written against anyio, so asyncio or trio both work.from PyPI (released 2026-07-28). This tutorial is v2-only: it relies on the`mcp`\n\n2.0.0`subscriptions/listen`\n\nmethod introduced in the 2026-07-28 protocol revision, which replaced`resources/subscribe`\n\n. On`mcp`\n\n1.x the`listen()`\n\nAPI and`notify_*`\n\nhelpers don't exist.- macOS or Linux shell. Windows works the same with\n`.venv\\Scripts\\`\n\npaths. - No API keys or accounts — everything runs on localhost.\n\nOne thing to know before you start: in the 2026-07-28 spec, change notifications reach a client **only** over a `subscriptions/listen`\n\nstream the client opened. The old per-session helpers (`ctx.session.send_resource_updated(uri)`\n\n) are silently dropped on a 2026-era connection. If you've built subscriptions on the 2025 protocol, the server-side API below is the one you migrate to.\n\n## 1. Set up the project\n\n```\nmkdir deploy-board && cd deploy-board\npython3 -m venv .venv && source .venv/bin/activate\npip install \"mcp[cli]\"\npip list | grep -E \"^mcp\"\n```\n\nYou should see `mcp 2.0.0`\n\nand `mcp-types 2.0.0`\n\n. The `[cli]`\n\nextra adds the `mcp dev`\n\nInspector launcher; you won't need it here but it's useful later.\n\n## 2. Write the server\n\nCreate `server.py`\n\n. The pieces that matter are the `InMemorySubscriptionBus`\n\nyou construct yourself (so code outside a request can publish on it), the `notify_resource_updated()`\n\ncall in the tool, and the background `health_poller`\n\npublishing from the lifespan.\n\n``` python\nimport asyncio\nfrom collections.abc import AsyncIterator\nfrom contextlib import asynccontextmanager\n\nfrom mcp.server import MCPServer\nfrom mcp.server.mcpserver import Context\nfrom mcp.server.subscriptions import InMemorySubscriptionBus, ResourceUpdated\n\n# Fake deployment state. Swap for a database, Redis, or a CI webhook.\nDEPLOYS: dict[str, dict[str, str | int]] = {\n    \"api\": {\"version\": \"1.4.2\", \"status\": \"healthy\", \"restarts\": 0},\n    \"worker\": {\"version\": \"0.9.0\", \"status\": \"healthy\", \"restarts\": 0},\n}\n\n# Hold the bus yourself so code outside a request (a background task,\n# a webhook handler) can publish on it too.\nbus = InMemorySubscriptionBus()\n\nasync def health_poller() -> None:\n    \"\"\"Simulate an external system: bump a counter every 3s and publish.\"\"\"\n    while True:\n        await asyncio.sleep(3)\n        DEPLOYS[\"worker\"][\"restarts\"] = int(DEPLOYS[\"worker\"][\"restarts\"]) + 1\n        await bus.publish(ResourceUpdated(uri=\"deploy://worker\"))\n\n@asynccontextmanager\nasync def lifespan(server: MCPServer) -> AsyncIterator[dict]:\n    task = asyncio.create_task(health_poller())\n    try:\n        yield {}\n    finally:\n        task.cancel()\n\nmcp = MCPServer(\"Deploy Board\", lifespan=lifespan, subscriptions=bus)\n\n@mcp.resource(\"deploy://{service}\")\ndef deploy(service: str) -> str:\n    \"\"\"Current deployment state of one service.\"\"\"\n    d = DEPLOYS[service]\n    return f\"{service} v{d['version']} status={d['status']} restarts={d['restarts']}\"\n\n@mcp.tool()\nasync def set_status(service: str, status: str, ctx: Context) -> str:\n    \"\"\"Mark a service healthy, degraded, or down.\"\"\"\n    DEPLOYS[service][\"status\"] = status\n    await ctx.notify_resource_updated(f\"deploy://{service}\")\n    return f\"{service} is now {status}\"\n\nif __name__ == \"__main__\":\n    mcp.run(transport=\"streamable-http\", port=8000)\n```\n\nWhy two publish paths: `ctx.notify_resource_updated()`\n\nis the one-liner for changes your own handler makes. `bus.publish(ResourceUpdated(uri=...))`\n\nis for changes that originate elsewhere — a poller, a webhook, a queue consumer — where there's no request context. `MCPServer`\n\nbuilds a bus internally if you pass nothing, but doesn't expose it, which is why you construct one and pass `subscriptions=bus`\n\n.\n\nThe SDK serves `subscriptions/listen`\n\nfor you: acknowledgment as the first frame, subscription id stamped on every frame, per-stream filtering. Publishing with no subscribers is a no-op.\n\nNote the lifespan uses `asyncio.create_task`\n\n, which pins this server to asyncio. If you run under trio, start the poller in a task group instead.\n\nStart it:\n\n```\npython server.py\nINFO:     Application startup complete.\nINFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)\n```\n\nLeave it running and open a second terminal.\n\n## 3. Write the subscriber client\n\nCreate `client.py`\n\n. `client.listen()`\n\nsends the `subscriptions/listen`\n\nrequest and waits for the server's acknowledgment before the `async with`\n\nbody runs, so the snapshot you take inside the block can't miss an update.\n\n``` python\nimport asyncio\n\nfrom mcp import Client\nfrom mcp.client.subscriptions import ResourceUpdated\nfrom mcp.types import TextResourceContents\n\nURIS = [\"deploy://api\", \"deploy://worker\"]\n\nasync def read(client: Client, uri: str) -> str:\n    [contents] = (await client.read_resource(uri)).contents\n    assert isinstance(contents, TextResourceContents)\n    return contents.text\n\nasync def main() -> None:\n    async with Client(\"http://127.0.0.1:8000/mcp\") as client:\n        async with client.listen(resource_subscriptions=URIS) as sub:\n            print(\"subscribed:\", sub.honored.resource_subscriptions)\n            for uri in URIS:\n                print(\"snapshot:\", await read(client, uri))\n            async for event in sub:\n                if isinstance(event, ResourceUpdated):\n                    print(\"updated:\", await read(client, event.uri))\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nAn event is a cue, not a payload — the frame carries the URI and nothing else, so the client refetches. Read `event.uri`\n\nrather than assuming which resource moved; one filter can name many URIs. Leaving the `async with`\n\nblock is the unsubscribe; there's no explicit call.\n\nRun it:\n\n```\nsource .venv/bin/activate\npython client.py\n```\n\n## 4. Trigger a change from a tool call\n\nCreate `poke.py`\n\n— a second client that calls `set_status`\n\n, the way an LLM host would:\n\n``` php\nimport asyncio\n\nfrom mcp import Client\n\nasync def main() -> None:\n    async with Client(\"http://127.0.0.1:8000/mcp\") as client:\n        result = await client.call_tool(\"set_status\", {\"service\": \"api\", \"status\": \"degraded\"})\n        print(result.content[0].text)\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nIn a third terminal:\n\n```\nsource .venv/bin/activate\npython poke.py\n```\n\n## Verify it works\n\n`poke.py`\n\nprints:\n\n```\napi is now degraded\n```\n\nThe `client.py`\n\nterminal shows the acknowledged filter, the two snapshots, a stream of `worker`\n\nupdates every ~3 s from the background poller, and the `api`\n\nupdate the moment `poke.py`\n\nran:\n\n```\nsubscribed: ['deploy://api', 'deploy://worker']\nsnapshot: api v1.4.2 status=healthy restarts=0\nsnapshot: worker v0.9.0 status=healthy restarts=1\nupdated: worker v0.9.0 status=healthy restarts=2\nupdated: worker v0.9.0 status=healthy restarts=3\nupdated: api v1.4.2 status=degraded restarts=0\nupdated: worker v0.9.0 status=healthy restarts=4\n```\n\nBoth publish paths delivered — the tool's `ctx.notify_resource_updated()`\n\nand the lifespan task's `bus.publish()`\n\n— and only to the URIs this stream asked for. On the wire, the stream looks like this:\n\n```\n{\"method\": \"notifications/subscriptions/acknowledged\",\n \"params\": {\"notifications\": {\"resourceSubscriptions\": [\"deploy://api\", \"deploy://worker\"]},\n            \"_meta\": {\"io.modelcontextprotocol/subscriptionId\": \"listen-1\"}}}\n\n{\"method\": \"notifications/resources/updated\",\n \"params\": {\"uri\": \"deploy://api\", \"_meta\": {\"io.modelcontextprotocol/subscriptionId\": \"listen-1\"}}}\n```\n\nStop `client.py`\n\nwith Ctrl+C; the server logs nothing special, because closing the listen request's stream is how a client unsubscribes.\n\n## Troubleshooting\n\n** mcp.shared.exceptions.MCPError: Method not found** when a client calls\n\n`client.subscribe_resource(uri)`\n\n. You're also seeing `MCPDeprecationWarning: resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.`\n\nA 2026-07-28 server answers `resources/subscribe`\n\nwith `-32601`\n\n. Replace the call with `async with client.listen(resource_subscriptions=[uri])`\n\n. Keep `subscribe_resource()`\n\nonly for talking to 2025-era servers, and filter the warning there.** mcp.client.subscriptions.ListenNotSupportedError: subscriptions/listen is not available at protocol version '2025-11-25'; it requires 2026-07-28.** The client negotiated an older protocol, usually because you passed\n\n`mode=\"legacy\"`\n\nto `Client(...)`\n\nor the server is on `mcp`\n\n1.x. Drop `mode=\"legacy\"`\n\n, or upgrade the server. This one never heals on retry, so don't wrap it in a reconnect loop.**Updates never arrive, no error anywhere.** Your server still calls `ctx.session.send_resource_updated(uri)`\n\n. On a 2026-07-28 connection that helper is dropped with a debug log — it pushes onto a standalone channel that `subscriptions/listen`\n\nstreams don't read. Switch to `await ctx.notify_resource_updated(uri)`\n\n(or `bus.publish(ResourceUpdated(uri=...))`\n\n). Also check the URI string matches exactly: `MCPServer`\n\ncompares as exact strings, so a subscription to `deploy://api`\n\nhears nothing about `deploy://api/pods`\n\n.\n\n** mcp.shared.exceptions.MCPError: Server returned an error response** right after deploying behind a real hostname. The server log shows\n\n`WARNING mcp.server.transport_security: Invalid Host header: <your-host>`\n\n(HTTP 421). DNS-rebinding protection is on by default and accepts only localhost `Host`\n\nheaders. Pass `transport_security=TransportSecuritySettings(allowed_hosts=[\"mcp.example.com\", \"mcp.example.com:*\"], allowed_origins=[...])`\n\n(from `mcp.server.transport_security`\n\n) to `mcp.run(...)`\n\nor `mcp.streamable_http_app(...)`\n\n.## Next steps\n\n**Reconnect logic.** A stream ends gracefully (the`async for`\n\nexits) or abruptly (`SubscriptionLost`\n\n). Neither replays missed events, and the client holds at most 1024 unconsumed events before dropping the subscription. Wrap`listen()`\n\nin a loop that refetches, backs off a second, and re-listens — the[client Subscriptions page](https://py.sdk.modelcontextprotocol.io/client/subscriptions/)has the pattern.**Gate who may watch.** By default any caller can listen on any URI, including ones your read handler would refuse. Add a middleware that inspects`subscriptions/listen`\n\nrequests and raises`MCPError`\n\nfor URIs the caller can't read — see[server-side Subscriptions](https://py.sdk.modelcontextprotocol.io/handlers/subscriptions/).**Scale past one process.**`InMemorySubscriptionBus`\n\nonly reaches streams in the same process. Behind a load balancer, implement the two-method`SubscriptionBus`\n\nprotocol over Redis pub/sub and pass it as`subscriptions=`\n\n.**Subscribe to list changes too.**`client.listen(tools_list_changed=True, ...)`\n\nplus`ctx.notify_tools_changed()`\n\nlets an agent discover tools you register at runtime with`mcp.add_tool()`\n\n.**Migrating from 1.x?** The[v2 migration guide](https://py.sdk.modelcontextprotocol.io/migration/)covers every breaking change, including the era rules for notifications.\n\n## Sources & further reading\n\n-\n[Subscriptions (server side) - MCP Python SDK](https://py.sdk.modelcontextprotocol.io/handlers/subscriptions/)— py.sdk.modelcontextprotocol.io -\n[Subscriptions (client side) - MCP Python SDK](https://py.sdk.modelcontextprotocol.io/client/subscriptions/)— py.sdk.modelcontextprotocol.io -\n[Migration Guide v1 to v2 - MCP Python SDK](https://py.sdk.modelcontextprotocol.io/migration/)— py.sdk.modelcontextprotocol.io -\n[Troubleshooting - MCP Python SDK](https://py.sdk.modelcontextprotocol.io/troubleshooting/)— py.sdk.modelcontextprotocol.io -\n[Running your server - MCP Python SDK](https://py.sdk.modelcontextprotocol.io/run/)— py.sdk.modelcontextprotocol.io -\n[mcp 2.0.0 on PyPI](https://pypi.org/project/mcp/)— pypi.org\n\n[Mariana Souza](https://sourcefeed.dev/u/mariana_souza)· Senior Editor\n\nMariana covers the fast-moving world of machine learning and generative AI, with a particular focus on how these technologies are reshaping development workflows. When she isn't stress-testing the latest foundation models, she's usually at a local hackathon.\n\n## Discussion 0\n\nNo comments yet\n\nBe the first to weigh in.", "url": "https://wpnews.pro/news/build-an-mcp-server-with-real-time-resource-subscriptions", "canonical_source": "https://sourcefeed.dev/a/build-an-mcp-server-with-real-time-resource-subscriptions", "published_at": "2026-08-23 11:39:43+00:00", "updated_at": "2026-08-23 11:42:54.197467+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["Model Context Protocol", "Python SDK", "Mariana Souza", "InMemorySubscriptionBus", "mcp 2.0.0", "mcp-types 2.0.0"], "alternates": {"html": "https://wpnews.pro/news/build-an-mcp-server-with-real-time-resource-subscriptions", "markdown": "https://wpnews.pro/news/build-an-mcp-server-with-real-time-resource-subscriptions.md", "text": "https://wpnews.pro/news/build-an-mcp-server-with-real-time-resource-subscriptions.txt", "jsonld": "https://wpnews.pro/news/build-an-mcp-server-with-real-time-resource-subscriptions.jsonld"}}