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.
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/PKG-INFO +6 -2
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/README.md +5 -1
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/pyproject.toml +1 -1
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/pyproject.toml.orig +1 -1
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/adapter.py +1 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/client.py +49 -7
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/console.css +2 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/console.js +41 -3
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/index.html +3 -3
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/core.py +1 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/endpoint.py +56 -14
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/postgres.py +19 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/redis.py +6 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/sessions.py +18 -3
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/sqlite.py +8 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/LICENSE +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/auth.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/client_base.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/exchange.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/extensions.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/http_sse.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/hub.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/models.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/py.typed +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/registry.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/request_state.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/schema.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/skills.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/specs.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/sse.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/stdio.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
- {aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
- {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
|
+
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(
|
|
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(
|
|
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)
|
|
@@ -90,6 +90,7 @@ class Adapter(ABC):
|
|
|
90
90
|
FailureKind.MALFORMED: (-32600, 400),
|
|
91
91
|
FailureKind.INVALID_PARAMS: (-32602, 200),
|
|
92
92
|
FailureKind.ORIGIN_REJECTED: (-32600, 403),
|
|
93
|
+
FailureKind.SESSION_NOT_FOUND: (-32001, 404),
|
|
93
94
|
FailureKind.INTERNAL: (-32603, 200),
|
|
94
95
|
FailureKind.INVALID_ARGUMENTS: (-32603, 200),
|
|
95
96
|
FailureKind.UNKNOWN_METHOD: (-32601, 200),
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import logging
|
|
6
7
|
from collections.abc import AsyncIterator, Callable, Mapping
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
@@ -19,6 +20,8 @@ NOTIFICATIONS_METHOD = "notifications/message"
|
|
|
19
20
|
|
|
20
21
|
BASE_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
|
21
22
|
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
22
25
|
|
|
23
26
|
async def frames(resp: aiohttp.ClientResponse) -> AsyncIterator[dict[str, Any]]:
|
|
24
27
|
"""Yield JSON or SSE messages immediately; waiting for EOF would deadlock pushed questions."""
|
|
@@ -76,23 +79,62 @@ class Client(BaseClient):
|
|
|
76
79
|
headers["Mcp-Session-Id"] = self.session_id
|
|
77
80
|
return headers
|
|
78
81
|
|
|
82
|
+
def lost_session(self, resp: aiohttp.ClientResponse) -> bool:
|
|
83
|
+
"""Whether the server no longer holds the session this request named.
|
|
84
|
+
|
|
85
|
+
The spec answers such a request with 404. A 404 without a session id
|
|
86
|
+
in flight is an ordinary reply and is read as one.
|
|
87
|
+
"""
|
|
88
|
+
return resp.status == 404 and self.session_id is not None
|
|
89
|
+
|
|
90
|
+
async def reopen(self) -> None:
|
|
91
|
+
"""Start over with initialize, which mints a session and restores the log level."""
|
|
92
|
+
log.debug("session %s is gone, opening a new one", self.session_id)
|
|
93
|
+
self.session_id = None
|
|
94
|
+
await self.initialize()
|
|
95
|
+
|
|
79
96
|
async def exchange(
|
|
80
97
|
self, envelope: dict[str, Any], *, method: str, name: str | None
|
|
81
98
|
) -> AsyncIterator[dict[str, Any]]:
|
|
82
99
|
assert self.session is not None, "use 'async with Client(...) as client:'"
|
|
83
100
|
tool = self.tool_definitions.get(name) if name is not None else None
|
|
84
|
-
|
|
85
|
-
async with self.session.post(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
101
|
+
params = envelope.get("params")
|
|
102
|
+
async with self.session.post(
|
|
103
|
+
self.base_url, json=envelope, headers=self.headers(method, name, params, tool)
|
|
104
|
+
) as resp:
|
|
105
|
+
if not self.lost_session(resp):
|
|
106
|
+
self.adopt(resp)
|
|
107
|
+
async for frame in frames(resp):
|
|
108
|
+
yield frame
|
|
109
|
+
return
|
|
110
|
+
# Retry once on a fresh session; a second 404 is reported as any other reply.
|
|
111
|
+
await self.reopen()
|
|
112
|
+
async with self.session.post(
|
|
113
|
+
self.base_url, json=envelope, headers=self.headers(method, name, params, tool)
|
|
114
|
+
) as resp:
|
|
115
|
+
self.adopt(resp)
|
|
89
116
|
async for frame in frames(resp):
|
|
90
117
|
yield frame
|
|
91
118
|
|
|
119
|
+
def adopt(self, resp: aiohttp.ClientResponse) -> None:
|
|
120
|
+
"""Keep the session id a handshake reply carries."""
|
|
121
|
+
issued = resp.headers.get("Mcp-Session-Id")
|
|
122
|
+
if issued:
|
|
123
|
+
self.session_id = issued
|
|
124
|
+
|
|
92
125
|
async def send_notification(self, envelope: dict[str, Any], *, method: str) -> None:
|
|
93
126
|
assert self.session is not None, "use 'async with Client(...) as client:'"
|
|
94
|
-
|
|
95
|
-
async with self.session.post(
|
|
127
|
+
params = envelope.get("params")
|
|
128
|
+
async with self.session.post(
|
|
129
|
+
self.base_url, json=envelope, headers=self.headers(method, params=params)
|
|
130
|
+
) as resp:
|
|
131
|
+
if not self.lost_session(resp):
|
|
132
|
+
await resp.read()
|
|
133
|
+
return
|
|
134
|
+
await self.reopen()
|
|
135
|
+
async with self.session.post(
|
|
136
|
+
self.base_url, json=envelope, headers=self.headers(method, params=params)
|
|
137
|
+
) as resp:
|
|
96
138
|
await resp.read()
|
|
97
139
|
|
|
98
140
|
async def stream_notifications(
|
|
@@ -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
|
-
|
|
1413
|
-
page.endpoint.
|
|
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
|
-
<
|
|
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
|
-
<
|
|
17
|
-
</
|
|
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
|
|
191
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
453
|
-
"""
|
|
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.
|
|
47
|
-
|
|
46
|
+
expected_version no longer matches. touch() renews the TTL without a write to the data, so
|
|
47
|
+
a session stays alive as long as its client keeps sending requests. Store only
|
|
48
|
+
JSON-serializable values, never requests, sockets, queues, or tasks.
|
|
48
49
|
|
|
49
50
|
Subclass it to have the methods checked and the missing ones refused, or
|
|
50
|
-
supply any object with these
|
|
51
|
+
supply any object with these five methods: this is a protocol, so a
|
|
51
52
|
backend that inherits nothing is still a SessionStore.
|
|
52
53
|
"""
|
|
53
54
|
|
|
@@ -70,6 +71,12 @@ class SessionStore(Protocol):
|
|
|
70
71
|
) -> bool:
|
|
71
72
|
"""Replace the data and renew the TTL, or return False where the version moved."""
|
|
72
73
|
|
|
74
|
+
@abstractmethod
|
|
75
|
+
async def touch(self, session_id: str, *, ttl_seconds: int) -> bool:
|
|
76
|
+
"""Renew the TTL of a live record, keeping its data and version. Return False where
|
|
77
|
+
it is missing or expired.
|
|
78
|
+
"""
|
|
79
|
+
|
|
73
80
|
@abstractmethod
|
|
74
81
|
async def delete(self, session_id: str) -> None:
|
|
75
82
|
"""Forget it."""
|
|
@@ -118,6 +125,14 @@ class MemorySessionStore(SessionStore):
|
|
|
118
125
|
self.records[session_id] = (dict(data), expected_version + 1, self.clock() + ttl_seconds)
|
|
119
126
|
return True
|
|
120
127
|
|
|
128
|
+
async def touch(self, session_id: str, *, ttl_seconds: int) -> bool:
|
|
129
|
+
entry = self.live(session_id)
|
|
130
|
+
if entry is None:
|
|
131
|
+
return False
|
|
132
|
+
data, version, _ = entry
|
|
133
|
+
self.records[session_id] = (data, version, self.clock() + ttl_seconds)
|
|
134
|
+
return True
|
|
135
|
+
|
|
121
136
|
async def delete(self, session_id: str) -> None:
|
|
122
137
|
self.records.pop(session_id, None)
|
|
123
138
|
|
|
@@ -165,6 +165,14 @@ class SqliteSessionStore(SessionStore):
|
|
|
165
165
|
)
|
|
166
166
|
return cursor.rowcount > 0
|
|
167
167
|
|
|
168
|
+
async def touch(self, session_id: str, *, ttl_seconds: int) -> bool:
|
|
169
|
+
connection = await self.storage.open()
|
|
170
|
+
cursor = await connection.execute(
|
|
171
|
+
f"UPDATE mcp_sessions SET expires_at = {NOW} + ? WHERE id = ? AND expires_at > {NOW}",
|
|
172
|
+
(ttl_seconds, session_id),
|
|
173
|
+
)
|
|
174
|
+
return cursor.rowcount > 0
|
|
175
|
+
|
|
168
176
|
async def delete(self, session_id: str) -> None:
|
|
169
177
|
connection = await self.storage.open()
|
|
170
178
|
await connection.execute("DELETE FROM mcp_sessions WHERE id = ?", (session_id,))
|
|
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.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/selection.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.3 → aiohttp_tiny_mcp-0.3.5}/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
|
|
File without changes
|
|
File without changes
|