aiohttp-tiny-mcp 0.3.4__tar.gz → 0.3.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/PKG-INFO +1 -1
  2. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/pyproject.toml +1 -1
  3. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/pyproject.toml.orig +1 -1
  4. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/__init__.py +2 -0
  5. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/adapter.py +1 -0
  6. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/client.py +49 -7
  7. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/client_base.py +14 -1
  8. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/console.js +4 -0
  9. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/core.py +1 -0
  10. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/endpoint.py +33 -10
  11. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/extensions.py +53 -1
  12. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/http_sse.py +48 -11
  13. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/models.py +10 -0
  14. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/postgres.py +19 -0
  15. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +10 -1
  16. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/redis.py +6 -0
  17. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/registry.py +50 -2
  18. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/sessions.py +18 -3
  19. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/sqlite.py +8 -0
  20. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/subscriptions.py +81 -8
  21. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/LICENSE +0 -0
  22. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/README.md +0 -0
  23. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/auth.py +0 -0
  24. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
  25. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/console.css +0 -0
  26. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/index.html +0 -0
  27. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
  28. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/exchange.py +0 -0
  29. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/hub.py +0 -0
  30. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
  31. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  32. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
  33. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
  34. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
  35. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  36. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
  37. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/py.typed +0 -0
  38. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/request_state.py +0 -0
  39. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/schema.py +0 -0
  40. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/skills.py +0 -0
  41. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/specs.py +0 -0
  42. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/sse.py +0 -0
  43. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/stdio.py +0 -0
  44. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
  45. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohttp-tiny-mcp
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Remote MCP server and client library for aiohttp
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.3.4"
3
+ version = "0.3.6"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.3.4"
3
+ version = "0.3.6"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -27,6 +27,7 @@ from .models import (
27
27
  GetPromptResult,
28
28
  Hint,
29
29
  ImageContent,
30
+ MethodFilter,
30
31
  PromptMessage,
31
32
  ResourceLink,
32
33
  TextContent,
@@ -63,6 +64,7 @@ __all__ = [
63
64
  "ImageContent",
64
65
  "MemoryHub",
65
66
  "MemorySessionStore",
67
+ "MethodFilter",
66
68
  "NeedInput",
67
69
  "namespace",
68
70
  "PromptMessage",
@@ -90,6 +90,7 @@ class Adapter(ABC):
90
90
  FailureKind.MALFORMED: (-32600, 400),
91
91
  FailureKind.INVALID_PARAMS: (-32602, 200),
92
92
  FailureKind.ORIGIN_REJECTED: (-32600, 403),
93
+ FailureKind.SESSION_NOT_FOUND: (-32001, 404),
93
94
  FailureKind.INTERNAL: (-32603, 200),
94
95
  FailureKind.INVALID_ARGUMENTS: (-32603, 200),
95
96
  FailureKind.UNKNOWN_METHOD: (-32601, 200),
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import logging
6
7
  from collections.abc import AsyncIterator, Callable, Mapping
7
8
  from typing import Any
8
9
 
@@ -19,6 +20,8 @@ NOTIFICATIONS_METHOD = "notifications/message"
19
20
 
20
21
  BASE_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
21
22
 
23
+ log = logging.getLogger(__name__)
24
+
22
25
 
23
26
  async def frames(resp: aiohttp.ClientResponse) -> AsyncIterator[dict[str, Any]]:
24
27
  """Yield JSON or SSE messages immediately; waiting for EOF would deadlock pushed questions."""
@@ -76,23 +79,62 @@ class Client(BaseClient):
76
79
  headers["Mcp-Session-Id"] = self.session_id
77
80
  return headers
78
81
 
82
+ def lost_session(self, resp: aiohttp.ClientResponse) -> bool:
83
+ """Whether the server no longer holds the session this request named.
84
+
85
+ The spec answers such a request with 404. A 404 without a session id
86
+ in flight is an ordinary reply and is read as one.
87
+ """
88
+ return resp.status == 404 and self.session_id is not None
89
+
90
+ async def reopen(self) -> None:
91
+ """Start over with initialize, which mints a session and restores the log level."""
92
+ log.debug("session %s is gone, opening a new one", self.session_id)
93
+ self.session_id = None
94
+ await self.initialize()
95
+
79
96
  async def exchange(
80
97
  self, envelope: dict[str, Any], *, method: str, name: str | None
81
98
  ) -> AsyncIterator[dict[str, Any]]:
82
99
  assert self.session is not None, "use 'async with Client(...) as client:'"
83
100
  tool = self.tool_definitions.get(name) if name is not None else None
84
- headers = self.headers(method, name, envelope.get("params"), tool)
85
- async with self.session.post(self.base_url, json=envelope, headers=headers) as resp:
86
- issued = resp.headers.get("Mcp-Session-Id")
87
- if issued:
88
- self.session_id = issued
101
+ params = envelope.get("params")
102
+ async with self.session.post(
103
+ self.base_url, json=envelope, headers=self.headers(method, name, params, tool)
104
+ ) as resp:
105
+ if not self.lost_session(resp):
106
+ self.adopt(resp)
107
+ async for frame in frames(resp):
108
+ yield frame
109
+ return
110
+ # Retry once on a fresh session; a second 404 is reported as any other reply.
111
+ await self.reopen()
112
+ async with self.session.post(
113
+ self.base_url, json=envelope, headers=self.headers(method, name, params, tool)
114
+ ) as resp:
115
+ self.adopt(resp)
89
116
  async for frame in frames(resp):
90
117
  yield frame
91
118
 
119
+ def adopt(self, resp: aiohttp.ClientResponse) -> None:
120
+ """Keep the session id a handshake reply carries."""
121
+ issued = resp.headers.get("Mcp-Session-Id")
122
+ if issued:
123
+ self.session_id = issued
124
+
92
125
  async def send_notification(self, envelope: dict[str, Any], *, method: str) -> None:
93
126
  assert self.session is not None, "use 'async with Client(...) as client:'"
94
- headers = self.headers(method, params=envelope.get("params"))
95
- async with self.session.post(self.base_url, json=envelope, headers=headers) as resp:
127
+ params = envelope.get("params")
128
+ async with self.session.post(
129
+ self.base_url, json=envelope, headers=self.headers(method, params=params)
130
+ ) as resp:
131
+ if not self.lost_session(resp):
132
+ await resp.read()
133
+ return
134
+ await self.reopen()
135
+ async with self.session.post(
136
+ self.base_url, json=envelope, headers=self.headers(method, params=params)
137
+ ) as resp:
96
138
  await resp.read()
97
139
 
98
140
  async def stream_notifications(
@@ -25,6 +25,7 @@ from .models import (
25
25
  ListPromptsResult,
26
26
  ListResourcesResult,
27
27
  ListToolsResult,
28
+ MethodFilter,
28
29
  Params,
29
30
  PromptDef,
30
31
  ReadResourceParams,
@@ -258,13 +259,25 @@ class BaseClient(ABC):
258
259
  tools_changed: bool = False,
259
260
  prompts_changed: bool = False,
260
261
  resources_changed: bool = False,
262
+ methods: Sequence[str | MethodFilter | Mapping[str, Any]] = (),
261
263
  ) -> AsyncIterator[dict[str, Any]]:
262
- """Yield changes until cancelled, via a request stream or legacy resource subscriptions."""
264
+ """Yield changes until cancelled, via a request stream or legacy resource subscriptions.
265
+
266
+ `methods` names the extension broadcasts to relay: a method name for
267
+ every event of it, or `MethodFilter(method, topics)` (a mapping of
268
+ the same shape will do) for the topics of a multicast. A legacy
269
+ stream carries every broadcast the server declares, so there it is
270
+ not sent.
271
+ """
263
272
  wanted = ListenNotifications(
264
273
  tools_list_changed=tools_changed,
265
274
  prompts_list_changed=prompts_changed,
266
275
  resources_list_changed=resources_changed,
267
276
  resource_subscriptions=list(resources),
277
+ methods=[
278
+ entry if isinstance(entry, str | MethodFilter) else MethodFilter(**entry)
279
+ for entry in methods
280
+ ],
268
281
  )
269
282
  method = self.adapter.method_for(Operation.LISTEN)
270
283
  if method is not None:
@@ -876,6 +876,10 @@ function choose(what) {
876
876
  page.subject.textContent = what.item.name;
877
877
  page.about.textContent = "Enter a method and its JSON parameters from the extension documentation.";
878
878
  reportValue("Capabilities", what.item.capabilities);
879
+ if ((what.item.capabilities || {}).notifications) {
880
+ // Method to the params field that carries its topic; null for a plain broadcast.
881
+ reportValue("Broadcasts", what.item.capabilities.notifications);
882
+ }
879
883
  chosen.fields = buildFields({
880
884
  properties: {
881
885
  method: { type: "string", default: what.item.name === SKILLS_EXTENSION ? "skills/list" : "" },
@@ -89,6 +89,7 @@ class FailureKind(StringEnum):
89
89
  HEADER_MISMATCH = "header_mismatch"
90
90
  UNSUPPORTED_VERSION = "unsupported_version"
91
91
  ORIGIN_REJECTED = "origin_rejected"
92
+ SESSION_NOT_FOUND = "session_not_found"
92
93
  INPUT_UNSUPPORTED = "input_unsupported"
93
94
  MISSING_REQUIRED_CAPABILITY = "missing_required_capability"
94
95
  INTERNAL = "internal"
@@ -291,14 +291,12 @@ class Endpoint:
291
291
  await relay_reply(self.registry.hub, pre.body)
292
292
  return web.Response(status=202)
293
293
 
294
- session = await self.load_session(request)
295
- if not self.owns(session, principal):
296
- session = None
297
- held = self.open_values(request, session)
298
294
  try:
295
+ session = await self.load_session(request, principal)
299
296
  adapter = self.adapters.select(pre, stored_version(session))
300
297
  except Rejected as e:
301
298
  return self.render_failure(self.adapters.fallback(), e.failure)
299
+ held = self.open_values(request, session)
302
300
 
303
301
  log.debug("<- [%s] %s", adapter.version, raw.decode("utf-8", "replace"))
304
302
 
@@ -399,9 +397,10 @@ class Endpoint:
399
397
  assert self.registry.auth is not None
400
398
  return self.refuse(self.registry.auth, refusal)
401
399
 
402
- record = await self.load_session(request)
403
- if not self.owns(record, principal):
404
- record = None
400
+ try:
401
+ record = await self.load_session(request, principal)
402
+ except Rejected as e:
403
+ return self.render_failure(self.adapters.fallback(), e.failure)
405
404
  adapter = self.stream_adapter(request, record)
406
405
  if not adapter.has_handshake:
407
406
  return web.Response(
@@ -436,7 +435,9 @@ class Endpoint:
436
435
  if not found:
437
436
  continue
438
437
  accepted = wanted(
439
- capabilities, self.open_values(request, await self.load_session(request))
438
+ capabilities,
439
+ self.open_values(request, await self.stored(request)),
440
+ self.registry.broadcasts,
440
441
  )
441
442
  for event in found:
442
443
  if relays(event.message, accepted):
@@ -472,13 +473,35 @@ class Endpoint:
472
473
  return True
473
474
  return principal is not None and principal.identity == owner
474
475
 
475
- async def load_session(self, request: web.Request) -> SessionRecord | None:
476
- """Load a legacy handshake, or None if no session exists or it has expired."""
476
+ async def stored(self, request: web.Request) -> SessionRecord | None:
477
+ """The record the session header names, or None where there is no header or no record."""
477
478
  session_id = request.headers.get(SESSION_HEADER)
478
479
  if not session_id:
479
480
  return None
480
481
  return await self.registry.session_store.get(scoped(session_id))
481
482
 
483
+ async def load_session(self, request: web.Request, principal: Any) -> SessionRecord | None:
484
+ """Load the legacy handshake this request names, or None without a session header.
485
+
486
+ Raises `Rejected` where the header names a session the store does not
487
+ hold or another principal owns. The spec answers that with 404 so the
488
+ client opens a new session with `initialize` instead of going on with
489
+ capabilities and a log level this server no longer remembers.
490
+
491
+ A found session has its TTL renewed: it lives while the client keeps
492
+ talking, not for a fixed time after the handshake.
493
+ """
494
+ session_id = request.headers.get(SESSION_HEADER)
495
+ if not session_id:
496
+ return None
497
+ record = await self.registry.session_store.get(scoped(session_id))
498
+ if record is None or not self.owns(record, principal):
499
+ raise Rejected(Failure(FailureKind.SESSION_NOT_FOUND, "session not found"))
500
+ await self.registry.session_store.touch(
501
+ scoped(session_id), ttl_seconds=self.registry.session_ttl_seconds
502
+ )
503
+ return record
504
+
482
505
  def open_values(self, request: web.Request, record: SessionRecord | None) -> Session | None:
483
506
  """The application-owned half of this request's session, if any."""
484
507
  session_id = request.headers.get(SESSION_HEADER)
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from collections.abc import Awaitable, Callable, Mapping
6
+ from collections.abc import Awaitable, Callable, Iterable, Mapping
7
7
  from copy import deepcopy
8
8
  from dataclasses import dataclass
9
9
  from types import MappingProxyType
@@ -17,12 +17,47 @@ from .models import ResultModel
17
17
  from .schema import json_schema
18
18
  from .specs import Bound, ResourceSpec
19
19
 
20
+ RESERVED_NOTIFICATIONS = frozenset(
21
+ {
22
+ "notifications/cancelled",
23
+ "notifications/initialized",
24
+ "notifications/message",
25
+ "notifications/progress",
26
+ "notifications/prompts/list_changed",
27
+ "notifications/resources/list_changed",
28
+ "notifications/resources/updated",
29
+ "notifications/roots/list_changed",
30
+ "notifications/subscriptions/acknowledged",
31
+ "notifications/tools/list_changed",
32
+ }
33
+ )
34
+
35
+
36
+ def check_notification(name: str, field: str | None) -> None:
37
+ """Refuse a broadcast name the protocol owns or a client could not route, and a topic
38
+ field a listener could not match on.
39
+ """
40
+ if (
41
+ not name.startswith("notifications/")
42
+ or len(name) <= len("notifications/")
43
+ or any(char.isspace() for char in name)
44
+ or any(part in ("", ".", "..") for part in name.split("/"))
45
+ ):
46
+ raise ValueError(f"invalid broadcast notification: {name!r}")
47
+ if name in RESERVED_NOTIFICATIONS:
48
+ raise ValueError(f"extension cannot broadcast a protocol notification: {name}")
49
+ if field is not None and (not isinstance(field, str) or not field or field == "_meta"):
50
+ raise ValueError(f"invalid topic field for {name}: {field!r}")
51
+
20
52
 
21
53
  @dataclass(frozen=True)
22
54
  class ExtensionSpec:
23
55
  capabilities: Mapping[str, Any]
24
56
  min_revision: str
25
57
  methods: Mapping[str, Bound]
58
+ #: Broadcast method to the params field that carries its topic, or None for one
59
+ #: every listener of the method gets.
60
+ notifications: Mapping[str, str | None] = MappingProxyType({})
26
61
 
27
62
 
28
63
  class ExtensionResult(ResultModel):
@@ -84,6 +119,7 @@ class Extension:
84
119
  *,
85
120
  capabilities: Mapping[str, Any] | None = None,
86
121
  min_revision: str = "2026-07-28",
122
+ notifications: Iterable[str] | Mapping[str, str | None] = (),
87
123
  ) -> None:
88
124
  if (
89
125
  not name
@@ -99,6 +135,18 @@ class Extension:
99
135
  self.min_revision = min_revision
100
136
  self.methods: dict[str, Bound] = {}
101
137
  self.resources: dict[str, ResourceSpec] = {}
138
+ #: Methods `Registry.broadcast` may send, each with the params field that
139
+ #: carries its topic. None makes it a broadcast every listener of the method
140
+ #: gets; a field makes it a multicast a listener filters by topic.
141
+ self.notifications: dict[str, str | None] = {}
142
+ declared = (
143
+ notifications.items()
144
+ if isinstance(notifications, Mapping)
145
+ else ((method, None) for method in notifications)
146
+ )
147
+ for method, field in declared:
148
+ check_notification(method, field)
149
+ self.notifications[method] = field
102
150
 
103
151
  def method(self, name: str, fn: Callable[..., Awaitable[Any]] | None = None):
104
152
  """Register an async handler whose first argument is a Pydantic model.
@@ -233,6 +281,9 @@ class Extension:
233
281
  ),
234
282
  "capabilities": self.capabilities,
235
283
  "methods": sorted(self.methods),
284
+ "notifications": {
285
+ name: self.notifications[name] for name in sorted(self.notifications)
286
+ },
236
287
  "resources": sorted(
237
288
  [
238
289
  spec.legacy_uri
@@ -276,4 +327,5 @@ class Extension:
276
327
  methods=MappingProxyType(
277
328
  {name: bound.model_copy(deep=True) for name, bound in self.methods.items()}
278
329
  ),
330
+ notifications=MappingProxyType(dict(self.notifications)),
279
331
  )
@@ -5,8 +5,10 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
+ from collections.abc import Coroutine
8
9
  from contextlib import suppress
9
10
  from dataclasses import replace
11
+ from typing import Any
10
12
 
11
13
  from aiohttp import web
12
14
 
@@ -24,7 +26,7 @@ from .core import (
24
26
  from .dispatcher import Dispatcher
25
27
  from .endpoint import Endpoint
26
28
  from .exchange import Exchange
27
- from .hub import Subscription, topic
29
+ from .hub import NOTIFICATIONS, Subscription, topic
28
30
  from .namespaces import scoped
29
31
  from .protocol.selection import AdapterSet
30
32
  from .registry import Registry
@@ -38,6 +40,7 @@ from .sessions import (
38
40
  stored_version,
39
41
  )
40
42
  from .sse import SSEResponse
43
+ from .subscriptions import relays, wanted
41
44
 
42
45
  log = logging.getLogger(__name__)
43
46
 
@@ -103,7 +106,9 @@ class SseEndpoint:
103
106
  session_id = await self.open_session()
104
107
  where = topic(STREAM, session_id)
105
108
  hub = self.registry.hub
106
- events = await hub.subscribe(where, wait=self.registry.hub_poll_seconds)
109
+ wait = self.registry.hub_poll_seconds
110
+ events = await hub.subscribe(where, wait=wait)
111
+ shared = await hub.subscribe(topic(NOTIFICATIONS), wait=wait)
107
112
 
108
113
  response = SSEResponse(compress=self.compress)
109
114
  await response.prepare(request)
@@ -111,7 +116,11 @@ class SseEndpoint:
111
116
  await self.write(response, "endpoint", f"{posting}?session_id={session_id}")
112
117
 
113
118
  try:
114
- await self.relay_until_disconnect(request, response, events)
119
+ await self.relay_until_disconnect(
120
+ request,
121
+ self.relay(response, events),
122
+ self.relay_shared(response, shared, session_id),
123
+ )
115
124
  finally:
116
125
  await hub.delete(where)
117
126
  await self.registry.session_store.delete(scoped(session_id))
@@ -128,26 +137,54 @@ class SseEndpoint:
128
137
  return session_id
129
138
 
130
139
  async def relay_until_disconnect(
131
- self, request: web.Request, response: SSEResponse, events: Subscription
140
+ self, request: web.Request, *relays: Coroutine[Any, Any, None]
132
141
  ) -> None:
133
- """Run a relay until its client disconnects, then always join it."""
134
- relay = asyncio.create_task(self.relay(response, events))
142
+ """Run the relays until the client disconnects or one ends, then always join them."""
143
+ tasks = [asyncio.create_task(relay) for relay in relays]
135
144
  try:
136
- while not relay.done():
137
- await asyncio.wait({relay}, timeout=0.05)
145
+ while not any(task.done() for task in tasks):
146
+ await asyncio.wait(tasks, timeout=0.05, return_when=asyncio.FIRST_COMPLETED)
138
147
  transport = request.transport
139
148
  if transport is None or transport.is_closing():
140
149
  break
141
150
  finally:
142
- relay.cancel()
143
- with suppress(asyncio.CancelledError, ConnectionError):
144
- await relay
151
+ for task in tasks:
152
+ task.cancel()
153
+ for task in tasks:
154
+ with suppress(asyncio.CancelledError, ConnectionError):
155
+ await task
145
156
 
146
157
  async def relay(self, response: SSEResponse, events: Subscription) -> None:
147
158
  """Write everything published for this connection, until cancelled."""
148
159
  async for event in events:
149
160
  await self.write(response, "message", json.dumps(event.message, ensure_ascii=False))
150
161
 
162
+ async def relay_shared(
163
+ self, response: SSEResponse, events: Subscription, session_id: str
164
+ ) -> None:
165
+ """Write the list changes, resource updates and broadcasts this session wants.
166
+
167
+ The session is re-read for each batch, so a subscription made through
168
+ the message endpoint takes effect on the stream that is already open.
169
+ """
170
+ store = self.registry.session_store
171
+ while True:
172
+ found = await events.poll()
173
+ if not found:
174
+ continue
175
+ record = await store.get(scoped(session_id))
176
+ if record is None:
177
+ return
178
+ adapter = self.adapters.resolve_version(stored_version(record) or VERSION)
179
+ session = Session(store, session_id, record, self.registry.session_ttl_seconds)
180
+ accepted = wanted(
181
+ adapter.capabilities(self.registry), session, self.registry.broadcasts
182
+ )
183
+ for event in found:
184
+ if relays(event.message, accepted):
185
+ text = json.dumps(event.message, ensure_ascii=False)
186
+ await self.write(response, "message", text)
187
+
151
188
  async def write(self, response: SSEResponse, event: str, data: str) -> None:
152
189
  log.debug("-> [%s] %s %s", VERSION, event, data)
153
190
  await response.send(data, event=event)
@@ -313,11 +313,21 @@ class ListParams(Params):
313
313
  cursor: str | None = None
314
314
 
315
315
 
316
+ class MethodFilter(Model):
317
+ """One extension multicast, narrowed to the topics named."""
318
+
319
+ method: StrictStr
320
+ topics: list[StrictStr] = Field(default_factory=list)
321
+
322
+
316
323
  class ListenNotifications(Model):
317
324
  tools_list_changed: StrictBool = False
318
325
  prompts_list_changed: StrictBool = False
319
326
  resources_list_changed: StrictBool = False
320
327
  resource_subscriptions: list[StrictStr] = Field(default_factory=list)
328
+ #: Extension broadcasts to relay: a method name for every event of it, or a
329
+ #: `MethodFilter` for the topics of a multicast.
330
+ methods: list[StrictStr | MethodFilter] = Field(default_factory=list)
321
331
 
322
332
 
323
333
  class ListenParams(Params):
@@ -301,6 +301,19 @@ class PostgresSessionStore(SessionStore):
301
301
  ]
302
302
  )
303
303
 
304
+ @cached_property
305
+ def _query_touch(self) -> sql.Composed:
306
+ return sql.Composed(
307
+ [
308
+ sql.SQL("UPDATE "),
309
+ self.storage.sessions_table,
310
+ sql.SQL("""
311
+ SET expires_at = now() + make_interval(secs => %s)
312
+ WHERE id = %s AND expires_at > now()
313
+ """),
314
+ ]
315
+ )
316
+
304
317
  @cached_property
305
318
  def _query_delete(self) -> sql.Composed:
306
319
  return sql.Composed(
@@ -343,6 +356,12 @@ class PostgresSessionStore(SessionStore):
343
356
  )
344
357
  return found.rowcount > 0
345
358
 
359
+ async def touch(self, session_id: str, *, ttl_seconds: int) -> bool:
360
+ pool = await self.storage.open()
361
+ async with pool.connection() as connection:
362
+ found = await connection.execute(self._query_touch, (ttl_seconds, session_id))
363
+ return found.rowcount > 0
364
+
346
365
  async def delete(self, session_id: str) -> None:
347
366
  pool = await self.storage.open()
348
367
  async with pool.connection() as connection:
@@ -324,7 +324,16 @@ class Adapter2026_07_28(Adapter): # noqa: N801 -- revision date, greppable agai
324
324
  def capabilities(self, registry: RegistryProtocol) -> dict[str, Any]:
325
325
  caps = dict(super().capabilities(registry))
326
326
  extensions = {
327
- name: dict(spec.capabilities)
327
+ name: (
328
+ {
329
+ **spec.capabilities,
330
+ "notifications": {
331
+ name: spec.notifications[name] for name in sorted(spec.notifications)
332
+ },
333
+ }
334
+ if spec.notifications
335
+ else dict(spec.capabilities)
336
+ )
328
337
  for name, spec in registry.extensions.items()
329
338
  if spec.min_revision <= self.version
330
339
  }
@@ -126,6 +126,12 @@ class RedisSessionStore(SessionStore):
126
126
  )
127
127
  return bool(written)
128
128
 
129
+ async def touch(self, session_id: str, *, ttl_seconds: int) -> bool:
130
+ """EXPIRE answers 1 only for a key that exists, so a lost session reads False."""
131
+ client = await self.storage.open()
132
+ renewed: Any = await client.expire(self.key(session_id), ttl_seconds)
133
+ return bool(renewed)
134
+
129
135
  async def delete(self, session_id: str) -> None:
130
136
  client = await self.storage.open()
131
137
  await client.delete(self.key(session_id))
@@ -2,13 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Awaitable, Callable
5
+ from collections.abc import Awaitable, Callable, Mapping
6
6
  from typing import Any
7
7
 
8
8
  from .auth import Authorization, Principal
9
9
  from .exchange import Exchange, Instance
10
10
  from .extensions import Extension, ExtensionSpec
11
- from .hub import Hub, MemoryHub
11
+ from .hub import NOTIFICATIONS, Cursor, Hub, MemoryHub, topic
12
12
  from .models import Implementation
13
13
  from .request_state import DEFAULT_TTL_SECONDS as STATE_TTL_SECONDS
14
14
  from .request_state import RequestStates
@@ -85,6 +85,11 @@ class Registry:
85
85
  if any(name in spec.methods for spec in self.extensions.values()):
86
86
  raise ValueError(f"duplicate extension method: {name}")
87
87
  self.check(name, bound)
88
+ for method in extension.notifications:
89
+ if any(adapter.operation_for(method) is not None for adapter in adapters):
90
+ raise ValueError(f"extension cannot broadcast a protocol notification: {method}")
91
+ if method in self.broadcasts:
92
+ raise ValueError(f"duplicate broadcast notification: {method}")
88
93
  routes, methods = extension.method_resources()
89
94
  manifest = extension.manifest_resource(routes, methods)
90
95
  assert manifest.definition is not None
@@ -129,6 +134,49 @@ class Registry:
129
134
  else:
130
135
  self.resources_templated.append(resource)
131
136
 
137
+ @property
138
+ def broadcasts(self) -> frozenset[str]:
139
+ """Every notification method some installed extension may broadcast."""
140
+ return frozenset(self.topic_fields)
141
+
142
+ @property
143
+ def topic_fields(self) -> dict[str, str | None]:
144
+ """Each declared broadcast with the params field that carries its topic, or None."""
145
+ found: dict[str, str | None] = {}
146
+ for spec in self.extensions.values():
147
+ found.update(spec.notifications)
148
+ return found
149
+
150
+ async def broadcast(
151
+ self,
152
+ method: str,
153
+ params: Mapping[str, Any] | None = None,
154
+ *,
155
+ meta: Mapping[str, Any] | None = None,
156
+ ) -> Cursor:
157
+ """Send one notification to every client listening in the current namespace.
158
+
159
+ `method` must be declared by an installed extension. A method declared
160
+ with a topic field is a multicast: `params` must carry that field as a
161
+ string, and a listener that named topics gets the event only when the
162
+ value is one of them. The event goes through the hub, so a listener
163
+ on another worker gets it too, and a legacy stream that reconnects
164
+ with `Last-Event-ID` is replayed it. Returns the hub id the event was
165
+ stored under.
166
+ """
167
+ fields = self.topic_fields
168
+ if method not in fields:
169
+ raise ValueError(f"no installed extension declares the broadcast {method!r}")
170
+ sent: dict[str, Any] = dict(params or {})
171
+ field = fields[method]
172
+ if field is not None and not isinstance(sent.get(field), str):
173
+ raise ValueError(f"{method} is a multicast: params[{field!r}] must be a string topic")
174
+ if meta:
175
+ sent["_meta"] = {**dict(sent.get("_meta") or {}), **meta}
176
+ return await self.hub.publish(
177
+ topic(NOTIFICATIONS), {"jsonrpc": "2.0", "method": method, "params": sent}
178
+ )
179
+
132
180
  def provide(self, kind: type, source: Any) -> None:
133
181
  """Bind a type to where it comes from. Call before registering
134
182
  handlers -- plans are checked at registration, not at first call.
@@ -43,11 +43,12 @@ class SessionStore(Protocol):
43
43
  """Application-provided storage, safe across workers.
44
44
 
45
45
  create() atomically returns False for an existing id. save() atomically returns False when
46
- expected_version no longer matches. Store only JSON-serializable values, never requests,
47
- sockets, queues, or tasks.
46
+ expected_version no longer matches. touch() renews the TTL without a write to the data, so
47
+ a session stays alive as long as its client keeps sending requests. Store only
48
+ JSON-serializable values, never requests, sockets, queues, or tasks.
48
49
 
49
50
  Subclass it to have the methods checked and the missing ones refused, or
50
- supply any object with these four methods: this is a protocol, so a
51
+ supply any object with these five methods: this is a protocol, so a
51
52
  backend that inherits nothing is still a SessionStore.
52
53
  """
53
54
 
@@ -70,6 +71,12 @@ class SessionStore(Protocol):
70
71
  ) -> bool:
71
72
  """Replace the data and renew the TTL, or return False where the version moved."""
72
73
 
74
+ @abstractmethod
75
+ async def touch(self, session_id: str, *, ttl_seconds: int) -> bool:
76
+ """Renew the TTL of a live record, keeping its data and version. Return False where
77
+ it is missing or expired.
78
+ """
79
+
73
80
  @abstractmethod
74
81
  async def delete(self, session_id: str) -> None:
75
82
  """Forget it."""
@@ -118,6 +125,14 @@ class MemorySessionStore(SessionStore):
118
125
  self.records[session_id] = (dict(data), expected_version + 1, self.clock() + ttl_seconds)
119
126
  return True
120
127
 
128
+ async def touch(self, session_id: str, *, ttl_seconds: int) -> bool:
129
+ entry = self.live(session_id)
130
+ if entry is None:
131
+ return False
132
+ data, version, _ = entry
133
+ self.records[session_id] = (data, version, self.clock() + ttl_seconds)
134
+ return True
135
+
121
136
  async def delete(self, session_id: str) -> None:
122
137
  self.records.pop(session_id, None)
123
138
 
@@ -165,6 +165,14 @@ class SqliteSessionStore(SessionStore):
165
165
  )
166
166
  return cursor.rowcount > 0
167
167
 
168
+ async def touch(self, session_id: str, *, ttl_seconds: int) -> bool:
169
+ connection = await self.storage.open()
170
+ cursor = await connection.execute(
171
+ f"UPDATE mcp_sessions SET expires_at = {NOW} + ? WHERE id = ? AND expires_at > {NOW}",
172
+ (ttl_seconds, session_id),
173
+ )
174
+ return cursor.rowcount > 0
175
+
168
176
  async def delete(self, session_id: str) -> None:
169
177
  connection = await self.storage.open()
170
178
  await connection.execute("DELETE FROM mcp_sessions WHERE id = ?", (session_id,))
@@ -1,15 +1,18 @@
1
1
  """Notification filtering for modern request streams and legacy session subscriptions.
2
2
 
3
3
  Both read the same hub topic; legacy streams reload subscriptions from the shared session.
4
+ Extension broadcasts travel the same topic: a modern stream names the methods it wants, a
5
+ legacy stream gets every declared one.
4
6
  """
5
7
 
6
8
  from __future__ import annotations
7
9
 
8
- from collections.abc import Mapping
10
+ from collections.abc import Iterable, Mapping
11
+ from types import MappingProxyType
9
12
  from typing import TYPE_CHECKING, Any
10
13
 
11
14
  from . import hub as hubs
12
- from .models import EmptyResult, ListenParams, Meta
15
+ from .models import EmptyResult, ListenParams, Meta, MethodFilter
13
16
  from .sessions import SUBSCRIPTIONS_KEY, Session
14
17
 
15
18
  if TYPE_CHECKING:
@@ -29,8 +32,17 @@ def tag(payload: Mapping[str, Any], subscription_id: str | int) -> dict[str, Any
29
32
  return {**payload, "params": params}
30
33
 
31
34
 
32
- def relays(payload: Mapping[str, Any], accepted: Mapping[str, Any]) -> bool:
33
- """Whether this event is one the client asked for."""
35
+ def relays(
36
+ payload: Mapping[str, Any],
37
+ accepted: Mapping[str, Any],
38
+ fields: Mapping[str, str | None] = MappingProxyType({}),
39
+ ) -> bool:
40
+ """Whether this event is one the client asked for.
41
+
42
+ `fields` maps a multicast method to the params field that carries its
43
+ topic; an accepted entry with `topics` matches only when that field holds
44
+ one of them.
45
+ """
34
46
  method = payload.get("method")
35
47
  if "id" in payload or payload.get("jsonrpc") != "2.0":
36
48
  return False
@@ -39,7 +51,59 @@ def relays(payload: Mapping[str, Any], accepted: Mapping[str, Any]) -> bool:
39
51
  if not isinstance(params, Mapping):
40
52
  return False
41
53
  return params.get("uri") in accepted.get("resourceSubscriptions", [])
42
- return method in LIST_CHANGES and bool(accepted.get(LIST_CHANGES[method][0]))
54
+ if method in LIST_CHANGES:
55
+ return bool(accepted.get(LIST_CHANGES[method][0]))
56
+ for entry in accepted.get("methods", ()):
57
+ if isinstance(entry, str):
58
+ if entry == method:
59
+ return True
60
+ continue
61
+ if entry.get("method") != method:
62
+ continue
63
+ field = fields.get(method)
64
+ params = payload.get("params")
65
+ if field is None or not isinstance(params, Mapping):
66
+ return False
67
+ return params.get(field) in entry.get("topics", ())
68
+ return False
69
+
70
+
71
+ def broadcasts_for(ex: Exchange) -> dict[str, str | None]:
72
+ """The broadcasts this revision may hear, those of the extensions it can see, each with
73
+ its topic field.
74
+ """
75
+ found: dict[str, str | None] = {}
76
+ for spec in ex.registry.extensions.values():
77
+ if spec.min_revision <= ex.adapter.version:
78
+ found.update(spec.notifications)
79
+ return found
80
+
81
+
82
+ def merged(
83
+ requested: Iterable[str | MethodFilter], declared: Mapping[str, str | None]
84
+ ) -> list[str | dict[str, Any]]:
85
+ """The entries a listen request keeps, in first-seen order, one per method.
86
+
87
+ A bare name asks for every event of the method, so it wins over any
88
+ filter for the same method. Filters on one method merge their topics. A
89
+ filter on a method declared without a topic field has nothing to match
90
+ on and becomes the bare name. Undeclared methods are dropped.
91
+ """
92
+ kept: dict[str, list[str] | None] = {}
93
+ for entry in requested:
94
+ name = entry if isinstance(entry, str) else entry.method
95
+ if name not in declared:
96
+ continue
97
+ topics = None if isinstance(entry, str) or declared[name] is None else entry.topics
98
+ held = kept.get(name, [])
99
+ if topics is None or held is None:
100
+ kept[name] = None
101
+ else:
102
+ kept[name] = list(dict.fromkeys([*held, *topics]))
103
+ return [
104
+ name if topics is None else {"method": name, "topics": topics}
105
+ for name, topics in kept.items()
106
+ ]
43
107
 
44
108
 
45
109
  async def listen(ex: Exchange) -> EmptyResult:
@@ -55,6 +119,10 @@ async def listen(ex: Exchange) -> EmptyResult:
55
119
  uris = [uri for uri in wanted.resource_subscriptions if ex.registry.match_resource(uri)]
56
120
  if uris:
57
121
  accepted["resourceSubscriptions"] = list(dict.fromkeys(uris))
122
+ declared = broadcasts_for(ex)
123
+ methods = merged(wanted.methods, declared)
124
+ if methods:
125
+ accepted["methods"] = methods
58
126
 
59
127
  ack = tag(
60
128
  {
@@ -73,7 +141,7 @@ async def listen(ex: Exchange) -> EmptyResult:
73
141
  await ex.emit(ack)
74
142
  while not ex.cancelled.is_set():
75
143
  for event in await events.poll():
76
- if relays(event.message, accepted):
144
+ if relays(event.message, accepted, declared):
77
145
  await ex.emit(tag(event.message, ex.id))
78
146
  return EmptyResult(meta=Meta.model_validate({SUBSCRIPTION_ID: ex.id}))
79
147
 
@@ -107,9 +175,11 @@ async def unsubscribe(session: Session, uri: str) -> None:
107
175
  )
108
176
 
109
177
 
110
- def wanted(capabilities: Mapping[str, Any], session: Session | None) -> dict[str, Any]:
178
+ def wanted(
179
+ capabilities: Mapping[str, Any], session: Session | None, broadcasts: Iterable[str] = ()
180
+ ) -> dict[str, Any]:
111
181
  """Legacy list changes follow advertised capabilities; resource updates follow session
112
- subscriptions.
182
+ subscriptions; every declared broadcast is relayed, as a legacy stream cannot choose.
113
183
  """
114
184
  accepted: dict[str, Any] = {}
115
185
  for field, capability in LIST_CHANGES.values():
@@ -119,4 +189,7 @@ def wanted(capabilities: Mapping[str, Any], session: Session | None) -> dict[str
119
189
  uris = subscribed(session)
120
190
  if uris:
121
191
  accepted["resourceSubscriptions"] = uris
192
+ methods = sorted(broadcasts)
193
+ if methods:
194
+ accepted["methods"] = methods
122
195
  return accepted