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.
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/PKG-INFO +1 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/pyproject.toml +1 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/pyproject.toml.orig +1 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/__init__.py +2 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/client_base.py +14 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/console/console.js +4 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/endpoint.py +8 -9
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/extensions.py +54 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/http_sse.py +45 -12
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/models.py +10 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +10 -1
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/registry.py +50 -2
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/stdio.py +4 -2
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/subscriptions.py +81 -8
- aiohttp_tiny_mcp-0.3.7/src/aiohttp_tiny_mcp/tasks.py +37 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/LICENSE +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/README.md +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/adapter.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/auth.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/client.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/console/console.css +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/console/index.html +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/core.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/exchange.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/hub.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/postgres.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/py.typed +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/redis.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/request_state.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/schema.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/sessions.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/skills.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/specs.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/sse.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
- {aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/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" : "" },
|
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,50 @@ 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
|
-
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):
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/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.
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
@@ -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"]
|
|
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.7}/src/aiohttp_tiny_mcp/protocol/selection.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.5 → aiohttp_tiny_mcp-0.3.7}/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
|