aiohttp-tiny-mcp 0.3.5__tar.gz → 0.3.7__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 (46) hide show
  1. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/PKG-INFO +1 -1
  2. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/pyproject.toml +1 -1
  3. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/pyproject.toml.orig +1 -1
  4. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/__init__.py +2 -0
  5. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/client_base.py +14 -1
  6. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/console/console.js +4 -0
  7. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/endpoint.py +8 -9
  8. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/extensions.py +54 -1
  9. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/http_sse.py +45 -12
  10. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/models.py +10 -0
  11. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +10 -1
  12. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/registry.py +50 -2
  13. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/stdio.py +4 -2
  14. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/subscriptions.py +81 -8
  15. aiohttp_tiny_mcp-0.3.7/src/aiohttp_tiny_mcp/tasks.py +37 -0
  16. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/LICENSE +0 -0
  17. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/README.md +0 -0
  18. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/adapter.py +0 -0
  19. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/auth.py +0 -0
  20. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/client.py +0 -0
  21. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
  22. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/console/console.css +0 -0
  23. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/console/index.html +0 -0
  24. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/core.py +0 -0
  25. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
  26. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/exchange.py +0 -0
  27. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/hub.py +0 -0
  28. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
  29. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/postgres.py +0 -0
  30. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  31. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
  32. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
  33. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
  34. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  35. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
  36. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/py.typed +0 -0
  37. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/redis.py +0 -0
  38. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/request_state.py +0 -0
  39. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/schema.py +0 -0
  40. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/sessions.py +0 -0
  41. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/skills.py +0 -0
  42. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/specs.py +0 -0
  43. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
  44. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/sse.py +0 -0
  45. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
  46. {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/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.7
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.7"
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.7"
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" : "" },
@@ -7,7 +7,6 @@ import json
7
7
  import logging
8
8
  import re
9
9
  from collections.abc import Mapping
10
- from contextlib import suppress
11
10
  from dataclasses import replace
12
11
  from functools import cached_property
13
12
  from typing import Any
@@ -46,6 +45,7 @@ from .sessions import (
46
45
  )
47
46
  from .sse import SSEResponse
48
47
  from .subscriptions import relays, wanted
48
+ from .tasks import stop
49
49
 
50
50
  log = logging.getLogger("aiohttp_tiny_mcp")
51
51
 
@@ -419,9 +419,7 @@ class Endpoint:
419
419
  if transport is None or transport.is_closing():
420
420
  break
421
421
  finally:
422
- relay.cancel()
423
- with suppress(asyncio.CancelledError, ConnectionError):
424
- await relay
422
+ await stop(relay)
425
423
  return response
426
424
 
427
425
  async def relay_notifications(
@@ -434,7 +432,11 @@ class Endpoint:
434
432
  found = await events.poll()
435
433
  if not found:
436
434
  continue
437
- accepted = wanted(capabilities, self.open_values(request, await self.stored(request)))
435
+ accepted = wanted(
436
+ capabilities,
437
+ self.open_values(request, await self.stored(request)),
438
+ self.registry.broadcasts,
439
+ )
438
440
  for event in found:
439
441
  if relays(event.message, accepted):
440
442
  text = json.dumps(event.message, ensure_ascii=False)
@@ -574,10 +576,7 @@ class Endpoint:
574
576
  ex.cancel()
575
577
  raise
576
578
  finally:
577
- if not task.done():
578
- task.cancel()
579
- with suppress(asyncio.CancelledError):
580
- await task
579
+ await stop(task)
581
580
 
582
581
 
583
582
  MCP_ENDPOINT = web.AppKey("mcp_endpoint", Endpoint)
@@ -2,8 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import dataclasses
5
6
  import json
6
- from collections.abc import Awaitable, Callable, Mapping
7
+ from collections.abc import Awaitable, Callable, Iterable, Mapping
7
8
  from copy import deepcopy
8
9
  from dataclasses import dataclass
9
10
  from types import MappingProxyType
@@ -17,12 +18,47 @@ from .models import ResultModel
17
18
  from .schema import json_schema
18
19
  from .specs import Bound, ResourceSpec
19
20
 
21
+ RESERVED_NOTIFICATIONS = frozenset(
22
+ {
23
+ "notifications/cancelled",
24
+ "notifications/initialized",
25
+ "notifications/message",
26
+ "notifications/progress",
27
+ "notifications/prompts/list_changed",
28
+ "notifications/resources/list_changed",
29
+ "notifications/resources/updated",
30
+ "notifications/roots/list_changed",
31
+ "notifications/subscriptions/acknowledged",
32
+ "notifications/tools/list_changed",
33
+ }
34
+ )
35
+
36
+
37
+ def check_notification(name: str, field: str | None) -> None:
38
+ """Refuse a broadcast name the protocol owns or a client could not route, and a topic
39
+ field a listener could not match on.
40
+ """
41
+ if (
42
+ not name.startswith("notifications/")
43
+ or len(name) <= len("notifications/")
44
+ or any(char.isspace() for char in name)
45
+ or any(part in ("", ".", "..") for part in name.split("/"))
46
+ ):
47
+ raise ValueError(f"invalid broadcast notification: {name!r}")
48
+ if name in RESERVED_NOTIFICATIONS:
49
+ raise ValueError(f"extension cannot broadcast a protocol notification: {name}")
50
+ if field is not None and (not isinstance(field, str) or not field or field == "_meta"):
51
+ raise ValueError(f"invalid topic field for {name}: {field!r}")
52
+
20
53
 
21
54
  @dataclass(frozen=True)
22
55
  class ExtensionSpec:
23
56
  capabilities: Mapping[str, Any]
24
57
  min_revision: str
25
58
  methods: Mapping[str, Bound]
59
+ #: Broadcast method to the params field that carries its topic, or None for one
60
+ #: every listener of the method gets.
61
+ notifications: Mapping[str, str | None] = dataclasses.field(default_factory=dict)
26
62
 
27
63
 
28
64
  class ExtensionResult(ResultModel):
@@ -84,6 +120,7 @@ class Extension:
84
120
  *,
85
121
  capabilities: Mapping[str, Any] | None = None,
86
122
  min_revision: str = "2026-07-28",
123
+ notifications: Iterable[str] | Mapping[str, str | None] = (),
87
124
  ) -> None:
88
125
  if (
89
126
  not name
@@ -99,6 +136,18 @@ class Extension:
99
136
  self.min_revision = min_revision
100
137
  self.methods: dict[str, Bound] = {}
101
138
  self.resources: dict[str, ResourceSpec] = {}
139
+ #: Methods `Registry.broadcast` may send, each with the params field that
140
+ #: carries its topic. None makes it a broadcast every listener of the method
141
+ #: gets; a field makes it a multicast a listener filters by topic.
142
+ self.notifications: dict[str, str | None] = {}
143
+ declared = (
144
+ notifications.items()
145
+ if isinstance(notifications, Mapping)
146
+ else ((method, None) for method in notifications)
147
+ )
148
+ for method, field in declared:
149
+ check_notification(method, field)
150
+ self.notifications[method] = field
102
151
 
103
152
  def method(self, name: str, fn: Callable[..., Awaitable[Any]] | None = None):
104
153
  """Register an async handler whose first argument is a Pydantic model.
@@ -233,6 +282,9 @@ class Extension:
233
282
  ),
234
283
  "capabilities": self.capabilities,
235
284
  "methods": sorted(self.methods),
285
+ "notifications": {
286
+ name: self.notifications[name] for name in sorted(self.notifications)
287
+ },
236
288
  "resources": sorted(
237
289
  [
238
290
  spec.legacy_uri
@@ -276,4 +328,5 @@ class Extension:
276
328
  methods=MappingProxyType(
277
329
  {name: bound.model_copy(deep=True) for name, bound in self.methods.items()}
278
330
  ),
331
+ notifications=MappingProxyType(dict(self.notifications)),
279
332
  )
@@ -5,8 +5,9 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
- from contextlib import suppress
8
+ from collections.abc import Coroutine
9
9
  from dataclasses import replace
10
+ from typing import Any
10
11
 
11
12
  from aiohttp import web
12
13
 
@@ -24,7 +25,7 @@ from .core import (
24
25
  from .dispatcher import Dispatcher
25
26
  from .endpoint import Endpoint
26
27
  from .exchange import Exchange
27
- from .hub import Subscription, topic
28
+ from .hub import NOTIFICATIONS, Subscription, topic
28
29
  from .namespaces import scoped
29
30
  from .protocol.selection import AdapterSet
30
31
  from .registry import Registry
@@ -38,6 +39,8 @@ from .sessions import (
38
39
  stored_version,
39
40
  )
40
41
  from .sse import SSEResponse
42
+ from .subscriptions import relays, wanted
43
+ from .tasks import stop
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,50 @@ 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
+ await stop(*tasks)
145
152
 
146
153
  async def relay(self, response: SSEResponse, events: Subscription) -> None:
147
154
  """Write everything published for this connection, until cancelled."""
148
155
  async for event in events:
149
156
  await self.write(response, "message", json.dumps(event.message, ensure_ascii=False))
150
157
 
158
+ async def relay_shared(
159
+ self, response: SSEResponse, events: Subscription, session_id: str
160
+ ) -> None:
161
+ """Write the list changes, resource updates and broadcasts this session wants.
162
+
163
+ The session is re-read for each batch, so a subscription made through
164
+ the message endpoint takes effect on the stream that is already open.
165
+ """
166
+ store = self.registry.session_store
167
+ while True:
168
+ found = await events.poll()
169
+ if not found:
170
+ continue
171
+ record = await store.get(scoped(session_id))
172
+ if record is None:
173
+ return
174
+ adapter = self.adapters.resolve_version(stored_version(record) or VERSION)
175
+ session = Session(store, session_id, record, self.registry.session_ttl_seconds)
176
+ accepted = wanted(
177
+ adapter.capabilities(self.registry), session, self.registry.broadcasts
178
+ )
179
+ for event in found:
180
+ if relays(event.message, accepted):
181
+ text = json.dumps(event.message, ensure_ascii=False)
182
+ await self.write(response, "message", text)
183
+
151
184
  async def write(self, response: SSEResponse, event: str, data: str) -> None:
152
185
  log.debug("-> [%s] %s %s", VERSION, event, data)
153
186
  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.
@@ -11,6 +11,7 @@ import json
11
11
  import logging
12
12
  import sys
13
13
  from collections.abc import Callable, Mapping
14
+ from contextlib import suppress
14
15
  from dataclasses import replace
15
16
  from typing import Any
16
17
 
@@ -26,6 +27,7 @@ from .dispatcher import Dispatcher
26
27
  from .exchange import Exchange, is_reply, relay_reply
27
28
  from .protocol.selection import AdapterSet
28
29
  from .registry import Registry
30
+ from .tasks import stop
29
31
 
30
32
  log = logging.getLogger("aiohttp_tiny_mcp")
31
33
 
@@ -155,9 +157,9 @@ async def serve_stdio(
155
157
  tasks = []
156
158
  for exchange, task in list(active.values()):
157
159
  exchange.cancel()
158
- task.cancel()
159
160
  tasks.append(task)
160
- await asyncio.gather(*tasks, return_exceptions=True)
161
+ with suppress(Exception):
162
+ await stop(*tasks)
161
163
 
162
164
 
163
165
  async def stdin_reader() -> asyncio.StreamReader:
@@ -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
@@ -0,0 +1,37 @@
1
+ """Ending a task that may not honour its first cancellation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ STOP_GRACE_SECONDS = 1.0
9
+
10
+
11
+ async def stop(*tasks: asyncio.Task[Any], grace: float = STOP_GRACE_SECONDS) -> None:
12
+ """Cancel `tasks` and return when every one of them has ended.
13
+
14
+ One cancellation is not always enough. A library may catch it, and on
15
+ Python before 3.12 `asyncio.wait_for` drops it when the future it waits
16
+ on completes in the same loop turn: psycopg_pool waits for a connection
17
+ that way, and a sibling task that is cancelled first hands its
18
+ connection back at exactly that moment. A task still running after
19
+ `grace` seconds is cancelled again, at whatever it awaits by then.
20
+
21
+ A cancelled task and one that ended with `ConnectionError` are what
22
+ stopping looks like. Any other exception is raised, the first one found.
23
+ """
24
+ pending = {task for task in tasks if not task.done()}
25
+ while pending:
26
+ for task in pending:
27
+ task.cancel()
28
+ _, pending = await asyncio.wait(pending, timeout=grace)
29
+ for task in tasks:
30
+ if task.cancelled():
31
+ continue
32
+ error = task.exception()
33
+ if error is not None and not isinstance(error, ConnectionError):
34
+ raise error
35
+
36
+
37
+ __all__ = ["STOP_GRACE_SECONDS", "stop"]