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.
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/PKG-INFO +1 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/pyproject.toml +1 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/pyproject.toml.orig +1 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/__init__.py +2 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/client_base.py +14 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/console.js +4 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/endpoint.py +5 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/extensions.py +53 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/http_sse.py +48 -11
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/models.py +10 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +10 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/registry.py +50 -2
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/subscriptions.py +81 -8
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/LICENSE +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/README.md +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/adapter.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/auth.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/client.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/console.css +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/console/index.html +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/core.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/exchange.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/hub.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/postgres.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/py.typed +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/redis.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/request_state.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/schema.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/sessions.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/skills.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/specs.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/sse.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/stdio.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/testing.py +0 -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(
|
|
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
|
-
|
|
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(
|
|
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,
|
|
140
|
+
self, request: web.Request, *relays: Coroutine[Any, Any, None]
|
|
132
141
|
) -> None:
|
|
133
|
-
"""Run
|
|
134
|
-
|
|
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
|
|
137
|
-
await asyncio.wait(
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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):
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py
RENAMED
|
@@ -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:
|
|
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(
|
|
33
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/selection.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.6}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|