aiohttp-tiny-mcp 0.3.4__tar.gz → 0.3.5__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 (45) hide show
  1. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/PKG-INFO +1 -1
  2. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/pyproject.toml +1 -1
  3. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/pyproject.toml.orig +1 -1
  4. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/adapter.py +1 -0
  5. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/client.py +49 -7
  6. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/core.py +1 -0
  7. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/endpoint.py +31 -12
  8. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/postgres.py +19 -0
  9. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/redis.py +6 -0
  10. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/sessions.py +18 -3
  11. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/sqlite.py +8 -0
  12. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/LICENSE +0 -0
  13. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/README.md +0 -0
  14. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/__init__.py +0 -0
  15. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/auth.py +0 -0
  16. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/client_base.py +0 -0
  17. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
  18. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/console.css +0 -0
  19. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/console.js +0 -0
  20. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/index.html +0 -0
  21. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
  22. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/exchange.py +0 -0
  23. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/extensions.py +0 -0
  24. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/http_sse.py +0 -0
  25. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/hub.py +0 -0
  26. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/models.py +0 -0
  27. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
  28. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  29. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
  30. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
  31. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
  32. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  33. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
  34. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
  35. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/py.typed +0 -0
  36. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/registry.py +0 -0
  37. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/request_state.py +0 -0
  38. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/schema.py +0 -0
  39. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/skills.py +0 -0
  40. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/specs.py +0 -0
  41. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/sse.py +0 -0
  42. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/stdio.py +0 -0
  43. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
  44. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
  45. {aiohttp_tiny_mcp-0.3.4 → aiohttp_tiny_mcp-0.3.5}/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.3.4
3
+ Version: 0.3.5
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.3.4"
3
+ version = "0.3.5"
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.3.4"
3
+ version = "0.3.5"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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
- headers = self.headers(method, name, envelope.get("params"), tool)
85
- async with self.session.post(self.base_url, json=envelope, headers=headers) as resp:
86
- issued = resp.headers.get("Mcp-Session-Id")
87
- if issued:
88
- self.session_id = issued
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
- headers = self.headers(method, params=envelope.get("params"))
95
- async with self.session.post(self.base_url, json=envelope, headers=headers) as resp:
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(
@@ -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
- record = await self.load_session(request)
403
- if not self.owns(record, principal):
404
- record = None
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(
@@ -435,9 +434,7 @@ class Endpoint:
435
434
  found = await events.poll()
436
435
  if not found:
437
436
  continue
438
- accepted = wanted(
439
- capabilities, self.open_values(request, await self.load_session(request))
440
- )
437
+ accepted = wanted(capabilities, self.open_values(request, await self.stored(request)))
441
438
  for event in found:
442
439
  if relays(event.message, accepted):
443
440
  text = json.dumps(event.message, ensure_ascii=False)
@@ -472,13 +469,35 @@ class Endpoint:
472
469
  return True
473
470
  return principal is not None and principal.identity == owner
474
471
 
475
- async def load_session(self, request: web.Request) -> SessionRecord | None:
476
- """Load a legacy handshake, or None if no session exists or it has expired."""
472
+ async def stored(self, request: web.Request) -> SessionRecord | None:
473
+ """The record the session header names, or None where there is no header or no record."""
477
474
  session_id = request.headers.get(SESSION_HEADER)
478
475
  if not session_id:
479
476
  return None
480
477
  return await self.registry.session_store.get(scoped(session_id))
481
478
 
479
+ async def load_session(self, request: web.Request, principal: Any) -> SessionRecord | None:
480
+ """Load the legacy handshake this request names, or None without a session header.
481
+
482
+ Raises `Rejected` where the header names a session the store does not
483
+ hold or another principal owns. The spec answers that with 404 so the
484
+ client opens a new session with `initialize` instead of going on with
485
+ capabilities and a log level this server no longer remembers.
486
+
487
+ A found session has its TTL renewed: it lives while the client keeps
488
+ talking, not for a fixed time after the handshake.
489
+ """
490
+ session_id = request.headers.get(SESSION_HEADER)
491
+ if not session_id:
492
+ return None
493
+ record = await self.registry.session_store.get(scoped(session_id))
494
+ if record is None or not self.owns(record, principal):
495
+ raise Rejected(Failure(FailureKind.SESSION_NOT_FOUND, "session not found"))
496
+ await self.registry.session_store.touch(
497
+ scoped(session_id), ttl_seconds=self.registry.session_ttl_seconds
498
+ )
499
+ return record
500
+
482
501
  def open_values(self, request: web.Request, record: SessionRecord | None) -> Session | None:
483
502
  """The application-owned half of this request's session, if any."""
484
503
  session_id = request.headers.get(SESSION_HEADER)
@@ -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:
@@ -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))
@@ -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. Store only JSON-serializable values, never requests,
47
- sockets, queues, or tasks.
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 four methods: this is a protocol, so a
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,))