aiohttp-tiny-mcp 0.2.6__tar.gz → 0.3.0__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.2.6 → aiohttp_tiny_mcp-0.3.0}/PKG-INFO +1 -1
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/pyproject.toml +1 -1
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/pyproject.toml.orig +1 -1
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/client.py +13 -4
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/client_base.py +24 -7
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/dispatcher.py +46 -5
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/endpoint.py +22 -12
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/exchange.py +6 -9
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/http_sse.py +8 -11
- aiohttp_tiny_mcp-0.3.0/src/aiohttp_tiny_mcp/hub.py +169 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/postgres.py +17 -16
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/redis.py +21 -13
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/registry.py +3 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/sqlite.py +15 -18
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/subscriptions.py +4 -6
- aiohttp_tiny_mcp-0.2.6/src/aiohttp_tiny_mcp/hub.py +0 -109
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/LICENSE +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/README.md +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/adapter.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/auth.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/console/console.css +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/console/console.js +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/console/index.html +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/core.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/models.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/py.typed +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/request_state.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/schema.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/sessions.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/specs.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/sse.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/stdio.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
- {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/testing.py +0 -0
|
@@ -52,6 +52,8 @@ class Client(BaseClient):
|
|
|
52
52
|
self.base_url = base_url
|
|
53
53
|
self.session = session
|
|
54
54
|
self.owns_session = session is None
|
|
55
|
+
#: The id of the last event read from the notification stream.
|
|
56
|
+
self.last_event_id: str | None = None
|
|
55
57
|
self.session_id: str | None = None
|
|
56
58
|
|
|
57
59
|
async def __aenter__(self) -> Client:
|
|
@@ -93,15 +95,22 @@ class Client(BaseClient):
|
|
|
93
95
|
async with self.session.post(self.base_url, json=envelope, headers=headers) as resp:
|
|
94
96
|
await resp.read()
|
|
95
97
|
|
|
96
|
-
async def stream_notifications(
|
|
97
|
-
|
|
98
|
+
async def stream_notifications(
|
|
99
|
+
self, *, last_event_id: str | None = None
|
|
100
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
101
|
+
"""Read the legacy GET stream until cancelled. `last_event_id` asks for a replay."""
|
|
98
102
|
assert self.session is not None, "use 'async with Client(...) as client:'"
|
|
99
103
|
headers = self.headers(NOTIFICATIONS_METHOD)
|
|
100
104
|
headers["Accept"] = "text/event-stream"
|
|
105
|
+
if last_event_id is not None:
|
|
106
|
+
headers["Last-Event-ID"] = last_event_id
|
|
101
107
|
async with self.session.get(self.base_url, headers=headers) as resp:
|
|
102
108
|
resp.raise_for_status()
|
|
103
|
-
async for
|
|
104
|
-
|
|
109
|
+
async for event in read_sse(resp):
|
|
110
|
+
if event.id is not None:
|
|
111
|
+
self.last_event_id = event.id
|
|
112
|
+
if event.data:
|
|
113
|
+
yield json.loads(event.data)
|
|
105
114
|
|
|
106
115
|
async def reply(self, envelope: dict[str, Any]) -> None:
|
|
107
116
|
"""Send a bare JSON-RPC response. The hub routes it to the node waiting for the answer."""
|
|
@@ -104,7 +104,9 @@ class BaseClient(ABC):
|
|
|
104
104
|
async def reply(self, envelope: dict[str, Any]) -> None:
|
|
105
105
|
"""Answer a request the server made. Carries no reply of its own."""
|
|
106
106
|
|
|
107
|
-
def stream_notifications(
|
|
107
|
+
def stream_notifications(
|
|
108
|
+
self, *, last_event_id: str | None = None
|
|
109
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
108
110
|
"""Open a separate legacy notification channel, if the transport supports one."""
|
|
109
111
|
raise NotImplementedError(
|
|
110
112
|
f"{type(self).__name__} has no separate stream to read notifications on"
|
|
@@ -183,9 +185,20 @@ class BaseClient(ABC):
|
|
|
183
185
|
await self.request(Operation.SET_LOG_LEVEL, SetLevelParams(level=level))
|
|
184
186
|
self.log_level = level
|
|
185
187
|
|
|
188
|
+
async def pages(self, operation: Operation) -> AsyncIterator[dict[str, Any]]:
|
|
189
|
+
"""Every page of a listing, following `nextCursor` to the end."""
|
|
190
|
+
cursor: str | None = None
|
|
191
|
+
while True:
|
|
192
|
+
result = await self.request(operation, ListParams(cursor=cursor))
|
|
193
|
+
yield result
|
|
194
|
+
cursor = result.get("nextCursor")
|
|
195
|
+
if not cursor:
|
|
196
|
+
return
|
|
197
|
+
|
|
186
198
|
async def list_tools(self) -> list[ToolDef]:
|
|
187
|
-
|
|
188
|
-
|
|
199
|
+
tools: list[ToolDef] = []
|
|
200
|
+
async for result in self.pages(Operation.LIST_TOOLS):
|
|
201
|
+
tools += ListToolsResult.model_validate(result).tools
|
|
189
202
|
self.tool_definitions = {tool.name: tool for tool in tools}
|
|
190
203
|
return tools
|
|
191
204
|
|
|
@@ -272,16 +285,20 @@ class BaseClient(ABC):
|
|
|
272
285
|
yield frame
|
|
273
286
|
|
|
274
287
|
async def list_resources(self) -> list[ResourceDef]:
|
|
275
|
-
|
|
276
|
-
|
|
288
|
+
found: list[ResourceDef] = []
|
|
289
|
+
async for result in self.pages(Operation.LIST_RESOURCES):
|
|
290
|
+
found += ListResourcesResult.model_validate(result).resources
|
|
291
|
+
return found
|
|
277
292
|
|
|
278
293
|
async def read_resource(self, uri: str) -> ReadResourceResult:
|
|
279
294
|
result = await self.request(Operation.READ_RESOURCE, ReadResourceParams(uri=uri), name=uri)
|
|
280
295
|
return ReadResourceResult.model_validate(result)
|
|
281
296
|
|
|
282
297
|
async def list_prompts(self) -> list[PromptDef]:
|
|
283
|
-
|
|
284
|
-
|
|
298
|
+
found: list[PromptDef] = []
|
|
299
|
+
async for result in self.pages(Operation.LIST_PROMPTS):
|
|
300
|
+
found += ListPromptsResult.model_validate(result).prompts
|
|
301
|
+
return found
|
|
285
302
|
|
|
286
303
|
async def get_prompt(self, name: str, arguments: dict[str, str]) -> GetPromptResult:
|
|
287
304
|
result = await self.request(
|
|
@@ -4,7 +4,8 @@ about HTTP, headers or protocol versions.
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, TypeVar
|
|
8
9
|
|
|
9
10
|
from pydantic import ValidationError
|
|
10
11
|
|
|
@@ -26,6 +27,7 @@ from .models import (
|
|
|
26
27
|
Completion,
|
|
27
28
|
EmptyResult,
|
|
28
29
|
GetPromptResult,
|
|
30
|
+
ListParams,
|
|
29
31
|
ListPromptsResult,
|
|
30
32
|
ListResourcesResult,
|
|
31
33
|
ListResourceTemplatesResult,
|
|
@@ -78,6 +80,8 @@ def refusal(spec: ToolSpec, principal: Principal | None) -> str | None:
|
|
|
78
80
|
|
|
79
81
|
PUSH_ASK_ROUNDS = 8
|
|
80
82
|
|
|
83
|
+
Entry = TypeVar("Entry")
|
|
84
|
+
|
|
81
85
|
|
|
82
86
|
class Dispatcher:
|
|
83
87
|
def __init__(self, registry: Registry) -> None:
|
|
@@ -222,6 +226,25 @@ class Dispatcher:
|
|
|
222
226
|
async def ping(self, ex: Exchange) -> Outcome:
|
|
223
227
|
return Value(result=EmptyResult())
|
|
224
228
|
|
|
229
|
+
def paged(
|
|
230
|
+
self, ex: Exchange, entries: list[Entry], key: Callable[[Entry], str]
|
|
231
|
+
) -> tuple[list[Entry], str | None] | Failure:
|
|
232
|
+
"""One page of `entries`, sorted by `key`, and the cursor for the next.
|
|
233
|
+
|
|
234
|
+
The cursor is the key of the last entry shown. One that names no entry is
|
|
235
|
+
-32602: a silent first page would send a paging client round in a circle.
|
|
236
|
+
"""
|
|
237
|
+
cursor = ex.call.params.cursor if isinstance(ex.call.params, ListParams) else None
|
|
238
|
+
if cursor is not None:
|
|
239
|
+
if all(key(entry) != cursor for entry in entries):
|
|
240
|
+
return Failure(FailureKind.INVALID_PARAMS, f"unknown cursor: {cursor}")
|
|
241
|
+
entries = [entry for entry in entries if key(entry) > cursor]
|
|
242
|
+
size = self.registry.page_size
|
|
243
|
+
if size is None or len(entries) <= size:
|
|
244
|
+
return entries, None
|
|
245
|
+
shown = entries[:size]
|
|
246
|
+
return shown, key(shown[-1])
|
|
247
|
+
|
|
225
248
|
async def list_tools(self, ex: Exchange) -> Outcome:
|
|
226
249
|
tools: list[ToolDef] = [
|
|
227
250
|
d
|
|
@@ -229,7 +252,11 @@ class Dispatcher:
|
|
|
229
252
|
if (d := ex.adapter.describe_tool(spec)) is not None
|
|
230
253
|
]
|
|
231
254
|
tools.sort(key=lambda item: item.name)
|
|
232
|
-
|
|
255
|
+
found = self.paged(ex, tools, lambda item: item.name)
|
|
256
|
+
if isinstance(found, Failure):
|
|
257
|
+
return found
|
|
258
|
+
shown, next_cursor = found
|
|
259
|
+
return Value(result=ListToolsResult(tools=shown, next_cursor=next_cursor))
|
|
233
260
|
|
|
234
261
|
async def call_tool(self, ex: Exchange) -> Outcome:
|
|
235
262
|
spec = self.registry.tools.get(ex.call.target or "")
|
|
@@ -254,7 +281,11 @@ class Dispatcher:
|
|
|
254
281
|
if isinstance(d := ex.adapter.describe_resource(spec), ResourceDef)
|
|
255
282
|
]
|
|
256
283
|
defs.sort(key=lambda item: item.uri)
|
|
257
|
-
|
|
284
|
+
found = self.paged(ex, defs, lambda item: item.uri)
|
|
285
|
+
if isinstance(found, Failure):
|
|
286
|
+
return found
|
|
287
|
+
shown, next_cursor = found
|
|
288
|
+
return Value(result=ListResourcesResult(resources=shown, next_cursor=next_cursor))
|
|
258
289
|
|
|
259
290
|
async def list_resource_templates(self, ex: Exchange) -> Outcome:
|
|
260
291
|
defs: list[ResourceTemplateDef] = [
|
|
@@ -263,7 +294,13 @@ class Dispatcher:
|
|
|
263
294
|
if isinstance(d := ex.adapter.describe_resource(spec), ResourceTemplateDef)
|
|
264
295
|
]
|
|
265
296
|
defs.sort(key=lambda item: item.uri_template)
|
|
266
|
-
|
|
297
|
+
found = self.paged(ex, defs, lambda item: item.uri_template)
|
|
298
|
+
if isinstance(found, Failure):
|
|
299
|
+
return found
|
|
300
|
+
shown, next_cursor = found
|
|
301
|
+
return Value(
|
|
302
|
+
result=ListResourceTemplatesResult(resource_templates=shown, next_cursor=next_cursor)
|
|
303
|
+
)
|
|
267
304
|
|
|
268
305
|
async def read_resource(self, ex: Exchange) -> Outcome:
|
|
269
306
|
uri = ex.call.target or ""
|
|
@@ -286,7 +323,11 @@ class Dispatcher:
|
|
|
286
323
|
if (d := ex.adapter.describe_prompt(spec)) is not None
|
|
287
324
|
]
|
|
288
325
|
prompts.sort(key=lambda item: item.name)
|
|
289
|
-
|
|
326
|
+
found = self.paged(ex, prompts, lambda item: item.name)
|
|
327
|
+
if isinstance(found, Failure):
|
|
328
|
+
return found
|
|
329
|
+
shown, next_cursor = found
|
|
330
|
+
return Value(result=ListPromptsResult(prompts=shown, next_cursor=next_cursor))
|
|
290
331
|
|
|
291
332
|
async def get_prompt(self, ex: Exchange) -> Outcome:
|
|
292
333
|
spec = self.registry.prompts.get(ex.call.target or "")
|
|
@@ -28,7 +28,7 @@ from .core import (
|
|
|
28
28
|
)
|
|
29
29
|
from .dispatcher import Dispatcher
|
|
30
30
|
from .exchange import Exchange, is_reply, relay_reply
|
|
31
|
-
from .hub import NOTIFICATIONS, topic
|
|
31
|
+
from .hub import NOTIFICATIONS, Subscription, topic
|
|
32
32
|
from .namespaces import current, namespace, scoped
|
|
33
33
|
from .protocol.selection import AdapterSet
|
|
34
34
|
from .registry import Registry
|
|
@@ -405,23 +405,33 @@ class Endpoint:
|
|
|
405
405
|
async def relay_notifications(
|
|
406
406
|
self, request: web.Request, response: SSEResponse, adapter: Adapter
|
|
407
407
|
) -> None:
|
|
408
|
-
"""Relay changes, re-reading subscriptions each pass
|
|
408
|
+
"""Relay changes with their hub ids, re-reading subscriptions each pass."""
|
|
409
409
|
capabilities = adapter.capabilities(self.registry)
|
|
410
|
-
|
|
411
|
-
where = topic(NOTIFICATIONS)
|
|
412
|
-
cursor = await hub.position(where)
|
|
410
|
+
events = await self.resumed(request, topic(NOTIFICATIONS))
|
|
413
411
|
while True:
|
|
414
|
-
|
|
415
|
-
if not
|
|
412
|
+
found = await events.poll()
|
|
413
|
+
if not found:
|
|
416
414
|
continue
|
|
417
415
|
accepted = wanted(
|
|
418
416
|
capabilities, self.open_values(request, await self.load_session(request))
|
|
419
417
|
)
|
|
420
|
-
for
|
|
421
|
-
if relays(
|
|
422
|
-
text = json.dumps(
|
|
423
|
-
log.debug("-> [%s] %s", adapter.version, text)
|
|
424
|
-
await response.send(text)
|
|
418
|
+
for event in found:
|
|
419
|
+
if relays(event.message, accepted):
|
|
420
|
+
text = json.dumps(event.message, ensure_ascii=False)
|
|
421
|
+
log.debug("-> [%s] %s %s", adapter.version, event.id, text)
|
|
422
|
+
await response.send(text, id=event.id)
|
|
423
|
+
|
|
424
|
+
async def resumed(self, request: web.Request, where: str) -> Subscription:
|
|
425
|
+
"""Subscribe from `Last-Event-ID`, or from now when it is absent or unknown."""
|
|
426
|
+
hub = self.registry.hub
|
|
427
|
+
wait = self.registry.hub_poll_seconds
|
|
428
|
+
last = request.headers.get("Last-Event-ID")
|
|
429
|
+
if last:
|
|
430
|
+
try:
|
|
431
|
+
return await hub.subscribe(where, after=last, wait=wait)
|
|
432
|
+
except ValueError:
|
|
433
|
+
log.debug("ignoring Last-Event-ID %r: not from this hub", last)
|
|
434
|
+
return await hub.subscribe(where, wait=wait)
|
|
425
435
|
|
|
426
436
|
def owns(self, record: SessionRecord | None, principal: Any) -> bool:
|
|
427
437
|
"""Whether this caller may use this session.
|
|
@@ -78,7 +78,7 @@ class Exchange:
|
|
|
78
78
|
self.principal: Principal | None = None
|
|
79
79
|
self.keep_log_level: Callable[[str], None] | None = None
|
|
80
80
|
self.sse: SSEResponse | None = None
|
|
81
|
-
self.send: Callable[[Mapping[str, Any]], Awaitable[
|
|
81
|
+
self.send: Callable[[Mapping[str, Any]], Awaitable[Any]] | None = None
|
|
82
82
|
self.stack = AsyncExitStack()
|
|
83
83
|
self.resolved: dict[type, Any] = {}
|
|
84
84
|
self.cancelled = asyncio.Event()
|
|
@@ -182,13 +182,13 @@ class Exchange:
|
|
|
182
182
|
async def push_ask(self, key: str, request: InputRequest) -> Answer:
|
|
183
183
|
"""Send a question on the active stream and wait through the hub.
|
|
184
184
|
|
|
185
|
-
The reply may reach another node.
|
|
186
|
-
|
|
185
|
+
The reply may reach another node. Subscribe before sending so fast replies are not
|
|
186
|
+
missed.
|
|
187
187
|
"""
|
|
188
188
|
hub = self.registry.hub
|
|
189
189
|
wire_id = new_id()
|
|
190
190
|
where = topic(ASK, wire_id)
|
|
191
|
-
|
|
191
|
+
replies = await hub.subscribe(where, wait=self.registry.hub_poll_seconds)
|
|
192
192
|
await self.emit({"jsonrpc": "2.0", "id": wire_id, **request})
|
|
193
193
|
loop = asyncio.get_running_loop()
|
|
194
194
|
deadline = loop.time() + self.registry.ask_timeout_seconds
|
|
@@ -197,11 +197,8 @@ class Exchange:
|
|
|
197
197
|
left = deadline - loop.time()
|
|
198
198
|
if left <= 0:
|
|
199
199
|
return Answer(action=AnswerAction.CANCEL)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
)
|
|
203
|
-
for message in messages:
|
|
204
|
-
reply = message.get("reply")
|
|
200
|
+
for event in await replies.poll(timeout=min(left, replies.wait)):
|
|
201
|
+
reply = event.message.get("reply")
|
|
205
202
|
if isinstance(reply, Mapping):
|
|
206
203
|
return answer_of(reply)
|
|
207
204
|
return Answer(action=AnswerAction.CANCEL)
|
|
@@ -24,7 +24,7 @@ from .core import (
|
|
|
24
24
|
from .dispatcher import Dispatcher
|
|
25
25
|
from .endpoint import Endpoint
|
|
26
26
|
from .exchange import Exchange
|
|
27
|
-
from .hub import topic
|
|
27
|
+
from .hub import Subscription, topic
|
|
28
28
|
from .namespaces import scoped
|
|
29
29
|
from .protocol.selection import AdapterSet
|
|
30
30
|
from .registry import Registry
|
|
@@ -103,7 +103,7 @@ class SseEndpoint:
|
|
|
103
103
|
session_id = await self.open_session()
|
|
104
104
|
where = topic(STREAM, session_id)
|
|
105
105
|
hub = self.registry.hub
|
|
106
|
-
|
|
106
|
+
events = await hub.subscribe(where, wait=self.registry.hub_poll_seconds)
|
|
107
107
|
|
|
108
108
|
response = SSEResponse(compress=self.compress)
|
|
109
109
|
await response.prepare(request)
|
|
@@ -111,7 +111,7 @@ class SseEndpoint:
|
|
|
111
111
|
await self.write(response, "endpoint", f"{posting}?session_id={session_id}")
|
|
112
112
|
|
|
113
113
|
try:
|
|
114
|
-
await self.relay_until_disconnect(request, response,
|
|
114
|
+
await self.relay_until_disconnect(request, response, events)
|
|
115
115
|
finally:
|
|
116
116
|
await hub.delete(where)
|
|
117
117
|
await self.registry.session_store.delete(scoped(session_id))
|
|
@@ -128,10 +128,10 @@ class SseEndpoint:
|
|
|
128
128
|
return session_id
|
|
129
129
|
|
|
130
130
|
async def relay_until_disconnect(
|
|
131
|
-
self, request: web.Request, response: SSEResponse,
|
|
131
|
+
self, request: web.Request, response: SSEResponse, events: Subscription
|
|
132
132
|
) -> None:
|
|
133
133
|
"""Run a relay until its client disconnects, then always join it."""
|
|
134
|
-
relay = asyncio.create_task(self.relay(response,
|
|
134
|
+
relay = asyncio.create_task(self.relay(response, events))
|
|
135
135
|
try:
|
|
136
136
|
while not relay.done():
|
|
137
137
|
await asyncio.wait({relay}, timeout=0.05)
|
|
@@ -143,13 +143,10 @@ class SseEndpoint:
|
|
|
143
143
|
with suppress(asyncio.CancelledError, ConnectionError):
|
|
144
144
|
await relay
|
|
145
145
|
|
|
146
|
-
async def relay(self, response: SSEResponse,
|
|
146
|
+
async def relay(self, response: SSEResponse, events: Subscription) -> None:
|
|
147
147
|
"""Write everything published for this connection, until cancelled."""
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
messages, cursor = await hub.poll(where, cursor, timeout=self.registry.hub_poll_seconds)
|
|
151
|
-
for payload in messages:
|
|
152
|
-
await self.write(response, "message", json.dumps(payload, ensure_ascii=False))
|
|
148
|
+
async for event in events:
|
|
149
|
+
await self.write(response, "message", json.dumps(event.message, ensure_ascii=False))
|
|
153
150
|
|
|
154
151
|
async def write(self, response: SSEResponse, event: str, data: str) -> None:
|
|
155
152
|
log.debug("-> [%s] %s %s", VERSION, event, data)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Cursor-based event storage shared by reply and notification streams.
|
|
2
|
+
|
|
3
|
+
Applications supply the backend; MemoryHub serves one process. Shared storage lets one node
|
|
4
|
+
publish replies that another is awaiting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from abc import abstractmethod
|
|
11
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from .namespaces import scoped
|
|
16
|
+
|
|
17
|
+
Cursor = str
|
|
18
|
+
|
|
19
|
+
START: Cursor = ""
|
|
20
|
+
|
|
21
|
+
NOTIFICATIONS = "notifications"
|
|
22
|
+
|
|
23
|
+
ASK = "ask"
|
|
24
|
+
|
|
25
|
+
MEMORY_KEEP = 1000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class Event:
|
|
30
|
+
"""One published message and its id. The id is the cursor after it."""
|
|
31
|
+
|
|
32
|
+
id: Cursor
|
|
33
|
+
message: Mapping[str, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Subscription:
|
|
37
|
+
"""A reader of one topic that keeps its own place. Made by `Hub.subscribe`."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self, hub: Hub, topic: str, cursor: Cursor, *, wait: float, pending: Sequence[Event] = ()
|
|
41
|
+
) -> None:
|
|
42
|
+
self.hub = hub
|
|
43
|
+
self.topic = topic
|
|
44
|
+
self.cursor = cursor
|
|
45
|
+
self.wait = wait
|
|
46
|
+
self.pending = pending
|
|
47
|
+
|
|
48
|
+
async def poll(self, *, timeout: float | None = None) -> Sequence[Event]:
|
|
49
|
+
"""Events since the last call. Wait up to `timeout`, or `wait` when None."""
|
|
50
|
+
events = self.pending
|
|
51
|
+
self.pending = ()
|
|
52
|
+
if not events:
|
|
53
|
+
wait = self.wait if timeout is None else timeout
|
|
54
|
+
events = await self.hub.poll(self.topic, self.cursor, timeout=wait)
|
|
55
|
+
if events:
|
|
56
|
+
self.cursor = events[-1].id
|
|
57
|
+
return events
|
|
58
|
+
|
|
59
|
+
async def __aiter__(self) -> AsyncIterator[Event]:
|
|
60
|
+
while True:
|
|
61
|
+
for event in await self.poll():
|
|
62
|
+
yield event
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@runtime_checkable
|
|
66
|
+
class Hub(Protocol):
|
|
67
|
+
"""Application-provided event storage.
|
|
68
|
+
|
|
69
|
+
Subclass it to have the methods checked and the missing ones refused, or
|
|
70
|
+
supply any object with these five methods. Readers call `subscribe`; the
|
|
71
|
+
four abstract methods are the storage it is built on.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
async def subscribe(
|
|
75
|
+
self, topic: str, *, after: Cursor | None = None, wait: float = 1.0
|
|
76
|
+
) -> Subscription:
|
|
77
|
+
"""Read after `after`, or after now. An unknown `after` raises ValueError."""
|
|
78
|
+
if after is None:
|
|
79
|
+
return Subscription(self, topic, await self.position(topic), wait=wait)
|
|
80
|
+
found = await self.poll(topic, after, timeout=0)
|
|
81
|
+
return Subscription(self, topic, after, wait=wait, pending=found)
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
async def publish(self, topic: str, message: Mapping[str, Any]) -> Cursor:
|
|
85
|
+
"""Append one message to `topic` and return the id it got."""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
async def position(self, topic: str) -> Cursor:
|
|
89
|
+
"""Capture the cursor before triggering a publish so replies preceding the first poll are
|
|
90
|
+
included.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
async def poll(self, topic: str, cursor: Cursor, *, timeout: float) -> Sequence[Event]:
|
|
95
|
+
"""Return the events after `cursor`, in order. Wait up to `timeout` for one.
|
|
96
|
+
|
|
97
|
+
Raise ValueError for a cursor this hub did not issue.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
async def delete(self, topic: str) -> None:
|
|
102
|
+
"""Delete a completed topic."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def topic(kind: str, name: str | None = None) -> str:
|
|
106
|
+
"""Prefix the topic with the current namespace to isolate callers."""
|
|
107
|
+
return scoped(kind if name is None else f"{kind}/{name}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class MemoryHub(Hub):
|
|
111
|
+
"""Single-process hub. A topic keeps its last `keep` events for replay."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, *, keep: int = MEMORY_KEEP) -> None:
|
|
114
|
+
self.rows: dict[str, list[Event]] = {}
|
|
115
|
+
self.keep = keep
|
|
116
|
+
self.last_id = 0
|
|
117
|
+
self.arrived = asyncio.Condition()
|
|
118
|
+
|
|
119
|
+
async def publish(self, topic: str, message: Mapping[str, Any]) -> Cursor:
|
|
120
|
+
async with self.arrived:
|
|
121
|
+
self.last_id += 1
|
|
122
|
+
event = Event(str(self.last_id), dict(message))
|
|
123
|
+
rows = self.rows.setdefault(topic, [])
|
|
124
|
+
rows.append(event)
|
|
125
|
+
del rows[: -self.keep]
|
|
126
|
+
self.arrived.notify_all()
|
|
127
|
+
return event.id
|
|
128
|
+
|
|
129
|
+
async def position(self, topic: str) -> Cursor:
|
|
130
|
+
rows = self.rows.get(topic)
|
|
131
|
+
return rows[-1].id if rows else START
|
|
132
|
+
|
|
133
|
+
def after(self, topic: str, cursor: Cursor) -> list[Event]:
|
|
134
|
+
least = int(cursor) if cursor else 0
|
|
135
|
+
return [event for event in self.rows.get(topic, []) if int(event.id) > least]
|
|
136
|
+
|
|
137
|
+
async def poll(self, topic: str, cursor: Cursor, *, timeout: float) -> Sequence[Event]:
|
|
138
|
+
loop = asyncio.get_running_loop()
|
|
139
|
+
deadline = loop.time() + timeout
|
|
140
|
+
async with self.arrived:
|
|
141
|
+
while True:
|
|
142
|
+
found = self.after(topic, cursor)
|
|
143
|
+
if found:
|
|
144
|
+
return found
|
|
145
|
+
left = deadline - loop.time()
|
|
146
|
+
if left <= 0:
|
|
147
|
+
return []
|
|
148
|
+
try:
|
|
149
|
+
await asyncio.wait_for(self.arrived.wait(), left)
|
|
150
|
+
except asyncio.TimeoutError:
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
async def delete(self, topic: str) -> None:
|
|
154
|
+
async with self.arrived:
|
|
155
|
+
self.rows.pop(topic, None)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = [
|
|
159
|
+
"ASK",
|
|
160
|
+
"MEMORY_KEEP",
|
|
161
|
+
"NOTIFICATIONS",
|
|
162
|
+
"START",
|
|
163
|
+
"Cursor",
|
|
164
|
+
"Event",
|
|
165
|
+
"Hub",
|
|
166
|
+
"MemoryHub",
|
|
167
|
+
"Subscription",
|
|
168
|
+
"topic",
|
|
169
|
+
]
|
|
@@ -9,7 +9,7 @@ from collections.abc import AsyncIterator, Mapping, Sequence
|
|
|
9
9
|
from functools import cached_property
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
-
from .hub import START, Cursor, Hub
|
|
12
|
+
from .hub import START, Cursor, Event, Hub
|
|
13
13
|
from .sessions import SessionRecord, SessionStore
|
|
14
14
|
|
|
15
15
|
try:
|
|
@@ -365,7 +365,7 @@ class PostgresHub(Hub):
|
|
|
365
365
|
[
|
|
366
366
|
sql.SQL("INSERT INTO "),
|
|
367
367
|
self.storage.events_table,
|
|
368
|
-
sql.SQL(" (topic, message) VALUES (%s, %s)"),
|
|
368
|
+
sql.SQL(" (topic, message) VALUES (%s, %s) RETURNING id"),
|
|
369
369
|
]
|
|
370
370
|
)
|
|
371
371
|
|
|
@@ -399,12 +399,17 @@ class PostgresHub(Hub):
|
|
|
399
399
|
]
|
|
400
400
|
)
|
|
401
401
|
|
|
402
|
-
async def publish(self, topic: str, message: Mapping[str, Any]) ->
|
|
402
|
+
async def publish(self, topic: str, message: Mapping[str, Any]) -> Cursor:
|
|
403
403
|
pool = await self.storage.open()
|
|
404
404
|
async with pool.connection() as connection:
|
|
405
405
|
async with connection.transaction():
|
|
406
406
|
await connection.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (topic,))
|
|
407
|
-
await connection.execute(
|
|
407
|
+
inserted = await connection.execute(
|
|
408
|
+
self._query_publish, (topic, json.dumps(message))
|
|
409
|
+
)
|
|
410
|
+
row = await inserted.fetchone()
|
|
411
|
+
assert row is not None
|
|
412
|
+
return str(row[0])
|
|
408
413
|
|
|
409
414
|
async def position(self, topic: str) -> Cursor:
|
|
410
415
|
pool = await self.storage.open()
|
|
@@ -413,30 +418,26 @@ class PostgresHub(Hub):
|
|
|
413
418
|
row = await found.fetchone()
|
|
414
419
|
return str(row[0]) if row is not None and row[0] is not None else START
|
|
415
420
|
|
|
416
|
-
async def after(self, topic: str, cursor: Cursor) ->
|
|
417
|
-
"""
|
|
421
|
+
async def after(self, topic: str, cursor: Cursor) -> list[Event]:
|
|
422
|
+
"""Events after `cursor`. The row id is the event id."""
|
|
418
423
|
pool = await self.storage.open()
|
|
419
424
|
async with pool.connection() as connection:
|
|
420
425
|
found = await connection.execute(
|
|
421
426
|
self._query_after, (topic, int(cursor) if cursor else 0)
|
|
422
427
|
)
|
|
423
428
|
rows = await found.fetchall()
|
|
424
|
-
|
|
425
|
-
return [], cursor
|
|
426
|
-
return [row[1] for row in rows], str(rows[-1][0])
|
|
429
|
+
return [Event(str(row[0]), row[1]) for row in rows]
|
|
427
430
|
|
|
428
|
-
async def poll(
|
|
429
|
-
self, topic: str, cursor: Cursor, *, timeout: float
|
|
430
|
-
) -> tuple[Sequence[Mapping[str, Any]], Cursor]:
|
|
431
|
+
async def poll(self, topic: str, cursor: Cursor, *, timeout: float) -> Sequence[Event]:
|
|
431
432
|
loop = asyncio.get_running_loop()
|
|
432
433
|
deadline = loop.time() + timeout
|
|
433
434
|
while True:
|
|
434
|
-
|
|
435
|
-
if
|
|
436
|
-
return
|
|
435
|
+
found = await self.after(topic, cursor)
|
|
436
|
+
if found:
|
|
437
|
+
return found
|
|
437
438
|
left = deadline - loop.time()
|
|
438
439
|
if left <= 0:
|
|
439
|
-
return []
|
|
440
|
+
return []
|
|
440
441
|
await asyncio.sleep(min(self.look_again, left))
|
|
441
442
|
|
|
442
443
|
async def delete(self, topic: str) -> None:
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
from .hub import START, Cursor, Hub
|
|
9
|
+
from .hub import START, Cursor, Event, Hub
|
|
10
10
|
from .sessions import SessionRecord, SessionStore
|
|
11
11
|
|
|
12
12
|
try:
|
|
@@ -156,29 +156,37 @@ class RedisHub(Hub):
|
|
|
156
156
|
def key(self, topic: str) -> str:
|
|
157
157
|
return f"{self.prefix}:topic:{topic}"
|
|
158
158
|
|
|
159
|
-
async def publish(self, topic: str, message: Mapping[str, Any]) ->
|
|
159
|
+
async def publish(self, topic: str, message: Mapping[str, Any]) -> Cursor:
|
|
160
160
|
client = await self.storage.open()
|
|
161
161
|
key = self.key(topic)
|
|
162
|
-
await client.xadd(
|
|
162
|
+
added: Any = await client.xadd(
|
|
163
|
+
key, {"message": json.dumps(message)}, maxlen=self.keep, approximate=True
|
|
164
|
+
)
|
|
163
165
|
await client.expire(key, self.ttl_seconds)
|
|
166
|
+
return text(added)
|
|
164
167
|
|
|
165
168
|
async def position(self, topic: str) -> Cursor:
|
|
166
169
|
client = await self.storage.open()
|
|
167
170
|
last: Any = await client.xrevrange(self.key(topic), count=1)
|
|
168
171
|
return text(last[0][0]) if last else START
|
|
169
172
|
|
|
170
|
-
async def poll(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
async def poll(self, topic: str, cursor: Cursor, *, timeout: float) -> Sequence[Event]:
|
|
174
|
+
"""The stream id is the event id. Redis refuses a malformed one."""
|
|
175
|
+
from redis.exceptions import ResponseError
|
|
176
|
+
|
|
173
177
|
client = await self.storage.open()
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
try:
|
|
179
|
+
found: Any = await client.xread(
|
|
180
|
+
{self.key(topic): cursor or BEGINNING}, block=max(1, int(timeout * 1000))
|
|
181
|
+
)
|
|
182
|
+
except ResponseError as e:
|
|
183
|
+
raise ValueError(f"not a stream id: {cursor!r}") from e
|
|
177
184
|
if not found:
|
|
178
|
-
return []
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
185
|
+
return []
|
|
186
|
+
return [
|
|
187
|
+
Event(text(entry_id), json.loads(next(iter(fields.values()))))
|
|
188
|
+
for entry_id, fields in found[0][1]
|
|
189
|
+
]
|
|
182
190
|
|
|
183
191
|
async def delete(self, topic: str) -> None:
|
|
184
192
|
client = await self.storage.open()
|
|
@@ -41,6 +41,7 @@ class Registry:
|
|
|
41
41
|
request_state_ttl_seconds: int = STATE_TTL_SECONDS,
|
|
42
42
|
hub_poll_seconds: float = 30.0,
|
|
43
43
|
ask_timeout_seconds: float = 120.0,
|
|
44
|
+
page_size: int | None = None,
|
|
44
45
|
) -> None:
|
|
45
46
|
self.info = Implementation(name=name, version=version)
|
|
46
47
|
self.instructions = instructions
|
|
@@ -48,6 +49,8 @@ class Registry:
|
|
|
48
49
|
self.session_store = session_store if session_store is not None else MemorySessionStore()
|
|
49
50
|
self.auth = auth
|
|
50
51
|
self.session_ttl_seconds = session_ttl_seconds
|
|
52
|
+
#: Most entries per listing page. `None`, the default, answers with all of them.
|
|
53
|
+
self.page_size = page_size
|
|
51
54
|
self.request_state = RequestStates(self.session_store, request_state_ttl_seconds)
|
|
52
55
|
self.hub_poll_seconds = hub_poll_seconds
|
|
53
56
|
self.ask_timeout_seconds = ask_timeout_seconds
|
|
@@ -10,7 +10,7 @@ from contextlib import suppress
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
-
from .hub import START, Cursor, Hub
|
|
13
|
+
from .hub import START, Cursor, Event, Hub
|
|
14
14
|
from .sessions import SessionRecord, SessionStore
|
|
15
15
|
|
|
16
16
|
try:
|
|
@@ -181,12 +181,13 @@ class SqliteHub(Hub):
|
|
|
181
181
|
if look_again is not None:
|
|
182
182
|
self.look_again = look_again
|
|
183
183
|
|
|
184
|
-
async def publish(self, topic: str, message: Mapping[str, Any]) ->
|
|
184
|
+
async def publish(self, topic: str, message: Mapping[str, Any]) -> Cursor:
|
|
185
185
|
connection = await self.storage.open()
|
|
186
|
-
|
|
186
|
+
async with connection.execute(
|
|
187
187
|
f"INSERT INTO mcp_events (topic, message, created_at) VALUES (?, ?, {NOW})",
|
|
188
188
|
(topic, json.dumps(message)),
|
|
189
|
-
)
|
|
189
|
+
) as inserted:
|
|
190
|
+
return str(inserted.lastrowid)
|
|
190
191
|
|
|
191
192
|
async def position(self, topic: str) -> Cursor:
|
|
192
193
|
connection = await self.storage.open()
|
|
@@ -196,30 +197,26 @@ class SqliteHub(Hub):
|
|
|
196
197
|
row = await cursor.fetchone()
|
|
197
198
|
return str(row[0]) if row is not None and row[0] is not None else START
|
|
198
199
|
|
|
199
|
-
async def after(self, topic: str, cursor: Cursor) ->
|
|
200
|
-
"""
|
|
200
|
+
async def after(self, topic: str, cursor: Cursor) -> list[Event]:
|
|
201
|
+
"""Events after `cursor`. The row id is the event id."""
|
|
201
202
|
connection = await self.storage.open()
|
|
202
203
|
async with connection.execute(
|
|
203
204
|
"SELECT id, message FROM mcp_events WHERE topic = ? AND id > ? ORDER BY id",
|
|
204
205
|
(topic, int(cursor) if cursor else 0),
|
|
205
206
|
) as rows:
|
|
206
|
-
found =
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
async def poll(
|
|
212
|
-
self, topic: str, cursor: Cursor, *, timeout: float
|
|
213
|
-
) -> tuple[Sequence[Mapping[str, Any]], Cursor]:
|
|
207
|
+
found = await rows.fetchall()
|
|
208
|
+
return [Event(str(row[0]), json.loads(row[1])) for row in found]
|
|
209
|
+
|
|
210
|
+
async def poll(self, topic: str, cursor: Cursor, *, timeout: float) -> Sequence[Event]:
|
|
214
211
|
loop = asyncio.get_running_loop()
|
|
215
212
|
deadline = loop.time() + timeout
|
|
216
213
|
while True:
|
|
217
|
-
|
|
218
|
-
if
|
|
219
|
-
return
|
|
214
|
+
found = await self.after(topic, cursor)
|
|
215
|
+
if found:
|
|
216
|
+
return found
|
|
220
217
|
left = deadline - loop.time()
|
|
221
218
|
if left <= 0:
|
|
222
|
-
return []
|
|
219
|
+
return []
|
|
223
220
|
await asyncio.sleep(min(self.look_again, left))
|
|
224
221
|
|
|
225
222
|
async def delete(self, topic: str) -> None:
|
|
@@ -69,14 +69,12 @@ async def listen(ex: Exchange) -> EmptyResult:
|
|
|
69
69
|
await ex.emit(ack)
|
|
70
70
|
return EmptyResult(meta=Meta.model_validate({SUBSCRIPTION_ID: ex.id}))
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
cursor = await hub.position(topic)
|
|
72
|
+
events = await hub.subscribe(hubs.topic(hubs.NOTIFICATIONS), wait=ex.registry.hub_poll_seconds)
|
|
74
73
|
await ex.emit(ack)
|
|
75
74
|
while not ex.cancelled.is_set():
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
await ex.emit(tag(payload, ex.id))
|
|
75
|
+
for event in await events.poll():
|
|
76
|
+
if relays(event.message, accepted):
|
|
77
|
+
await ex.emit(tag(event.message, ex.id))
|
|
80
78
|
return EmptyResult(meta=Meta.model_validate({SUBSCRIPTION_ID: ex.id}))
|
|
81
79
|
|
|
82
80
|
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
"""Cursor-based event storage shared by reply and notification streams.
|
|
2
|
-
|
|
3
|
-
Applications supply the backend; MemoryHub serves one process. Shared storage lets one node
|
|
4
|
-
publish replies that another is awaiting.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import asyncio
|
|
10
|
-
from abc import abstractmethod
|
|
11
|
-
from collections.abc import Mapping, Sequence
|
|
12
|
-
from typing import Any, Protocol, runtime_checkable
|
|
13
|
-
|
|
14
|
-
from .namespaces import scoped
|
|
15
|
-
|
|
16
|
-
Cursor = str
|
|
17
|
-
|
|
18
|
-
START: Cursor = ""
|
|
19
|
-
|
|
20
|
-
NOTIFICATIONS = "notifications"
|
|
21
|
-
|
|
22
|
-
ASK = "ask"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@runtime_checkable
|
|
26
|
-
class Hub(Protocol):
|
|
27
|
-
"""Application-provided event storage.
|
|
28
|
-
|
|
29
|
-
Subclass it to have the methods checked and the missing ones refused, or
|
|
30
|
-
supply any object with these four methods: this is a protocol, so a
|
|
31
|
-
backend that inherits nothing is still a Hub.
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
@abstractmethod
|
|
35
|
-
async def publish(self, topic: str, message: Mapping[str, Any]) -> None:
|
|
36
|
-
"""Append one message to `topic`."""
|
|
37
|
-
|
|
38
|
-
@abstractmethod
|
|
39
|
-
async def position(self, topic: str) -> Cursor:
|
|
40
|
-
"""Capture the cursor before triggering a publish so replies preceding the first poll are
|
|
41
|
-
included.
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
@abstractmethod
|
|
45
|
-
async def poll(
|
|
46
|
-
self, topic: str, cursor: Cursor, *, timeout: float
|
|
47
|
-
) -> tuple[Sequence[Mapping[str, Any]], Cursor]:
|
|
48
|
-
"""Return messages after cursor and the next cursor.
|
|
49
|
-
|
|
50
|
-
Wait up to timeout seconds for a message. Backends choose whether to poll or wait for
|
|
51
|
-
notification; timeout is a deadline, not a polling interval.
|
|
52
|
-
"""
|
|
53
|
-
|
|
54
|
-
@abstractmethod
|
|
55
|
-
async def delete(self, topic: str) -> None:
|
|
56
|
-
"""Delete a completed topic."""
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def topic(kind: str, name: str | None = None) -> str:
|
|
60
|
-
"""Prefix the topic with the current namespace to isolate callers."""
|
|
61
|
-
return scoped(kind if name is None else f"{kind}/{name}")
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class MemoryHub(Hub):
|
|
65
|
-
"""Single-process hub, waiting on a condition rather than sleeping."""
|
|
66
|
-
|
|
67
|
-
def __init__(self) -> None:
|
|
68
|
-
self.rows: dict[str, list[tuple[int, Mapping[str, Any]]]] = {}
|
|
69
|
-
self.last_id = 0
|
|
70
|
-
self.arrived = asyncio.Condition()
|
|
71
|
-
|
|
72
|
-
async def publish(self, topic: str, message: Mapping[str, Any]) -> None:
|
|
73
|
-
async with self.arrived:
|
|
74
|
-
self.last_id += 1
|
|
75
|
-
self.rows.setdefault(topic, []).append((self.last_id, dict(message)))
|
|
76
|
-
self.arrived.notify_all()
|
|
77
|
-
|
|
78
|
-
async def position(self, topic: str) -> Cursor:
|
|
79
|
-
rows = self.rows.get(topic)
|
|
80
|
-
return str(rows[-1][0]) if rows else START
|
|
81
|
-
|
|
82
|
-
def after(self, topic: str, cursor: Cursor) -> list[tuple[int, Mapping[str, Any]]]:
|
|
83
|
-
least = int(cursor) if cursor else 0
|
|
84
|
-
return [row for row in self.rows.get(topic, []) if row[0] > least]
|
|
85
|
-
|
|
86
|
-
async def poll(
|
|
87
|
-
self, topic: str, cursor: Cursor, *, timeout: float
|
|
88
|
-
) -> tuple[Sequence[Mapping[str, Any]], Cursor]:
|
|
89
|
-
loop = asyncio.get_running_loop()
|
|
90
|
-
deadline = loop.time() + timeout
|
|
91
|
-
async with self.arrived:
|
|
92
|
-
while True:
|
|
93
|
-
found = self.after(topic, cursor)
|
|
94
|
-
if found:
|
|
95
|
-
return [message for _, message in found], str(found[-1][0])
|
|
96
|
-
left = deadline - loop.time()
|
|
97
|
-
if left <= 0:
|
|
98
|
-
return [], cursor
|
|
99
|
-
try:
|
|
100
|
-
await asyncio.wait_for(self.arrived.wait(), left)
|
|
101
|
-
except asyncio.TimeoutError:
|
|
102
|
-
return [], cursor
|
|
103
|
-
|
|
104
|
-
async def delete(self, topic: str) -> None:
|
|
105
|
-
async with self.arrived:
|
|
106
|
-
self.rows.pop(topic, None)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
__all__ = ["ASK", "NOTIFICATIONS", "START", "Cursor", "Hub", "MemoryHub", "topic"]
|
|
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.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/selection.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.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
|