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,454 @@
|
|
|
1
|
+
"""PostgreSQL `SessionStore` and `Hub` backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
9
|
+
from functools import cached_property
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .hub import START, Cursor, Hub
|
|
13
|
+
from .sessions import SessionRecord, SessionStore
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from psycopg import sql
|
|
17
|
+
from psycopg_pool import AsyncConnectionPool
|
|
18
|
+
except ImportError as absent: # pragma: no cover - depends on what is installed
|
|
19
|
+
raise ImportError(
|
|
20
|
+
'aiohttp_tiny_mcp.postgres needs psycopg. Install "aiohttp-tiny-mcp[postgres]".'
|
|
21
|
+
) from absent
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("aiohttp_tiny_mcp")
|
|
24
|
+
|
|
25
|
+
#: The key the sweep records itself under, in the state table.
|
|
26
|
+
SWEPT = "swept"
|
|
27
|
+
|
|
28
|
+
SCHEMA = """
|
|
29
|
+
CREATE TABLE IF NOT EXISTS {sessions} (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
data JSONB NOT NULL,
|
|
32
|
+
version BIGINT NOT NULL DEFAULT 1,
|
|
33
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
34
|
+
);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS {sessions_expiry} ON {sessions} (expires_at);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS {events} (
|
|
38
|
+
id BIGSERIAL PRIMARY KEY,
|
|
39
|
+
topic TEXT NOT NULL,
|
|
40
|
+
message JSONB NOT NULL,
|
|
41
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
42
|
+
);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS {events_topic} ON {events} (topic, id);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS {state} (
|
|
46
|
+
key TEXT PRIMARY KEY,
|
|
47
|
+
value JSONB NOT NULL,
|
|
48
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
49
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
50
|
+
);
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PostgresStorage:
|
|
55
|
+
"""A pool, schema, and deployment maintenance state."""
|
|
56
|
+
|
|
57
|
+
prefix: str = "mcp"
|
|
58
|
+
|
|
59
|
+
event_ttl_seconds: int = 3600
|
|
60
|
+
|
|
61
|
+
sweep_seconds: float = 60.0
|
|
62
|
+
|
|
63
|
+
create_tables: bool = True
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
pool: AsyncConnectionPool,
|
|
68
|
+
*,
|
|
69
|
+
prefix: str | None = None,
|
|
70
|
+
event_ttl_seconds: int | None = None,
|
|
71
|
+
sweep_seconds: float | None = None,
|
|
72
|
+
create_tables: bool | None = None,
|
|
73
|
+
owned: bool = False,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.pool = pool
|
|
76
|
+
self.owned = owned
|
|
77
|
+
if prefix is not None:
|
|
78
|
+
self.prefix = prefix
|
|
79
|
+
if event_ttl_seconds is not None:
|
|
80
|
+
self.event_ttl_seconds = event_ttl_seconds
|
|
81
|
+
if sweep_seconds is not None:
|
|
82
|
+
self.sweep_seconds = sweep_seconds
|
|
83
|
+
if create_tables is not None:
|
|
84
|
+
self.create_tables = create_tables
|
|
85
|
+
self.opening = asyncio.Lock()
|
|
86
|
+
self.prepared = False
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_url(
|
|
90
|
+
cls,
|
|
91
|
+
conninfo: str,
|
|
92
|
+
*,
|
|
93
|
+
min_size: int = 4,
|
|
94
|
+
max_size: int | None = None,
|
|
95
|
+
**settings: Any,
|
|
96
|
+
) -> PostgresStorage:
|
|
97
|
+
"""Build an owned pool from a connection string."""
|
|
98
|
+
return cls(
|
|
99
|
+
AsyncConnectionPool(conninfo, open=False, min_size=min_size, max_size=max_size),
|
|
100
|
+
owned=True,
|
|
101
|
+
**settings,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def sessions_table(self) -> sql.Identifier:
|
|
106
|
+
return sql.Identifier(f"{self.prefix}_sessions")
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def events_table(self) -> sql.Identifier:
|
|
110
|
+
return sql.Identifier(f"{self.prefix}_events")
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def state_table(self) -> sql.Identifier:
|
|
114
|
+
"""Whatever this deployment has to remember between runs, by key."""
|
|
115
|
+
return sql.Identifier(f"{self.prefix}_state")
|
|
116
|
+
|
|
117
|
+
async def open(self) -> AsyncConnectionPool:
|
|
118
|
+
"""Open the pool and create the tables, once."""
|
|
119
|
+
async with self.opening:
|
|
120
|
+
if self.owned and self.pool.closed:
|
|
121
|
+
await self.pool.open()
|
|
122
|
+
if not self.prepared and self.create_tables:
|
|
123
|
+
async with self.pool.connection() as connection:
|
|
124
|
+
await connection.execute(
|
|
125
|
+
sql.SQL(SCHEMA).format(
|
|
126
|
+
sessions=self.sessions_table,
|
|
127
|
+
events=self.events_table,
|
|
128
|
+
sessions_expiry=sql.Identifier(f"{self.prefix}_sessions_expiry"),
|
|
129
|
+
events_topic=sql.Identifier(f"{self.prefix}_events_topic"),
|
|
130
|
+
state=self.state_table,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
if not self.prepared:
|
|
134
|
+
self.prepared = True
|
|
135
|
+
log.debug("MCP state in the tables named %s_*", self.prefix)
|
|
136
|
+
return self.pool
|
|
137
|
+
|
|
138
|
+
async def close(self) -> None:
|
|
139
|
+
if self.owned:
|
|
140
|
+
await self.pool.close()
|
|
141
|
+
self.prepared = False
|
|
142
|
+
|
|
143
|
+
async def sweep(self) -> None:
|
|
144
|
+
"""Remove expired rows now. Use `sweep_if_due` on multiple workers."""
|
|
145
|
+
pool = await self.open()
|
|
146
|
+
async with pool.connection() as connection:
|
|
147
|
+
await self.delete_expired(connection)
|
|
148
|
+
|
|
149
|
+
async def delete_expired(self, connection: Any) -> None:
|
|
150
|
+
await connection.execute(
|
|
151
|
+
sql.Composed(
|
|
152
|
+
[
|
|
153
|
+
sql.SQL("DELETE FROM "),
|
|
154
|
+
self.sessions_table,
|
|
155
|
+
sql.SQL(" WHERE expires_at <= now()"),
|
|
156
|
+
]
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
await connection.execute(
|
|
160
|
+
sql.Composed(
|
|
161
|
+
[
|
|
162
|
+
sql.SQL("DELETE FROM "),
|
|
163
|
+
self.events_table,
|
|
164
|
+
sql.SQL(" WHERE created_at <= now() - make_interval(secs => %s)"),
|
|
165
|
+
]
|
|
166
|
+
),
|
|
167
|
+
(self.event_ttl_seconds,),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def sweep_if_due(self, *, every: float | None = None) -> bool:
|
|
171
|
+
"""Sweep once per interval across all workers; return whether this one did."""
|
|
172
|
+
every = every if every is not None else self.sweep_seconds
|
|
173
|
+
if not await self.due(every):
|
|
174
|
+
return False
|
|
175
|
+
pool = await self.open()
|
|
176
|
+
async with pool.connection() as connection:
|
|
177
|
+
async with connection.transaction():
|
|
178
|
+
taken = await connection.execute(
|
|
179
|
+
"SELECT pg_try_advisory_xact_lock(hashtext(%s))", (f"{self.prefix}_sweep",)
|
|
180
|
+
)
|
|
181
|
+
row = await taken.fetchone()
|
|
182
|
+
if row is None or not row[0]:
|
|
183
|
+
return False
|
|
184
|
+
if not await self.due(every, connection):
|
|
185
|
+
return False
|
|
186
|
+
await self.delete_expired(connection)
|
|
187
|
+
await self.remember(SWEPT, {"seconds": every}, connection)
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
async def due(self, every: float, connection: Any = None) -> bool:
|
|
191
|
+
"""Whether the last sweep was longer than `every` seconds ago."""
|
|
192
|
+
if connection is None:
|
|
193
|
+
pool = await self.open()
|
|
194
|
+
async with pool.connection() as held:
|
|
195
|
+
return await self.due(every, held)
|
|
196
|
+
found = await connection.execute(
|
|
197
|
+
sql.Composed(
|
|
198
|
+
[
|
|
199
|
+
sql.SQL("SELECT updated_at <= now() - make_interval(secs => %s) FROM "),
|
|
200
|
+
self.state_table,
|
|
201
|
+
sql.SQL(" WHERE key = %s"),
|
|
202
|
+
]
|
|
203
|
+
),
|
|
204
|
+
(every, SWEPT),
|
|
205
|
+
)
|
|
206
|
+
row = await found.fetchone()
|
|
207
|
+
return True if row is None else bool(row[0])
|
|
208
|
+
|
|
209
|
+
async def remember(self, key: str, value: Any, connection: Any = None) -> None:
|
|
210
|
+
"""Store one value under `key`, stamping `updated_at`."""
|
|
211
|
+
if connection is None:
|
|
212
|
+
pool = await self.open()
|
|
213
|
+
async with pool.connection() as held:
|
|
214
|
+
await self.remember(key, value, held)
|
|
215
|
+
return
|
|
216
|
+
await connection.execute(
|
|
217
|
+
sql.Composed(
|
|
218
|
+
[
|
|
219
|
+
sql.SQL("INSERT INTO "),
|
|
220
|
+
self.state_table,
|
|
221
|
+
sql.SQL("""
|
|
222
|
+
(key, value) VALUES (%s, %s)
|
|
223
|
+
ON CONFLICT (key) DO UPDATE
|
|
224
|
+
SET value = excluded.value, updated_at = now()
|
|
225
|
+
"""),
|
|
226
|
+
]
|
|
227
|
+
),
|
|
228
|
+
(key, json.dumps(value)),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
async def recall(self, key: str) -> Any | None:
|
|
232
|
+
"""What was stored under `key`, or None."""
|
|
233
|
+
pool = await self.open()
|
|
234
|
+
async with pool.connection() as connection:
|
|
235
|
+
found = await connection.execute(
|
|
236
|
+
sql.Composed(
|
|
237
|
+
[sql.SQL("SELECT value FROM "), self.state_table, sql.SQL(" WHERE key = %s")]
|
|
238
|
+
),
|
|
239
|
+
(key,),
|
|
240
|
+
)
|
|
241
|
+
row = await found.fetchone()
|
|
242
|
+
return None if row is None else row[0]
|
|
243
|
+
|
|
244
|
+
async def sweeping(self, *, every: float | None = None) -> None:
|
|
245
|
+
"""Sweep repeatedly; safe to run on every worker."""
|
|
246
|
+
every = every if every is not None else self.sweep_seconds
|
|
247
|
+
while True:
|
|
248
|
+
await asyncio.sleep(every)
|
|
249
|
+
try:
|
|
250
|
+
await self.sweep_if_due(every=every)
|
|
251
|
+
except Exception: # noqa: BLE001 - a sweep must not end the loop
|
|
252
|
+
log.exception("sweeping failed; trying again in %ss", every)
|
|
253
|
+
|
|
254
|
+
async def cleanup_ctx(self, app: Any) -> AsyncIterator[None]:
|
|
255
|
+
"""Open and close the pool for an aiohttp application's lifetime."""
|
|
256
|
+
await self.open()
|
|
257
|
+
try:
|
|
258
|
+
yield
|
|
259
|
+
finally:
|
|
260
|
+
await self.close()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class PostgresSessionStore(SessionStore):
|
|
264
|
+
"""`SessionStore` with version-checked writes, timed by the database."""
|
|
265
|
+
|
|
266
|
+
def __init__(self, storage: PostgresStorage) -> None:
|
|
267
|
+
self.storage = storage
|
|
268
|
+
|
|
269
|
+
@cached_property
|
|
270
|
+
def _query_create(self) -> sql.Composed:
|
|
271
|
+
return sql.SQL("""
|
|
272
|
+
INSERT INTO {table} (id, data, version, expires_at)
|
|
273
|
+
VALUES (%s, %s, 1, now() + make_interval(secs => %s))
|
|
274
|
+
ON CONFLICT (id) DO UPDATE
|
|
275
|
+
SET data = excluded.data, version = 1, expires_at = excluded.expires_at
|
|
276
|
+
WHERE {table}.expires_at <= now()
|
|
277
|
+
""").format(table=self.storage.sessions_table)
|
|
278
|
+
|
|
279
|
+
@cached_property
|
|
280
|
+
def _query_get(self) -> sql.Composed:
|
|
281
|
+
return sql.Composed(
|
|
282
|
+
[
|
|
283
|
+
sql.SQL("SELECT data, version FROM "),
|
|
284
|
+
self.storage.sessions_table,
|
|
285
|
+
sql.SQL(" WHERE id = %s AND expires_at > now()"),
|
|
286
|
+
]
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
@cached_property
|
|
290
|
+
def _query_save(self) -> sql.Composed:
|
|
291
|
+
return sql.Composed(
|
|
292
|
+
[
|
|
293
|
+
sql.SQL("UPDATE "),
|
|
294
|
+
self.storage.sessions_table,
|
|
295
|
+
sql.SQL("""
|
|
296
|
+
SET data = %s,
|
|
297
|
+
version = version + 1,
|
|
298
|
+
expires_at = now() + make_interval(secs => %s)
|
|
299
|
+
WHERE id = %s AND version = %s AND expires_at > now()
|
|
300
|
+
"""),
|
|
301
|
+
]
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
@cached_property
|
|
305
|
+
def _query_delete(self) -> sql.Composed:
|
|
306
|
+
return sql.Composed(
|
|
307
|
+
[
|
|
308
|
+
sql.SQL("DELETE FROM "),
|
|
309
|
+
self.storage.sessions_table,
|
|
310
|
+
sql.SQL(" WHERE id = %s"),
|
|
311
|
+
]
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
async def create(self, session_id: str, data: Mapping[str, Any], *, ttl_seconds: int) -> bool:
|
|
315
|
+
pool = await self.storage.open()
|
|
316
|
+
async with pool.connection() as connection:
|
|
317
|
+
found = await connection.execute(
|
|
318
|
+
self._query_create, (session_id, json.dumps(data), ttl_seconds)
|
|
319
|
+
)
|
|
320
|
+
return found.rowcount > 0
|
|
321
|
+
|
|
322
|
+
async def get(self, session_id: str) -> SessionRecord | None:
|
|
323
|
+
pool = await self.storage.open()
|
|
324
|
+
async with pool.connection() as connection:
|
|
325
|
+
found = await connection.execute(self._query_get, (session_id,))
|
|
326
|
+
row = await found.fetchone()
|
|
327
|
+
if row is None:
|
|
328
|
+
return None
|
|
329
|
+
return SessionRecord(data=row[0], version=row[1])
|
|
330
|
+
|
|
331
|
+
async def save(
|
|
332
|
+
self,
|
|
333
|
+
session_id: str,
|
|
334
|
+
data: Mapping[str, Any],
|
|
335
|
+
*,
|
|
336
|
+
expected_version: int,
|
|
337
|
+
ttl_seconds: int,
|
|
338
|
+
) -> bool:
|
|
339
|
+
pool = await self.storage.open()
|
|
340
|
+
async with pool.connection() as connection:
|
|
341
|
+
found = await connection.execute(
|
|
342
|
+
self._query_save, (json.dumps(data), ttl_seconds, session_id, expected_version)
|
|
343
|
+
)
|
|
344
|
+
return found.rowcount > 0
|
|
345
|
+
|
|
346
|
+
async def delete(self, session_id: str) -> None:
|
|
347
|
+
pool = await self.storage.open()
|
|
348
|
+
async with pool.connection() as connection:
|
|
349
|
+
await connection.execute(self._query_delete, (session_id,))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class PostgresHub(Hub):
|
|
353
|
+
"""A cursor-based `Hub` backed by one PostgreSQL table."""
|
|
354
|
+
|
|
355
|
+
look_again: float = 0.25
|
|
356
|
+
|
|
357
|
+
def __init__(self, storage: PostgresStorage, *, look_again: float | None = None) -> None:
|
|
358
|
+
self.storage = storage
|
|
359
|
+
if look_again is not None:
|
|
360
|
+
self.look_again = look_again
|
|
361
|
+
|
|
362
|
+
@cached_property
|
|
363
|
+
def _query_publish(self) -> sql.Composed:
|
|
364
|
+
return sql.Composed(
|
|
365
|
+
[
|
|
366
|
+
sql.SQL("INSERT INTO "),
|
|
367
|
+
self.storage.events_table,
|
|
368
|
+
sql.SQL(" (topic, message) VALUES (%s, %s)"),
|
|
369
|
+
]
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
@cached_property
|
|
373
|
+
def _query_position(self) -> sql.Composed:
|
|
374
|
+
return sql.Composed(
|
|
375
|
+
[
|
|
376
|
+
sql.SQL("SELECT max(id) FROM "),
|
|
377
|
+
self.storage.events_table,
|
|
378
|
+
sql.SQL(" WHERE topic = %s"),
|
|
379
|
+
]
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
@cached_property
|
|
383
|
+
def _query_after(self) -> sql.Composed:
|
|
384
|
+
return sql.Composed(
|
|
385
|
+
[
|
|
386
|
+
sql.SQL("SELECT id, message FROM "),
|
|
387
|
+
self.storage.events_table,
|
|
388
|
+
sql.SQL(" WHERE topic = %s AND id > %s ORDER BY id"),
|
|
389
|
+
]
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
@cached_property
|
|
393
|
+
def _query_delete(self) -> sql.Composed:
|
|
394
|
+
return sql.Composed(
|
|
395
|
+
[
|
|
396
|
+
sql.SQL("DELETE FROM "),
|
|
397
|
+
self.storage.events_table,
|
|
398
|
+
sql.SQL(" WHERE topic = %s"),
|
|
399
|
+
]
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
async def publish(self, topic: str, message: Mapping[str, Any]) -> None:
|
|
403
|
+
pool = await self.storage.open()
|
|
404
|
+
async with pool.connection() as connection:
|
|
405
|
+
async with connection.transaction():
|
|
406
|
+
await connection.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (topic,))
|
|
407
|
+
await connection.execute(self._query_publish, (topic, json.dumps(message)))
|
|
408
|
+
|
|
409
|
+
async def position(self, topic: str) -> Cursor:
|
|
410
|
+
pool = await self.storage.open()
|
|
411
|
+
async with pool.connection() as connection:
|
|
412
|
+
found = await connection.execute(self._query_position, (topic,))
|
|
413
|
+
row = await found.fetchone()
|
|
414
|
+
return str(row[0]) if row is not None and row[0] is not None else START
|
|
415
|
+
|
|
416
|
+
async def after(self, topic: str, cursor: Cursor) -> tuple[list[Mapping[str, Any]], Cursor]:
|
|
417
|
+
"""Everything published after `cursor`, and where to continue from."""
|
|
418
|
+
pool = await self.storage.open()
|
|
419
|
+
async with pool.connection() as connection:
|
|
420
|
+
found = await connection.execute(
|
|
421
|
+
self._query_after, (topic, int(cursor) if cursor else 0)
|
|
422
|
+
)
|
|
423
|
+
rows = await found.fetchall()
|
|
424
|
+
if not rows:
|
|
425
|
+
return [], cursor
|
|
426
|
+
return [row[1] for row in rows], str(rows[-1][0])
|
|
427
|
+
|
|
428
|
+
async def poll(
|
|
429
|
+
self, topic: str, cursor: Cursor, *, timeout: float
|
|
430
|
+
) -> tuple[Sequence[Mapping[str, Any]], Cursor]:
|
|
431
|
+
loop = asyncio.get_running_loop()
|
|
432
|
+
deadline = loop.time() + timeout
|
|
433
|
+
while True:
|
|
434
|
+
messages, cursor = await self.after(topic, cursor)
|
|
435
|
+
if messages:
|
|
436
|
+
return messages, cursor
|
|
437
|
+
left = deadline - loop.time()
|
|
438
|
+
if left <= 0:
|
|
439
|
+
return [], cursor
|
|
440
|
+
await asyncio.sleep(min(self.look_again, left))
|
|
441
|
+
|
|
442
|
+
async def delete(self, topic: str) -> None:
|
|
443
|
+
pool = await self.storage.open()
|
|
444
|
+
async with pool.connection() as connection:
|
|
445
|
+
await connection.execute(self._query_delete, (topic,))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
__all__ = [
|
|
449
|
+
"SCHEMA",
|
|
450
|
+
"SWEPT",
|
|
451
|
+
"PostgresHub",
|
|
452
|
+
"PostgresSessionStore",
|
|
453
|
+
"PostgresStorage",
|
|
454
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""AdapterSet: selects exactly one Adapter per request (docs/reference/selection.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
from aiohttp_tiny_mcp.adapter import Adapter
|
|
9
|
+
from aiohttp_tiny_mcp.core import Failure, FailureKind, Preamble, Rejected
|
|
10
|
+
|
|
11
|
+
from .v2024_11_05 import Adapter2024_11_05
|
|
12
|
+
from .v2025_03_26 import Adapter2025_03_26
|
|
13
|
+
from .v2025_06_18 import Adapter2025_06_18
|
|
14
|
+
from .v2025_11_25 import Adapter2025_11_25
|
|
15
|
+
from .v2026_07_28 import Adapter2026_07_28
|
|
16
|
+
|
|
17
|
+
ASSUMED_VERSION = "2025-03-26"
|
|
18
|
+
|
|
19
|
+
STABLE_REVISION = "2025-11-25"
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T", bound="AdapterSet")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AdapterSet:
|
|
25
|
+
def __init__(self, adapters: Sequence[Adapter], *, fallback: Adapter | None = None) -> None:
|
|
26
|
+
self.adapters = tuple(adapters)
|
|
27
|
+
self.by_version: dict[str, Adapter] = {}
|
|
28
|
+
for adapter in self.adapters:
|
|
29
|
+
self.by_version[adapter.version] = adapter
|
|
30
|
+
for old in adapter.supersedes:
|
|
31
|
+
self.by_version[old] = adapter
|
|
32
|
+
self.fallback_adapter = fallback or self.adapters[0]
|
|
33
|
+
for adapter in self.adapters:
|
|
34
|
+
adapter.bind(self.versions)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def default(cls: type[T]) -> T:
|
|
38
|
+
return cls(
|
|
39
|
+
[
|
|
40
|
+
Adapter2026_07_28(),
|
|
41
|
+
Adapter2025_11_25(),
|
|
42
|
+
Adapter2025_06_18(),
|
|
43
|
+
Adapter2025_03_26(),
|
|
44
|
+
Adapter2024_11_05(),
|
|
45
|
+
]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def versions(self) -> tuple[str, ...]:
|
|
50
|
+
return tuple(sorted({a.version for a in self.adapters}, reverse=True))
|
|
51
|
+
|
|
52
|
+
def select(self, pre: Preamble, session_version: str | None = None) -> Adapter:
|
|
53
|
+
if pre.parse_error:
|
|
54
|
+
return self.fallback()
|
|
55
|
+
if pre.meta_version is not None and not isinstance(pre.meta_version, str):
|
|
56
|
+
raise Rejected(
|
|
57
|
+
Failure(FailureKind.MALFORMED, "protocol version in _meta must be a string")
|
|
58
|
+
)
|
|
59
|
+
if pre.query_version is not None:
|
|
60
|
+
return self.resolve_version(pre.query_version)
|
|
61
|
+
if (
|
|
62
|
+
pre.header_version is not None
|
|
63
|
+
and pre.meta_version is not None
|
|
64
|
+
and pre.header_version != pre.meta_version
|
|
65
|
+
):
|
|
66
|
+
raise Rejected(
|
|
67
|
+
Failure(FailureKind.HEADER_MISMATCH, "MCP-Protocol-Version does not match _meta")
|
|
68
|
+
)
|
|
69
|
+
if pre.meta_version is not None:
|
|
70
|
+
return self.resolve_version(pre.meta_version)
|
|
71
|
+
if pre.header_version is not None:
|
|
72
|
+
return self.resolve_version(pre.header_version)
|
|
73
|
+
if pre.method == "initialize":
|
|
74
|
+
return self.by_version.get(STABLE_REVISION, self.fallback())
|
|
75
|
+
if session_version is not None:
|
|
76
|
+
return self.resolve_version(session_version)
|
|
77
|
+
return self.by_version.get(ASSUMED_VERSION, self.fallback())
|
|
78
|
+
|
|
79
|
+
def resolve_version(self, version: str) -> Adapter:
|
|
80
|
+
adapter = self.by_version.get(version)
|
|
81
|
+
if adapter is None:
|
|
82
|
+
raise Rejected(
|
|
83
|
+
Failure(
|
|
84
|
+
FailureKind.UNSUPPORTED_VERSION,
|
|
85
|
+
f"unsupported protocol version: {version}",
|
|
86
|
+
data={"supported": list(self.versions), "requested": version},
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return adapter
|
|
90
|
+
|
|
91
|
+
def fallback(self) -> Adapter:
|
|
92
|
+
return self.fallback_adapter
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The 2024-11-05 adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from aiohttp_tiny_mcp.core import Call
|
|
8
|
+
from aiohttp_tiny_mcp.models import AudioContent, CallToolResult, ToolDef
|
|
9
|
+
from aiohttp_tiny_mcp.specs import ToolSpec
|
|
10
|
+
|
|
11
|
+
from .v2025_03_26 import Adapter2025_03_26
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Adapter2024_11_05(Adapter2025_03_26): # noqa: N801 -- revision date, greppable against the spec
|
|
15
|
+
version: ClassVar[str] = "2024-11-05"
|
|
16
|
+
allows_batch: ClassVar[bool] = False
|
|
17
|
+
progress_message: ClassVar[bool] = False
|
|
18
|
+
|
|
19
|
+
def describe_tool(self, spec: ToolSpec) -> ToolDef | None:
|
|
20
|
+
definition = super().describe_tool(spec)
|
|
21
|
+
if definition is None:
|
|
22
|
+
return None
|
|
23
|
+
return definition.model_copy(update={"annotations": None})
|
|
24
|
+
|
|
25
|
+
def encode_value(self, call: Call, registry: Any, result: Any) -> dict[str, Any]:
|
|
26
|
+
if isinstance(result, CallToolResult):
|
|
27
|
+
kept = [block for block in result.content if not isinstance(block, AudioContent)]
|
|
28
|
+
if len(kept) != len(result.content):
|
|
29
|
+
result = result.model_copy(update={"content": kept})
|
|
30
|
+
return super().encode_value(call, registry, result)
|