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.
Files changed (41) hide show
  1. aiohttp_tiny_mcp/__init__.py +76 -0
  2. aiohttp_tiny_mcp/adapter.py +321 -0
  3. aiohttp_tiny_mcp/auth.py +129 -0
  4. aiohttp_tiny_mcp/client.py +111 -0
  5. aiohttp_tiny_mcp/client_base.py +295 -0
  6. aiohttp_tiny_mcp/console/__init__.py +98 -0
  7. aiohttp_tiny_mcp/console/console.css +525 -0
  8. aiohttp_tiny_mcp/console/console.js +1273 -0
  9. aiohttp_tiny_mcp/console/index.html +100 -0
  10. aiohttp_tiny_mcp/core.py +327 -0
  11. aiohttp_tiny_mcp/dispatcher.py +309 -0
  12. aiohttp_tiny_mcp/endpoint.py +531 -0
  13. aiohttp_tiny_mcp/exchange.py +279 -0
  14. aiohttp_tiny_mcp/http_sse.py +267 -0
  15. aiohttp_tiny_mcp/hub.py +109 -0
  16. aiohttp_tiny_mcp/models.py +346 -0
  17. aiohttp_tiny_mcp/namespaces.py +36 -0
  18. aiohttp_tiny_mcp/postgres.py +454 -0
  19. aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  20. aiohttp_tiny_mcp/protocol/selection.py +92 -0
  21. aiohttp_tiny_mcp/protocol/v2024_11_05.py +30 -0
  22. aiohttp_tiny_mcp/protocol/v2025_03_26.py +165 -0
  23. aiohttp_tiny_mcp/protocol/v2025_06_18.py +11 -0
  24. aiohttp_tiny_mcp/protocol/v2025_11_25.py +164 -0
  25. aiohttp_tiny_mcp/protocol/v2026_07_28.py +363 -0
  26. aiohttp_tiny_mcp/py.typed +0 -0
  27. aiohttp_tiny_mcp/redis.py +195 -0
  28. aiohttp_tiny_mcp/registry.py +162 -0
  29. aiohttp_tiny_mcp/request_state.py +107 -0
  30. aiohttp_tiny_mcp/schema.py +131 -0
  31. aiohttp_tiny_mcp/sessions.py +347 -0
  32. aiohttp_tiny_mcp/specs.py +268 -0
  33. aiohttp_tiny_mcp/sqlite.py +236 -0
  34. aiohttp_tiny_mcp/sse.py +260 -0
  35. aiohttp_tiny_mcp/stdio.py +187 -0
  36. aiohttp_tiny_mcp/stdio_client.py +91 -0
  37. aiohttp_tiny_mcp/subscriptions.py +124 -0
  38. aiohttp_tiny_mcp/testing.py +200 -0
  39. aiohttp_tiny_mcp-0.1.0.dist-info/METADATA +12 -0
  40. aiohttp_tiny_mcp-0.1.0.dist-info/RECORD +41 -0
  41. 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)