aiohttp-tiny-mcp 0.3.5__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.5 → aiohttp_tiny_mcp-0.3.6}/PKG-INFO +1 -1
  2. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/pyproject.toml +1 -1
  3. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/pyproject.toml.orig +1 -1
  4. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/__init__.py +2 -0
  5. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/client_base.py +14 -1
  6. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/console.js +4 -0
  7. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/endpoint.py +5 -1
  8. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/extensions.py +53 -1
  9. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/http_sse.py +48 -11
  10. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/models.py +10 -0
  11. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +10 -1
  12. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/registry.py +50 -2
  13. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/subscriptions.py +81 -8
  14. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/LICENSE +0 -0
  15. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/README.md +0 -0
  16. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/adapter.py +0 -0
  17. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/auth.py +0 -0
  18. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/client.py +0 -0
  19. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
  20. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/console.css +0 -0
  21. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/index.html +0 -0
  22. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/core.py +0 -0
  23. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
  24. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/exchange.py +0 -0
  25. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/hub.py +0 -0
  26. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
  27. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/postgres.py +0 -0
  28. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  29. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
  30. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
  31. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
  32. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  33. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
  34. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/py.typed +0 -0
  35. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/redis.py +0 -0
  36. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/request_state.py +0 -0
  37. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/schema.py +0 -0
  38. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/sessions.py +0 -0
  39. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/skills.py +0 -0
  40. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/specs.py +0 -0
  41. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
  42. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/sse.py +0 -0
  43. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/stdio.py +0 -0
  44. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
  45. {aiohttp_tiny_mcp-0.3.5 → 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.5
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.5"
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.5"
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",
@@ -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" : "" },
@@ -434,7 +434,11 @@ class Endpoint:
434
434
  found = await events.poll()
435
435
  if not found:
436
436
  continue
437
- accepted = wanted(capabilities, self.open_values(request, await self.stored(request)))
437
+ accepted = wanted(
438
+ capabilities,
439
+ self.open_values(request, await self.stored(request)),
440
+ self.registry.broadcasts,
441
+ )
438
442
  for event in found:
439
443
  if relays(event.message, accepted):
440
444
  text = json.dumps(event.message, ensure_ascii=False)
@@ -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):
@@ -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
  }
@@ -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.
@@ -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