aiohttp-tiny-mcp 0.1.0__py3-none-any.whl
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/__init__.py +76 -0
- aiohttp_tiny_mcp/adapter.py +321 -0
- aiohttp_tiny_mcp/auth.py +129 -0
- aiohttp_tiny_mcp/client.py +111 -0
- aiohttp_tiny_mcp/client_base.py +295 -0
- aiohttp_tiny_mcp/console/__init__.py +98 -0
- aiohttp_tiny_mcp/console/console.css +525 -0
- aiohttp_tiny_mcp/console/console.js +1273 -0
- aiohttp_tiny_mcp/console/index.html +100 -0
- aiohttp_tiny_mcp/core.py +327 -0
- aiohttp_tiny_mcp/dispatcher.py +309 -0
- aiohttp_tiny_mcp/endpoint.py +531 -0
- aiohttp_tiny_mcp/exchange.py +279 -0
- aiohttp_tiny_mcp/http_sse.py +267 -0
- aiohttp_tiny_mcp/hub.py +109 -0
- aiohttp_tiny_mcp/models.py +346 -0
- aiohttp_tiny_mcp/namespaces.py +36 -0
- aiohttp_tiny_mcp/postgres.py +454 -0
- aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- aiohttp_tiny_mcp/protocol/selection.py +92 -0
- aiohttp_tiny_mcp/protocol/v2024_11_05.py +30 -0
- aiohttp_tiny_mcp/protocol/v2025_03_26.py +165 -0
- aiohttp_tiny_mcp/protocol/v2025_06_18.py +11 -0
- aiohttp_tiny_mcp/protocol/v2025_11_25.py +164 -0
- aiohttp_tiny_mcp/protocol/v2026_07_28.py +363 -0
- aiohttp_tiny_mcp/py.typed +0 -0
- aiohttp_tiny_mcp/redis.py +195 -0
- aiohttp_tiny_mcp/registry.py +162 -0
- aiohttp_tiny_mcp/request_state.py +107 -0
- aiohttp_tiny_mcp/schema.py +131 -0
- aiohttp_tiny_mcp/sessions.py +347 -0
- aiohttp_tiny_mcp/specs.py +268 -0
- aiohttp_tiny_mcp/sqlite.py +236 -0
- aiohttp_tiny_mcp/sse.py +260 -0
- aiohttp_tiny_mcp/stdio.py +187 -0
- aiohttp_tiny_mcp/stdio_client.py +91 -0
- aiohttp_tiny_mcp/subscriptions.py +124 -0
- aiohttp_tiny_mcp/testing.py +200 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/METADATA +12 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/RECORD +41 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""2026-07-28: no handshake, per-request `_meta`, MRTR, mirrored headers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Any, ClassVar, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
|
|
13
|
+
from aiohttp_tiny_mcp.adapter import Adapter, RegistryProtocol
|
|
14
|
+
from aiohttp_tiny_mcp.core import (
|
|
15
|
+
Call,
|
|
16
|
+
ClientInfo,
|
|
17
|
+
ClientProfile,
|
|
18
|
+
Failure,
|
|
19
|
+
FailureKind,
|
|
20
|
+
InputRequest,
|
|
21
|
+
NeedsInput,
|
|
22
|
+
Operation,
|
|
23
|
+
Preamble,
|
|
24
|
+
Rejected,
|
|
25
|
+
unwrap_answers,
|
|
26
|
+
)
|
|
27
|
+
from aiohttp_tiny_mcp.models import (
|
|
28
|
+
CallToolParams,
|
|
29
|
+
CompleteParams,
|
|
30
|
+
GetPromptParams,
|
|
31
|
+
Incoming,
|
|
32
|
+
ListenParams,
|
|
33
|
+
ListParams,
|
|
34
|
+
Meta,
|
|
35
|
+
Model,
|
|
36
|
+
Params,
|
|
37
|
+
ReadResourceParams,
|
|
38
|
+
ReadResourceResult,
|
|
39
|
+
ToolDef,
|
|
40
|
+
)
|
|
41
|
+
from aiohttp_tiny_mcp.schema import header_params
|
|
42
|
+
from aiohttp_tiny_mcp.specs import ToolSpec
|
|
43
|
+
|
|
44
|
+
# Modern subscriptions are independent long-lived requests, not MCP sessions.
|
|
45
|
+
METHODS: Mapping[str, Operation] = MappingProxyType(
|
|
46
|
+
{
|
|
47
|
+
"server/discover": Operation.DESCRIBE,
|
|
48
|
+
"tools/list": Operation.LIST_TOOLS,
|
|
49
|
+
"tools/call": Operation.CALL_TOOL,
|
|
50
|
+
"resources/list": Operation.LIST_RESOURCES,
|
|
51
|
+
"resources/templates/list": Operation.LIST_RESOURCE_TEMPLATES,
|
|
52
|
+
"resources/read": Operation.READ_RESOURCE,
|
|
53
|
+
"prompts/list": Operation.LIST_PROMPTS,
|
|
54
|
+
"prompts/get": Operation.GET_PROMPT,
|
|
55
|
+
"completion/complete": Operation.COMPLETE,
|
|
56
|
+
"subscriptions/listen": Operation.LISTEN,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
METHOD_NAMES: Mapping[Operation, str] = MappingProxyType({op: name for name, op in METHODS.items()})
|
|
60
|
+
|
|
61
|
+
PARAMS_MODELS: Mapping[Operation, type[Params]] = MappingProxyType(
|
|
62
|
+
{
|
|
63
|
+
Operation.LIST_TOOLS: ListParams,
|
|
64
|
+
Operation.CALL_TOOL: CallToolParams,
|
|
65
|
+
Operation.LIST_RESOURCES: ListParams,
|
|
66
|
+
Operation.LIST_RESOURCE_TEMPLATES: ListParams,
|
|
67
|
+
Operation.READ_RESOURCE: ReadResourceParams,
|
|
68
|
+
Operation.LIST_PROMPTS: ListParams,
|
|
69
|
+
Operation.GET_PROMPT: GetPromptParams,
|
|
70
|
+
Operation.COMPLETE: CompleteParams,
|
|
71
|
+
Operation.LISTEN: ListenParams,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
NAME_HEADER_METHODS = frozenset({"tools/call", "resources/read", "prompts/get"})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@lru_cache(maxsize=1024)
|
|
79
|
+
def decode_header(value: str) -> str:
|
|
80
|
+
"""Undo the `=?base64?...?=` sentinel used for non-ASCII header values."""
|
|
81
|
+
if value.startswith("=?base64?") and value.endswith("?="):
|
|
82
|
+
return base64.b64decode(value[9:-2], validate=True).decode("utf-8")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def header_text(value: Any) -> str:
|
|
87
|
+
if isinstance(value, bool):
|
|
88
|
+
return "true" if value else "false"
|
|
89
|
+
if isinstance(value, int) and -(2**53) + 1 <= value <= 2**53 - 1:
|
|
90
|
+
return str(value)
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
return value
|
|
93
|
+
raise ValueError("mirrored parameter must be a string, boolean, or safe integer")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def encode_header(value: Any) -> str:
|
|
97
|
+
text = header_text(value)
|
|
98
|
+
unsafe = text != text.strip(" \t") or any(
|
|
99
|
+
ord(char) < 0x20 or ord(char) >= 0x7F for char in text
|
|
100
|
+
)
|
|
101
|
+
if unsafe:
|
|
102
|
+
encoded = base64.b64encode(text.encode()).decode("ascii")
|
|
103
|
+
return f"=?base64?{encoded}?="
|
|
104
|
+
return text
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
MISSING = object()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def path_value(arguments: Mapping[str, Any], path: tuple[str, ...]) -> Any:
|
|
111
|
+
value: Any = arguments
|
|
112
|
+
for part in path:
|
|
113
|
+
if not isinstance(value, Mapping) or part not in value:
|
|
114
|
+
return MISSING
|
|
115
|
+
value = value[part]
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class DiscoverResult(Model):
|
|
120
|
+
result_type: Literal["complete"] = "complete"
|
|
121
|
+
supported_versions: list[str]
|
|
122
|
+
capabilities: dict[str, Any]
|
|
123
|
+
instructions: str | None = None
|
|
124
|
+
ttl_ms: int | None = None
|
|
125
|
+
cache_scope: Literal["private", "public"] | None = None
|
|
126
|
+
meta: Meta | None = Field(None, alias="_meta")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class InputRequiredResult(Model):
|
|
130
|
+
result_type: Literal["input_required"] = "input_required"
|
|
131
|
+
input_requests: dict[str, Any]
|
|
132
|
+
request_state: str | None = None
|
|
133
|
+
meta: Meta | None = Field(None, alias="_meta")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Adapter2026_07_28(Adapter): # noqa: N801 -- revision date, greppable against the spec
|
|
137
|
+
version: ClassVar[str] = "2026-07-28"
|
|
138
|
+
can_ask: ClassVar[bool] = True
|
|
139
|
+
|
|
140
|
+
FAILURE_MAP: ClassVar[Mapping[FailureKind, tuple[int, int]]] = MappingProxyType(
|
|
141
|
+
{
|
|
142
|
+
**Adapter.BASE_FAILURE_MAP,
|
|
143
|
+
FailureKind.UNKNOWN_METHOD: (-32601, 404),
|
|
144
|
+
FailureKind.UNKNOWN_TARGET: (-32601, 404),
|
|
145
|
+
FailureKind.RESOURCE_NOT_FOUND: (-32602, 404),
|
|
146
|
+
FailureKind.HEADER_MISMATCH: (-32020, 400),
|
|
147
|
+
FailureKind.INPUT_UNSUPPORTED: (-32603, 200),
|
|
148
|
+
FailureKind.MISSING_REQUIRED_CAPABILITY: (-32021, 400),
|
|
149
|
+
FailureKind.UNSUPPORTED_VERSION: (-32022, 400),
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def __init__(self) -> None:
|
|
154
|
+
self.supported_versions: tuple[str, ...] = (self.version,)
|
|
155
|
+
|
|
156
|
+
def bind(self, versions: tuple[str, ...]) -> None:
|
|
157
|
+
self.supported_versions = versions
|
|
158
|
+
|
|
159
|
+
def check_http(
|
|
160
|
+
self,
|
|
161
|
+
pre: Preamble,
|
|
162
|
+
headers: Mapping[str, str],
|
|
163
|
+
registry: RegistryProtocol | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
is_notification = isinstance(pre.body, dict) and "id" not in pre.body
|
|
166
|
+
if pre.meta_version is None:
|
|
167
|
+
raise Rejected(
|
|
168
|
+
Failure(FailureKind.MALFORMED, "missing protocol version in request _meta")
|
|
169
|
+
)
|
|
170
|
+
if pre.header_version is None:
|
|
171
|
+
if is_notification:
|
|
172
|
+
return
|
|
173
|
+
raise Rejected(
|
|
174
|
+
Failure(FailureKind.HEADER_MISMATCH, "missing MCP-Protocol-Version header")
|
|
175
|
+
)
|
|
176
|
+
if pre.header_version != pre.meta_version:
|
|
177
|
+
raise Rejected(
|
|
178
|
+
Failure(FailureKind.HEADER_MISMATCH, "MCP-Protocol-Version does not match _meta")
|
|
179
|
+
)
|
|
180
|
+
method = headers.get("Mcp-Method")
|
|
181
|
+
if method is None:
|
|
182
|
+
raise Rejected(Failure(FailureKind.HEADER_MISMATCH, "missing Mcp-Method header"))
|
|
183
|
+
if method != pre.method:
|
|
184
|
+
raise Rejected(
|
|
185
|
+
Failure(
|
|
186
|
+
FailureKind.HEADER_MISMATCH,
|
|
187
|
+
f"Mcp-Method {method!r} != body method {pre.method!r}",
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
if pre.method not in NAME_HEADER_METHODS:
|
|
191
|
+
return
|
|
192
|
+
name = headers.get("Mcp-Name")
|
|
193
|
+
if name is None:
|
|
194
|
+
raise Rejected(Failure(FailureKind.HEADER_MISMATCH, "missing Mcp-Name header"))
|
|
195
|
+
params = pre.body.get("params") if isinstance(pre.body, dict) else None
|
|
196
|
+
key = "uri" if pre.method == "resources/read" else "name"
|
|
197
|
+
target = params.get(key) if isinstance(params, dict) else None
|
|
198
|
+
try:
|
|
199
|
+
decoded_name = decode_header(name)
|
|
200
|
+
except (ValueError, UnicodeDecodeError):
|
|
201
|
+
raise Rejected(
|
|
202
|
+
Failure(FailureKind.HEADER_MISMATCH, "malformed Mcp-Name header")
|
|
203
|
+
) from None
|
|
204
|
+
if decoded_name != target:
|
|
205
|
+
raise Rejected(Failure(FailureKind.HEADER_MISMATCH, "Mcp-Name does not match body"))
|
|
206
|
+
if pre.method == "tools/call" and registry is not None:
|
|
207
|
+
spec = registry.tools.get(target or "")
|
|
208
|
+
arguments = params.get("arguments", {}) if isinstance(params, dict) else {}
|
|
209
|
+
if spec is not None and isinstance(arguments, Mapping):
|
|
210
|
+
self.check_parameter_headers(spec, arguments, headers)
|
|
211
|
+
|
|
212
|
+
def check_parameter_headers(
|
|
213
|
+
self,
|
|
214
|
+
spec: ToolSpec,
|
|
215
|
+
arguments: Mapping[str, Any],
|
|
216
|
+
headers: Mapping[str, str],
|
|
217
|
+
) -> None:
|
|
218
|
+
for path, suffix in spec.header_params:
|
|
219
|
+
header_name = f"Mcp-Param-{suffix}"
|
|
220
|
+
actual = headers.get(header_name)
|
|
221
|
+
expected_value = path_value(arguments, path)
|
|
222
|
+
if expected_value is MISSING or expected_value is None:
|
|
223
|
+
if actual is not None:
|
|
224
|
+
raise Rejected(
|
|
225
|
+
Failure(
|
|
226
|
+
FailureKind.HEADER_MISMATCH,
|
|
227
|
+
f"{header_name} has no matching body parameter",
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
continue
|
|
231
|
+
if actual is None:
|
|
232
|
+
raise Rejected(
|
|
233
|
+
Failure(FailureKind.HEADER_MISMATCH, f"missing {header_name} header")
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
expected = header_text(expected_value)
|
|
237
|
+
decoded = decode_header(actual)
|
|
238
|
+
except (ValueError, UnicodeDecodeError):
|
|
239
|
+
raise Rejected(
|
|
240
|
+
Failure(FailureKind.HEADER_MISMATCH, f"malformed {header_name} header")
|
|
241
|
+
) from None
|
|
242
|
+
if decoded != expected:
|
|
243
|
+
raise Rejected(
|
|
244
|
+
Failure(
|
|
245
|
+
FailureKind.HEADER_MISMATCH,
|
|
246
|
+
f"{header_name} does not match body parameter",
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def params_model(self, operation: Operation) -> type[Params]:
|
|
251
|
+
return PARAMS_MODELS.get(operation, Params)
|
|
252
|
+
|
|
253
|
+
def check_message(self, operation: Operation, msg: Incoming) -> None:
|
|
254
|
+
if operation is Operation.LISTEN and msg.is_notification:
|
|
255
|
+
raise Rejected(Failure(FailureKind.MALFORMED, "subscriptions/listen requires an id"))
|
|
256
|
+
|
|
257
|
+
def check_params(self, params: Params) -> None:
|
|
258
|
+
"""Context is required on every request; there are no handshake defaults."""
|
|
259
|
+
if params.meta.protocol_version != self.version:
|
|
260
|
+
raise Rejected(
|
|
261
|
+
Failure(FailureKind.MALFORMED, "missing or incorrect protocol version in _meta")
|
|
262
|
+
)
|
|
263
|
+
if params.meta.client_capabilities is None:
|
|
264
|
+
raise Rejected(
|
|
265
|
+
Failure(FailureKind.MALFORMED, "missing client capabilities in request _meta")
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def client_info_for(self, params: Params) -> ClientInfo:
|
|
269
|
+
info = params.meta.client_info
|
|
270
|
+
return ClientInfo(
|
|
271
|
+
name=info.name if info is not None else None,
|
|
272
|
+
version=info.version if info is not None else None,
|
|
273
|
+
capabilities=params.meta.client_capabilities or {},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def answers_for(self, params: Params) -> Mapping[str, Any]:
|
|
277
|
+
return unwrap_answers(params.input_responses)
|
|
278
|
+
|
|
279
|
+
def operation_for(self, method: str) -> Operation | None:
|
|
280
|
+
return METHODS.get(method)
|
|
281
|
+
|
|
282
|
+
def method_for(self, operation: Operation) -> str | None:
|
|
283
|
+
return METHOD_NAMES.get(operation)
|
|
284
|
+
|
|
285
|
+
def encode_value(self, call: Call, registry: RegistryProtocol, result: Any) -> dict[str, Any]:
|
|
286
|
+
meta = getattr(result, "meta", None) or Meta()
|
|
287
|
+
updates: dict[str, Any] = {"meta": meta.model_copy(update={"server_info": registry.info})}
|
|
288
|
+
if getattr(result, "result_type", "absent") is None:
|
|
289
|
+
updates["result_type"] = "complete"
|
|
290
|
+
if getattr(result, "ttl_ms", "absent") is None:
|
|
291
|
+
ttl_ms, cache_scope = 0, "private"
|
|
292
|
+
if isinstance(result, ReadResourceResult):
|
|
293
|
+
spec = registry.match_resource(call.target or "")
|
|
294
|
+
if spec is not None:
|
|
295
|
+
if spec[0].cache_ttl_ms is not None:
|
|
296
|
+
ttl_ms = spec[0].cache_ttl_ms
|
|
297
|
+
if spec[0].cache_scope is not None:
|
|
298
|
+
cache_scope = spec[0].cache_scope
|
|
299
|
+
updates["ttl_ms"] = ttl_ms
|
|
300
|
+
updates["cache_scope"] = cache_scope
|
|
301
|
+
result = result.model_copy(update=updates)
|
|
302
|
+
return {"jsonrpc": "2.0", "id": call.id, "result": result.wire()}
|
|
303
|
+
|
|
304
|
+
def encode_input_required(
|
|
305
|
+
self, call: Call, registry: RegistryProtocol, out: NeedsInput
|
|
306
|
+
) -> dict[str, Any]:
|
|
307
|
+
if out.state is not None and not isinstance(out.state, str):
|
|
308
|
+
raise TypeError("requestState must be sealed before wire encoding")
|
|
309
|
+
result = InputRequiredResult(
|
|
310
|
+
input_requests=dict(out.requests),
|
|
311
|
+
request_state=out.state,
|
|
312
|
+
meta=Meta(server_info=registry.info),
|
|
313
|
+
)
|
|
314
|
+
return {"jsonrpc": "2.0", "id": call.id, "result": result.wire()}
|
|
315
|
+
|
|
316
|
+
def describe_server(self, registry: RegistryProtocol, call: Call) -> DiscoverResult:
|
|
317
|
+
return DiscoverResult(
|
|
318
|
+
supported_versions=list(self.supported_versions),
|
|
319
|
+
capabilities=self.capabilities(registry),
|
|
320
|
+
instructions=registry.instructions,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def describe_tool(self, spec: ToolSpec) -> ToolDef | None:
|
|
324
|
+
if spec.min_revision is not None and spec.min_revision > self.version:
|
|
325
|
+
return None
|
|
326
|
+
return spec.definition(spec.input_schema, spec.output_schema)
|
|
327
|
+
|
|
328
|
+
def client_headers(
|
|
329
|
+
self,
|
|
330
|
+
method: str,
|
|
331
|
+
name: str | None = None,
|
|
332
|
+
params: Mapping[str, Any] | None = None,
|
|
333
|
+
tool: ToolDef | None = None,
|
|
334
|
+
) -> Mapping[str, str]:
|
|
335
|
+
headers = {"MCP-Protocol-Version": self.version, "Mcp-Method": method}
|
|
336
|
+
if name is not None and method in NAME_HEADER_METHODS:
|
|
337
|
+
headers["Mcp-Name"] = encode_header(name)
|
|
338
|
+
if method == "tools/call" and params is not None and tool is not None:
|
|
339
|
+
arguments = params.get("arguments", {})
|
|
340
|
+
if isinstance(arguments, Mapping):
|
|
341
|
+
for path, suffix in header_params(tool.input_schema):
|
|
342
|
+
value = path_value(arguments, path)
|
|
343
|
+
if value is not MISSING and value is not None:
|
|
344
|
+
headers[f"Mcp-Param-{suffix}"] = encode_header(value)
|
|
345
|
+
return headers
|
|
346
|
+
|
|
347
|
+
def client_handshake_params(self, client: ClientProfile) -> Params:
|
|
348
|
+
return Params()
|
|
349
|
+
|
|
350
|
+
def client_decorate_params(self, params: Params, client: ClientProfile) -> Params:
|
|
351
|
+
# No handshake or session: send client context on every request.
|
|
352
|
+
params.meta.protocol_version = self.version
|
|
353
|
+
params.meta.client_info = client.info
|
|
354
|
+
params.meta.client_capabilities = dict(client.capabilities)
|
|
355
|
+
params.meta.log_level = client.log_level
|
|
356
|
+
return params
|
|
357
|
+
|
|
358
|
+
def client_input_requests(
|
|
359
|
+
self, result: Mapping[str, Any]
|
|
360
|
+
) -> tuple[Mapping[str, InputRequest], Any] | None:
|
|
361
|
+
if result.get("resultType") != "input_required":
|
|
362
|
+
return None
|
|
363
|
+
return result.get("inputRequests") or {}, result.get("requestState")
|
|
File without changes
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Redis `SessionStore` and `Hub` backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .hub import START, Cursor, Hub
|
|
10
|
+
from .sessions import SessionRecord, SessionStore
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from redis.asyncio import Redis
|
|
14
|
+
except ImportError as absent: # pragma: no cover - depends on what is installed
|
|
15
|
+
raise ImportError(
|
|
16
|
+
'aiohttp_tiny_mcp.redis needs redis. Install "aiohttp-tiny-mcp[redis]".'
|
|
17
|
+
) from absent
|
|
18
|
+
|
|
19
|
+
BEGINNING = "0-0"
|
|
20
|
+
|
|
21
|
+
CREATE = """
|
|
22
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
|
|
23
|
+
redis.call('HSET', KEYS[1], 'data', ARGV[1], 'version', 1)
|
|
24
|
+
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
|
25
|
+
return 1
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
SAVE = """
|
|
29
|
+
local version = redis.call('HGET', KEYS[1], 'version')
|
|
30
|
+
if not version or tonumber(version) ~= tonumber(ARGV[2]) then return 0 end
|
|
31
|
+
redis.call('HSET', KEYS[1], 'data', ARGV[1], 'version', ARGV[3])
|
|
32
|
+
redis.call('EXPIRE', KEYS[1], ARGV[4])
|
|
33
|
+
return 1
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def text(value: Any) -> str:
|
|
38
|
+
"""Redis answers in bytes, or in strings where the client decodes them."""
|
|
39
|
+
return value.decode() if isinstance(value, bytes) else str(value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RedisStorage:
|
|
43
|
+
"""A Redis client shared by the store and hub."""
|
|
44
|
+
|
|
45
|
+
prefix: str = "mcp"
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
client: Redis,
|
|
50
|
+
*,
|
|
51
|
+
prefix: str | None = None,
|
|
52
|
+
owned: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.client = client
|
|
55
|
+
self.owned = owned
|
|
56
|
+
if prefix is not None:
|
|
57
|
+
self.prefix = prefix
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_url(cls, url: str, **settings: Any) -> RedisStorage:
|
|
61
|
+
"""Build an owned Redis client from a URL."""
|
|
62
|
+
return cls(Redis.from_url(url), owned=True, **settings)
|
|
63
|
+
|
|
64
|
+
async def open(self) -> Redis:
|
|
65
|
+
return self.client
|
|
66
|
+
|
|
67
|
+
async def close(self) -> None:
|
|
68
|
+
"""Close an owned client."""
|
|
69
|
+
if self.owned:
|
|
70
|
+
await self.client.aclose()
|
|
71
|
+
|
|
72
|
+
async def cleanup_ctx(self, app: Any) -> AsyncIterator[None]:
|
|
73
|
+
"""Manage an owned client for an aiohttp app."""
|
|
74
|
+
await self.open()
|
|
75
|
+
try:
|
|
76
|
+
yield
|
|
77
|
+
finally:
|
|
78
|
+
await self.close()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RedisSessionStore(SessionStore):
|
|
82
|
+
"""`SessionStore` on one key per session, with version-checked writes."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, storage: RedisStorage, *, prefix: str | None = None) -> None:
|
|
85
|
+
self.storage = storage
|
|
86
|
+
self.prefix = prefix if prefix is not None else storage.prefix
|
|
87
|
+
self.scripts: dict[str, Any] = {}
|
|
88
|
+
|
|
89
|
+
async def script(self, source: str) -> Any:
|
|
90
|
+
"""Register a script once per client, and keep it by its source."""
|
|
91
|
+
if source not in self.scripts:
|
|
92
|
+
client = await self.storage.open()
|
|
93
|
+
self.scripts[source] = client.register_script(source)
|
|
94
|
+
return self.scripts[source]
|
|
95
|
+
|
|
96
|
+
def key(self, session_id: str) -> str:
|
|
97
|
+
return f"{self.prefix}:session:{session_id}"
|
|
98
|
+
|
|
99
|
+
async def create(self, session_id: str, data: Mapping[str, Any], *, ttl_seconds: int) -> bool:
|
|
100
|
+
create = await self.script(CREATE)
|
|
101
|
+
written: Any = await create(
|
|
102
|
+
keys=[self.key(session_id)], args=[json.dumps(data), ttl_seconds]
|
|
103
|
+
)
|
|
104
|
+
return bool(written)
|
|
105
|
+
|
|
106
|
+
async def get(self, session_id: str) -> SessionRecord | None:
|
|
107
|
+
client = await self.storage.open()
|
|
108
|
+
held: Any = await client.hmget(self.key(session_id), ["data", "version"])
|
|
109
|
+
data, version = held
|
|
110
|
+
if data is None or version is None:
|
|
111
|
+
return None
|
|
112
|
+
return SessionRecord(data=json.loads(data), version=int(version))
|
|
113
|
+
|
|
114
|
+
async def save(
|
|
115
|
+
self,
|
|
116
|
+
session_id: str,
|
|
117
|
+
data: Mapping[str, Any],
|
|
118
|
+
*,
|
|
119
|
+
expected_version: int,
|
|
120
|
+
ttl_seconds: int,
|
|
121
|
+
) -> bool:
|
|
122
|
+
save = await self.script(SAVE)
|
|
123
|
+
written: Any = await save(
|
|
124
|
+
keys=[self.key(session_id)],
|
|
125
|
+
args=[json.dumps(data), expected_version, expected_version + 1, ttl_seconds],
|
|
126
|
+
)
|
|
127
|
+
return bool(written)
|
|
128
|
+
|
|
129
|
+
async def delete(self, session_id: str) -> None:
|
|
130
|
+
client = await self.storage.open()
|
|
131
|
+
await client.delete(self.key(session_id))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class RedisHub(Hub):
|
|
135
|
+
"""A Redis stream per topic."""
|
|
136
|
+
|
|
137
|
+
keep: int = 1000
|
|
138
|
+
|
|
139
|
+
ttl_seconds: int = 3600
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
storage: RedisStorage,
|
|
144
|
+
*,
|
|
145
|
+
prefix: str | None = None,
|
|
146
|
+
keep: int | None = None,
|
|
147
|
+
ttl_seconds: int | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
self.storage = storage
|
|
150
|
+
self.prefix = prefix if prefix is not None else storage.prefix
|
|
151
|
+
if keep is not None:
|
|
152
|
+
self.keep = keep
|
|
153
|
+
if ttl_seconds is not None:
|
|
154
|
+
self.ttl_seconds = ttl_seconds
|
|
155
|
+
|
|
156
|
+
def key(self, topic: str) -> str:
|
|
157
|
+
return f"{self.prefix}:topic:{topic}"
|
|
158
|
+
|
|
159
|
+
async def publish(self, topic: str, message: Mapping[str, Any]) -> None:
|
|
160
|
+
client = await self.storage.open()
|
|
161
|
+
key = self.key(topic)
|
|
162
|
+
await client.xadd(key, {"message": json.dumps(message)}, maxlen=self.keep, approximate=True)
|
|
163
|
+
await client.expire(key, self.ttl_seconds)
|
|
164
|
+
|
|
165
|
+
async def position(self, topic: str) -> Cursor:
|
|
166
|
+
client = await self.storage.open()
|
|
167
|
+
last: Any = await client.xrevrange(self.key(topic), count=1)
|
|
168
|
+
return text(last[0][0]) if last else START
|
|
169
|
+
|
|
170
|
+
async def poll(
|
|
171
|
+
self, topic: str, cursor: Cursor, *, timeout: float
|
|
172
|
+
) -> tuple[Sequence[Mapping[str, Any]], Cursor]:
|
|
173
|
+
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
|
+
)
|
|
177
|
+
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])
|
|
182
|
+
|
|
183
|
+
async def delete(self, topic: str) -> None:
|
|
184
|
+
client = await self.storage.open()
|
|
185
|
+
await client.delete(self.key(topic))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
__all__ = [
|
|
189
|
+
"BEGINNING",
|
|
190
|
+
"CREATE",
|
|
191
|
+
"SAVE",
|
|
192
|
+
"RedisHub",
|
|
193
|
+
"RedisSessionStore",
|
|
194
|
+
"RedisStorage",
|
|
195
|
+
]
|