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