aiohttp-tiny-mcp 0.3.3__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.3 → aiohttp_tiny_mcp-0.3.5}/PKG-INFO +6 -2
  2. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/README.md +5 -1
  3. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/pyproject.toml +1 -1
  4. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/pyproject.toml.orig +1 -1
  5. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/adapter.py +1 -0
  6. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/client.py +49 -7
  7. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/console.css +2 -0
  8. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/console.js +41 -3
  9. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/index.html +3 -3
  10. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/core.py +1 -0
  11. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/endpoint.py +56 -14
  12. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/postgres.py +19 -0
  13. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/redis.py +6 -0
  14. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/sessions.py +18 -3
  15. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/sqlite.py +8 -0
  16. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/LICENSE +0 -0
  17. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/__init__.py +0 -0
  18. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/auth.py +0 -0
  19. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/client_base.py +0 -0
  20. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
  21. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
  22. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/exchange.py +0 -0
  23. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/extensions.py +0 -0
  24. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/http_sse.py +0 -0
  25. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/hub.py +0 -0
  26. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/models.py +0 -0
  27. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
  28. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  29. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
  30. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
  31. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
  32. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  33. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
  34. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
  35. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/py.typed +0 -0
  36. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/registry.py +0 -0
  37. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/request_state.py +0 -0
  38. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/schema.py +0 -0
  39. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/skills.py +0 -0
  40. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/specs.py +0 -0
  41. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/sse.py +0 -0
  42. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/stdio.py +0 -0
  43. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
  44. {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
  45. {aiohttp_tiny_mcp-0.3.3 → 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.3
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
@@ -146,7 +146,11 @@ async def deploy(args: Deploy, ex: Exchange) -> str:
146
146
  return f"deployed {args.service}"
147
147
 
148
148
 
149
- app = Endpoint(registry).app("/mcp")
149
+ app = Endpoint(
150
+ registry,
151
+ # Behind a reverse proxy, uncomment this and let the proxy check Origin.
152
+ # trust_proxy_origin_validation=True,
153
+ ).app("/mcp")
150
154
 
151
155
  if __name__ == "__main__":
152
156
  web.run_app(app, host="127.0.0.1", port=8080)
@@ -104,7 +104,11 @@ async def deploy(args: Deploy, ex: Exchange) -> str:
104
104
  return f"deployed {args.service}"
105
105
 
106
106
 
107
- app = Endpoint(registry).app("/mcp")
107
+ app = Endpoint(
108
+ registry,
109
+ # Behind a reverse proxy, uncomment this and let the proxy check Origin.
110
+ # trust_proxy_origin_validation=True,
111
+ ).app("/mcp")
108
112
 
109
113
  if __name__ == "__main__":
110
114
  web.run_app(app, host="127.0.0.1", port=8080)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.3.3"
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.3"
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(
@@ -71,6 +71,8 @@ body {
71
71
  }
72
72
 
73
73
  .field { display: flex; align-items: center; gap: 6px; }
74
+ .field.grow { flex: 1 1 12em; min-width: 0; }
75
+ .field.grow > input { flex: 1 1 auto; min-width: 8em; }
74
76
  .field > span { color: var(--ink-soft); font-size: 12px; }
75
77
 
76
78
  input[type="text"], input[type="number"], select, textarea {
@@ -384,6 +384,34 @@ const page = {
384
384
  let client = null;
385
385
  let chosen = null;
386
386
  let endpointUrl = null;
387
+ //: Where the page was told the endpoint is. The field starts from it and may add a query.
388
+ let configuredEndpoint = null;
389
+
390
+ // The endpoint field: the path, plus any query string a person adds, such as
391
+ // `?mcp=2025-06-18` to pin a revision or whatever the server reads from the URL.
392
+ // The value is kept for the next visit while its path is still the configured one,
393
+ // so a server moved elsewhere is not chased at a stale address.
394
+ function endpointKey() {
395
+ return `mcp-console-endpoint:${location.pathname}`;
396
+ }
397
+
398
+ function rememberedEndpoint() {
399
+ const kept = localStorage.getItem(endpointKey());
400
+ if (!kept) return null;
401
+ const url = new URL(kept, location.origin);
402
+ return url.pathname === configuredEndpoint.pathname ? kept : null;
403
+ }
404
+
405
+ function chosenEndpoint() {
406
+ const typed = page.endpoint.value.trim() || configuredEndpoint.pathname;
407
+ const url = new URL(typed, location.origin);
408
+ if (url.origin !== location.origin) {
409
+ throw new Error(`The endpoint must be on this server, not ${url.origin}.`);
410
+ }
411
+ page.endpoint.value = url.pathname + url.search;
412
+ localStorage.setItem(endpointKey(), page.endpoint.value);
413
+ return url;
414
+ }
387
415
 
388
416
  function element(tag, className, text) {
389
417
  const made = document.createElement(tag);
@@ -1297,6 +1325,7 @@ function disconnect() {
1297
1325
  page.refresh.disabled = true;
1298
1326
  page.revision.disabled = false;
1299
1327
  page.answerable.disabled = false;
1328
+ page.endpoint.disabled = false;
1300
1329
  page.connect.textContent = "Connect";
1301
1330
  say("not connected", "");
1302
1331
  }
@@ -1305,9 +1334,11 @@ async function connect() {
1305
1334
  page.connect.disabled = true;
1306
1335
  page.revision.disabled = true;
1307
1336
  page.answerable.disabled = true;
1337
+ page.endpoint.disabled = true;
1308
1338
  say("connecting", "");
1309
1339
  page.outcome.textContent = "";
1310
1340
  try {
1341
+ endpointUrl = chosenEndpoint();
1311
1342
  client = new Client(endpointUrl.href, page.revision.value, {
1312
1343
  answerable: page.answerable.checked,
1313
1344
  onFrame: record,
@@ -1330,6 +1361,7 @@ async function connect() {
1330
1361
  page.refresh.disabled = true;
1331
1362
  page.revision.disabled = false;
1332
1363
  page.answerable.disabled = false;
1364
+ page.endpoint.disabled = false;
1333
1365
  page.connect.textContent = "Connect";
1334
1366
  say(error.message, "off");
1335
1367
  report("Could not connect", connectionHelp(error), true);
@@ -1340,7 +1372,7 @@ async function connect() {
1340
1372
 
1341
1373
  function connectionHelp(error) {
1342
1374
  const lines = [error.message];
1343
- if (error instanceof TypeError || /fetch/i.test(error.message)) {
1375
+ if (endpointUrl && (error instanceof TypeError || /fetch/i.test(error.message))) {
1344
1376
  lines.push("", `The request to ${endpointUrl.href} did not reach a server.`);
1345
1377
  }
1346
1378
  return lines.join("\n");
@@ -1409,8 +1441,14 @@ function start() {
1409
1441
  const remembered = localStorage.getItem("mcp-console-revision");
1410
1442
  page.revision.value = REVISIONS[remembered] ? remembered : Object.keys(REVISIONS)[0];
1411
1443
 
1412
- endpointUrl = new URL(document.documentElement.dataset.endpoint || "/mcp", location.origin);
1413
- page.endpoint.textContent = endpointUrl.pathname;
1444
+ configuredEndpoint = new URL(document.documentElement.dataset.endpoint || "/mcp", location.origin);
1445
+ page.endpoint.value = rememberedEndpoint() || configuredEndpoint.pathname + configuredEndpoint.search;
1446
+ page.endpoint.onkeydown = (event) => {
1447
+ if (event.key === "Enter" && !client) {
1448
+ event.preventDefault();
1449
+ connect();
1450
+ }
1451
+ };
1414
1452
 
1415
1453
  page.connect.onclick = () => (client ? disconnect() : connect());
1416
1454
  page.refresh.onclick = async () => {
@@ -11,10 +11,10 @@
11
11
 
12
12
  <header class="bar">
13
13
  <h1>{{TITLE}}</h1>
14
- <span class="field">
14
+ <label class="field grow" title="The path the server serves MCP on. A query string is sent as typed">
15
15
  <span>Endpoint</span>
16
- <code id="endpoint" class="endpoint"></code>
17
- </span>
16
+ <input id="endpoint" class="endpoint" type="text" spellcheck="false" autocomplete="off">
17
+ </label>
18
18
  <label class="field">
19
19
  <span>Revision</span>
20
20
  <select id="revision" class="revision"></select>
@@ -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"
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
+ import re
8
9
  from collections.abc import Mapping
9
10
  from contextlib import suppress
10
11
  from dataclasses import replace
@@ -51,6 +52,24 @@ log = logging.getLogger("aiohttp_tiny_mcp")
51
52
  MAY_ASK = frozenset({Operation.CALL_TOOL, Operation.GET_PROMPT, Operation.READ_RESOURCE})
52
53
 
53
54
 
55
+ def origin_pattern(spec: str) -> re.Pattern[str]:
56
+ """Compile an allowed origin with wildcards.
57
+
58
+ `*` matches one host label, `**` matches one or more labels, so
59
+ `https://*.example.com` admits `https://app.example.com` and `https://**.example.com`
60
+ admits `https://a.b.example.com` as well. Everything else matches literally.
61
+ """
62
+ parts = []
63
+ for piece in re.split(r"(\*\*|\*)", spec):
64
+ if piece == "**":
65
+ parts.append(r"[^./]+(?:\.[^./]+)*")
66
+ elif piece == "*":
67
+ parts.append(r"[^./]+")
68
+ else:
69
+ parts.append(re.escape(piece))
70
+ return re.compile("".join(parts))
71
+
72
+
54
73
  class Endpoint:
55
74
  """The MCP endpoint, in the spec's sense: one path that accepts POST.
56
75
 
@@ -81,6 +100,7 @@ class Endpoint:
81
100
  self.adapters = adapters or AdapterSet.default()
82
101
  self.dispatcher = Dispatcher(registry)
83
102
  self.allowed_origins = allowed_origins
103
+ self.origin_patterns = [origin_pattern(o) for o in allowed_origins or () if "*" in o]
84
104
  self.trust_proxy_origin_validation = trust_proxy_origin_validation
85
105
  self.compress = compress
86
106
 
@@ -187,8 +207,11 @@ class Endpoint:
187
207
  return
188
208
  if origin == self.own_origin(request):
189
209
  return
190
- if self.allowed_origins is None or origin not in self.allowed_origins:
191
- raise Rejected(Failure(FailureKind.ORIGIN_REJECTED, "origin not allowed"))
210
+ if self.allowed_origins is not None and origin in self.allowed_origins:
211
+ return
212
+ if any(pattern.fullmatch(origin) for pattern in self.origin_patterns):
213
+ return
214
+ raise Rejected(Failure(FailureKind.ORIGIN_REJECTED, "origin not allowed"))
192
215
 
193
216
  def own_origin(self, request: web.Request) -> str:
194
217
  """Origin of pages served by this endpoint. A rebound page retains the attacker's origin
@@ -268,14 +291,12 @@ class Endpoint:
268
291
  await relay_reply(self.registry.hub, pre.body)
269
292
  return web.Response(status=202)
270
293
 
271
- session = await self.load_session(request)
272
- if not self.owns(session, principal):
273
- session = None
274
- held = self.open_values(request, session)
275
294
  try:
295
+ session = await self.load_session(request, principal)
276
296
  adapter = self.adapters.select(pre, stored_version(session))
277
297
  except Rejected as e:
278
298
  return self.render_failure(self.adapters.fallback(), e.failure)
299
+ held = self.open_values(request, session)
279
300
 
280
301
  log.debug("<- [%s] %s", adapter.version, raw.decode("utf-8", "replace"))
281
302
 
@@ -376,9 +397,10 @@ class Endpoint:
376
397
  assert self.registry.auth is not None
377
398
  return self.refuse(self.registry.auth, refusal)
378
399
 
379
- record = await self.load_session(request)
380
- if not self.owns(record, principal):
381
- 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)
382
404
  adapter = self.stream_adapter(request, record)
383
405
  if not adapter.has_handshake:
384
406
  return web.Response(
@@ -412,9 +434,7 @@ class Endpoint:
412
434
  found = await events.poll()
413
435
  if not found:
414
436
  continue
415
- accepted = wanted(
416
- capabilities, self.open_values(request, await self.load_session(request))
417
- )
437
+ accepted = wanted(capabilities, self.open_values(request, await self.stored(request)))
418
438
  for event in found:
419
439
  if relays(event.message, accepted):
420
440
  text = json.dumps(event.message, ensure_ascii=False)
@@ -449,13 +469,35 @@ class Endpoint:
449
469
  return True
450
470
  return principal is not None and principal.identity == owner
451
471
 
452
- async def load_session(self, request: web.Request) -> SessionRecord | None:
453
- """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."""
454
474
  session_id = request.headers.get(SESSION_HEADER)
455
475
  if not session_id:
456
476
  return None
457
477
  return await self.registry.session_store.get(scoped(session_id))
458
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
+
459
501
  def open_values(self, request: web.Request, record: SessionRecord | None) -> Session | None:
460
502
  """The application-owned half of this request's session, if any."""
461
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,))