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.
Files changed (44) hide show
  1. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/PKG-INFO +1 -1
  2. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/pyproject.toml +1 -1
  3. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/pyproject.toml.orig +1 -1
  4. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/client.py +13 -4
  5. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/client_base.py +24 -7
  6. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/dispatcher.py +46 -5
  7. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/endpoint.py +22 -12
  8. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/exchange.py +6 -9
  9. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/http_sse.py +8 -11
  10. aiohttp_tiny_mcp-0.3.0/src/aiohttp_tiny_mcp/hub.py +169 -0
  11. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/postgres.py +17 -16
  12. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/redis.py +21 -13
  13. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/registry.py +3 -0
  14. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/sqlite.py +15 -18
  15. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/subscriptions.py +4 -6
  16. aiohttp_tiny_mcp-0.2.6/src/aiohttp_tiny_mcp/hub.py +0 -109
  17. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/LICENSE +0 -0
  18. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/README.md +0 -0
  19. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/__init__.py +0 -0
  20. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/adapter.py +0 -0
  21. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/auth.py +0 -0
  22. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
  23. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/console/console.css +0 -0
  24. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/console/console.js +0 -0
  25. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/console/index.html +0 -0
  26. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/core.py +0 -0
  27. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/models.py +0 -0
  28. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
  29. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  30. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
  31. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
  32. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
  33. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  34. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
  35. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
  36. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/py.typed +0 -0
  37. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/request_state.py +0 -0
  38. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/schema.py +0 -0
  39. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/sessions.py +0 -0
  40. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/specs.py +0 -0
  41. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/sse.py +0 -0
  42. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/stdio.py +0 -0
  43. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
  44. {aiohttp_tiny_mcp-0.2.6 → aiohttp_tiny_mcp-0.3.0}/src/aiohttp_tiny_mcp/testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohttp-tiny-mcp
3
- Version: 0.2.6
3
+ Version: 0.3.0
4
4
  Summary: Remote MCP server and client library for aiohttp
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.2.6"
3
+ version = "0.3.0"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.2.6"
3
+ version = "0.3.0"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.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(self) -> AsyncIterator[dict[str, Any]]:
97
- """Open the legacy notification stream with GET; keep it open until cancelled."""
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 frame in frames(resp):
104
- yield frame
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(self) -> AsyncIterator[dict[str, Any]]:
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
- result = await self.request(Operation.LIST_TOOLS, ListParams())
188
- tools = ListToolsResult.model_validate(result).tools
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
- result = await self.request(Operation.LIST_RESOURCES, ListParams())
276
- return ListResourcesResult.model_validate(result).resources
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
- result = await self.request(Operation.LIST_PROMPTS, ListParams())
284
- return ListPromptsResult.model_validate(result).prompts
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 typing import Any
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
- return Value(result=ListToolsResult(tools=tools))
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
- return Value(result=ListResourcesResult(resources=defs))
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
- return Value(result=ListResourceTemplatesResult(resource_templates=defs))
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
- return Value(result=ListPromptsResult(prompts=prompts))
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 so updates reach an open stream."""
408
+ """Relay changes with their hub ids, re-reading subscriptions each pass."""
409
409
  capabilities = adapter.capabilities(self.registry)
410
- hub = self.registry.hub
411
- where = topic(NOTIFICATIONS)
412
- cursor = await hub.position(where)
410
+ events = await self.resumed(request, topic(NOTIFICATIONS))
413
411
  while True:
414
- messages, cursor = await hub.poll(where, cursor, timeout=self.registry.hub_poll_seconds)
415
- if not messages:
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 payload in messages:
421
- if relays(payload, accepted):
422
- text = json.dumps(payload, ensure_ascii=False)
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[None]] | None = None
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. Capture the cursor before sending so fast replies are
186
- not missed.
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
- cursor = await hub.position(where)
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
- messages, cursor = await hub.poll(
201
- where, cursor, timeout=min(left, self.registry.hub_poll_seconds)
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
- cursor = await hub.position(where)
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, where, cursor)
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, where: str, cursor: str
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, where, cursor))
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, where: str, cursor: str) -> None:
146
+ async def relay(self, response: SSEResponse, events: Subscription) -> None:
147
147
  """Write everything published for this connection, until cancelled."""
148
- hub = self.registry.hub
149
- while True:
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]) -> None:
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(self._query_publish, (topic, json.dumps(message)))
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) -> tuple[list[Mapping[str, Any]], Cursor]:
417
- """Everything published after `cursor`, and where to continue from."""
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
- if not rows:
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
- messages, cursor = await self.after(topic, cursor)
435
- if messages:
436
- return messages, cursor
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 [], cursor
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]) -> None:
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(key, {"message": json.dumps(message)}, maxlen=self.keep, approximate=True)
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
- self, topic: str, cursor: Cursor, *, timeout: float
172
- ) -> tuple[Sequence[Mapping[str, Any]], Cursor]:
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
- found: Any = await client.xread(
175
- {self.key(topic): cursor or BEGINNING}, block=max(1, int(timeout * 1000))
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 [], cursor
179
- entries = found[0][1]
180
- messages = [json.loads(next(iter(fields.values()))) for _, fields in entries]
181
- return messages, text(entries[-1][0])
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]) -> None:
184
+ async def publish(self, topic: str, message: Mapping[str, Any]) -> Cursor:
185
185
  connection = await self.storage.open()
186
- await connection.execute(
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) -> tuple[list[Mapping[str, Any]], Cursor]:
200
- """Everything published after `cursor`, and where to continue from."""
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 = list(await rows.fetchall())
207
- if not found:
208
- return [], cursor
209
- return [json.loads(row[1]) for row in found], str(found[-1][0])
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
- messages, cursor = await self.after(topic, cursor)
218
- if messages:
219
- return messages, cursor
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 [], cursor
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
- topic = hubs.topic(hubs.NOTIFICATIONS)
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
- messages, cursor = await hub.poll(topic, cursor, timeout=ex.registry.hub_poll_seconds)
77
- for payload in messages:
78
- if relays(payload, accepted):
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"]