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,531 @@
1
+ """Streamable HTTP bound to an aiohttp router (docs/reference/runtime.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from collections.abc import Mapping
9
+ from contextlib import suppress
10
+ from dataclasses import replace
11
+ from functools import cached_property
12
+ from typing import Any
13
+
14
+ from aiohttp import web
15
+
16
+ from .adapter import Adapter
17
+ from .auth import Authorization, Unauthorized
18
+ from .core import (
19
+ Call,
20
+ DecodeFailure,
21
+ Failure,
22
+ FailureKind,
23
+ Operation,
24
+ Outcome,
25
+ Preamble,
26
+ Rejected,
27
+ Value,
28
+ )
29
+ from .dispatcher import Dispatcher
30
+ from .exchange import Exchange, is_reply, relay_reply
31
+ from .hub import NOTIFICATIONS, topic
32
+ from .namespaces import current, namespace, scoped
33
+ from .protocol.selection import AdapterSet
34
+ from .registry import Registry
35
+ from .sessions import (
36
+ SESSION_HEADER,
37
+ Session,
38
+ SessionRecord,
39
+ handshake_data,
40
+ new_session_id,
41
+ stored_capabilities,
42
+ stored_log_level,
43
+ stored_owner,
44
+ stored_version,
45
+ )
46
+ from .sse import SSEResponse
47
+ from .subscriptions import relays, wanted
48
+
49
+ log = logging.getLogger("aiohttp_tiny_mcp")
50
+
51
+ MAY_ASK = frozenset({Operation.CALL_TOOL, Operation.GET_PROMPT, Operation.READ_RESOURCE})
52
+
53
+
54
+ class Endpoint:
55
+ """The MCP endpoint, in the spec's sense: one path that accepts POST.
56
+
57
+ Mount it however you mount anything else in aiohttp::
58
+
59
+ app.add_routes(ep.routes("/mcp"))
60
+ app.router.add_view("/mcp", ep.view)
61
+ ep.setup(app, "/mcp") # the same routes, added for you
62
+
63
+ Under `add_subapp` the metadata route needs the root application, because
64
+ a prefix must not reach a well-known path::
65
+
66
+ section.add_routes(ep.routes("/mcp", metadata=False))
67
+ app.add_subapp("/api/", section)
68
+ app.add_routes(ep.metadata_routes())
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ registry: Registry,
74
+ *,
75
+ adapters: AdapterSet | None = None,
76
+ allowed_origins: set[str] | None = None,
77
+ trust_proxy_origin_validation: bool = False,
78
+ compress: bool = True,
79
+ ) -> None:
80
+ self.registry = registry
81
+ self.adapters = adapters or AdapterSet.default()
82
+ self.dispatcher = Dispatcher(registry)
83
+ self.allowed_origins = allowed_origins
84
+ self.trust_proxy_origin_validation = trust_proxy_origin_validation
85
+ self.compress = compress
86
+
87
+ @cached_property
88
+ def view(self) -> type[web.View]:
89
+ """GET opens the legacy notification stream; POST handles requests.
90
+
91
+ 2026-07-28 uses subscriptions/listen instead of GET. DELETE returns 405; sessions end by
92
+ expiration.
93
+ """
94
+ endpoint = self
95
+
96
+ class MCPView(web.View):
97
+ async def post(self) -> web.StreamResponse:
98
+ return await endpoint.handle(self.request)
99
+
100
+ async def get(self) -> web.StreamResponse:
101
+ return await endpoint.notifications(self.request)
102
+
103
+ return MCPView
104
+
105
+ def routes(
106
+ self, path: str = "/mcp", *, name: str | None = "mcp", metadata: bool = True
107
+ ) -> list[web.RouteDef]:
108
+ """The endpoint, and where a client looks to find out how to reach it.
109
+
110
+ The metadata route is included whenever tokens are verified, because a
111
+ client that has no token learns where to get one from there and
112
+ nowhere else. Pass `metadata=False` where this application cannot
113
+ serve that path -- see `metadata_routes`.
114
+ """
115
+ log.debug("MCP endpoint at %s, named %r", path, name)
116
+ found = [web.route("*", path, self.view, name=name)]
117
+ if metadata:
118
+ found.extend(self.metadata_routes(name=name))
119
+ return found
120
+
121
+ def metadata_routes(self, *, name: str | None = "mcp") -> list[web.RouteDef]:
122
+ """RFC 9728 metadata, for the application that owns the site root.
123
+
124
+ Empty where nothing verifies tokens. The path comes from the resource
125
+ URL, and RFC 8615 puts a well-known URI directly under the authority,
126
+ so a prefix must not reach it: an endpoint mounted with `add_subapp`
127
+ takes `routes(metadata=False)` and leaves these to the root
128
+ application.
129
+ """
130
+ auth = self.registry.auth
131
+ if auth is None:
132
+ log.debug("no resource metadata route: nothing verifies tokens")
133
+ return []
134
+ log.debug("resource metadata at %s, for resource %s", auth.metadata_path, auth.resource)
135
+ return [
136
+ web.get(
137
+ auth.metadata_path,
138
+ self.metadata,
139
+ name=f"{name}-resource-metadata" if name else None,
140
+ )
141
+ ]
142
+
143
+ async def metadata(self, request: web.Request) -> web.Response:
144
+ """RFC 9728: what this resource is and who issues tokens for it."""
145
+ auth = self.registry.auth
146
+ assert auth is not None, "the metadata route is only added with auth"
147
+ return web.json_response(auth.metadata(), headers={"Cache-Control": "public, max-age=3600"})
148
+
149
+ async def verified(self, request: web.Request) -> Any:
150
+ """Who is calling, or `None` where nothing verifies tokens.
151
+
152
+ Raises `Unauthorized`, which the caller turns into the refusal a
153
+ client can act on. The namespace is set from what was verified, so
154
+ every key this request touches is separated by an identity somebody
155
+ checked rather than by a header the caller chose. An application that
156
+ set its own namespace first keeps it.
157
+ """
158
+ auth = self.registry.auth
159
+ if auth is None:
160
+ return None
161
+ principal = await auth.principal(request.headers.get("Authorization"))
162
+ if auth.namespace_from_token and current() is None:
163
+ namespace.set(principal.identity)
164
+ return principal
165
+
166
+ def refuse(self, auth: Authorization, refusal: Unauthorized) -> web.Response:
167
+ return web.json_response(
168
+ {"error": refusal.error, "error_description": refusal.description},
169
+ status=refusal.status,
170
+ headers={"WWW-Authenticate": auth.challenge(refusal)},
171
+ )
172
+
173
+ def setup(
174
+ self, app: web.Application, path: str = "/mcp", *, name: str | None = "mcp"
175
+ ) -> web.Application:
176
+ log.debug("adding the MCP routes to %r", app)
177
+ app[MCP_ENDPOINT] = self
178
+ app.add_routes(self.routes(path, name=name))
179
+ return app
180
+
181
+ def app(self, path: str = "/mcp", **kw: Any) -> web.Application:
182
+ return self.setup(web.Application(**kw), path)
183
+
184
+ def check_origin(self, request: web.Request) -> None:
185
+ origin = request.headers.get("Origin")
186
+ if not origin or self.trust_proxy_origin_validation:
187
+ return
188
+ if origin == self.own_origin(request):
189
+ return
190
+ if self.allowed_origins is None or origin not in self.allowed_origins:
191
+ raise Rejected(Failure(FailureKind.ORIGIN_REJECTED, "origin not allowed"))
192
+
193
+ def own_origin(self, request: web.Request) -> str:
194
+ """Origin of pages served by this endpoint. A rebound page retains the attacker's origin
195
+ and fails this comparison.
196
+
197
+ TLS-terminating proxies require trust_proxy_origin_validation to account for the
198
+ external scheme.
199
+ """
200
+ host = request.headers.get("Host")
201
+ return f"{request.scheme}://{host}" if host else ""
202
+
203
+ def accepts(self, request: web.Request, media_type: str) -> bool:
204
+ wanted_type, wanted_subtype = media_type.lower().split("/", 1)
205
+ for value in request.headers.get("Accept", "*/*").split(","):
206
+ media_range, *parameters = value.split(";")
207
+ try:
208
+ quality = next(
209
+ (
210
+ float(parameter.split("=", 1)[1])
211
+ for parameter in parameters
212
+ if parameter.strip().lower().startswith("q=")
213
+ ),
214
+ 1.0,
215
+ )
216
+ except (ValueError, IndexError):
217
+ quality = 0.0
218
+ if quality <= 0:
219
+ continue
220
+ try:
221
+ accepted_type, accepted_subtype = media_range.strip().lower().split("/", 1)
222
+ except ValueError:
223
+ continue
224
+ if accepted_type in {"*", wanted_type} and accepted_subtype in {
225
+ "*",
226
+ wanted_subtype,
227
+ }:
228
+ return True
229
+ return False
230
+
231
+ def stream_reason(self, adapter: Adapter, call: Call) -> str | None:
232
+ """Return the SSE requirement used in a 406 response, or None for JSON."""
233
+ if call.is_notification:
234
+ return None
235
+ if call.operation is Operation.LISTEN:
236
+ return "subscriptions/listen"
237
+ if call.operation is Operation.CALL_TOOL:
238
+ spec = self.registry.tools.get(call.target or "")
239
+ if spec is not None and spec.streaming:
240
+ return "streaming tool"
241
+ if (
242
+ call.operation in MAY_ASK
243
+ and adapter.can_push_ask
244
+ and call.client.capabilities.get("elicitation") is not None
245
+ ):
246
+ return "a question this revision would have to push"
247
+ return None
248
+
249
+ async def handle(self, request: web.Request) -> web.StreamResponse:
250
+ try:
251
+ self.check_origin(request)
252
+ except Rejected as e:
253
+ return self.render_failure(self.adapters.fallback(), e.failure)
254
+
255
+ if request.content_type.lower() != "application/json":
256
+ return web.Response(status=415, text="MCP requests require application/json")
257
+
258
+ try:
259
+ principal = await self.verified(request)
260
+ except Unauthorized as refusal:
261
+ assert self.registry.auth is not None
262
+ return self.refuse(self.registry.auth, refusal)
263
+
264
+ raw = await request.read()
265
+ pre = Preamble.of(raw, request.headers, request.query)
266
+
267
+ if is_reply(pre.body):
268
+ await relay_reply(self.registry.hub, pre.body)
269
+ return web.Response(status=202)
270
+
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
+ try:
276
+ adapter = self.adapters.select(pre, stored_version(session))
277
+ except Rejected as e:
278
+ return self.render_failure(self.adapters.fallback(), e.failure)
279
+
280
+ log.debug("<- [%s] %s", adapter.version, raw.decode("utf-8", "replace"))
281
+
282
+ try:
283
+ items = adapter.decode(pre)
284
+ adapter.check_http(pre, request.headers, self.registry)
285
+ except Rejected as e:
286
+ return self.render_failure(adapter, e.failure)
287
+
288
+ if session is not None:
289
+ remembered = stored_capabilities(session)
290
+ level = stored_log_level(session)
291
+ for item in items:
292
+ if isinstance(item, Call):
293
+ item.client = replace(
294
+ item.client,
295
+ capabilities={**remembered, **item.client.capabilities},
296
+ )
297
+ if item.log_level is None:
298
+ item.log_level = level
299
+
300
+ if len(items) == 1 and isinstance(items[0], Call):
301
+ call = items[0]
302
+ streamed = self.stream_reason(adapter, call)
303
+ if streamed is not None:
304
+ if not self.accepts(request, "text/event-stream"):
305
+ return web.Response(status=406, text=f"{streamed} requires SSE")
306
+ streaming = Exchange(self.registry, request, adapter, call, held)
307
+ streaming.principal = principal
308
+ return await self.stream(request, streaming)
309
+
310
+ will_reply = any(
311
+ item.must_respond if isinstance(item, DecodeFailure) else not item.is_notification
312
+ for item in items
313
+ )
314
+ if will_reply and not self.accepts(request, "application/json"):
315
+ return web.Response(status=406, text="client does not accept application/json")
316
+
317
+ replies: list[tuple[int, Mapping[str, Any]]] = []
318
+ minted: str | None = None
319
+ for item in items:
320
+ if isinstance(item, DecodeFailure):
321
+ if item.must_respond:
322
+ replies.append(
323
+ (
324
+ adapter.http_status(item.failure),
325
+ adapter.encode_failure(item.id, item.failure),
326
+ )
327
+ )
328
+ continue
329
+ ex = Exchange(self.registry, request, adapter, item, held)
330
+ ex.principal = principal
331
+ outcome = await self.dispatcher.run(ex)
332
+ if item.operation is Operation.DESCRIBE and session is None:
333
+ owner = principal.identity if principal is not None else None
334
+ minted = await self.open_session(adapter, item, outcome, owner)
335
+ if item.is_notification:
336
+ continue
337
+ replies.append(self.encode(adapter, item, outcome))
338
+
339
+ if not replies:
340
+ return web.Response(status=202)
341
+ if pre.is_batch:
342
+ return web.Response(
343
+ status=200,
344
+ content_type="application/json",
345
+ text=json.dumps([p for _, p in replies], ensure_ascii=False),
346
+ )
347
+ status, payload = replies[0]
348
+ response = web.Response(
349
+ status=status,
350
+ content_type="application/json",
351
+ text=json.dumps(payload, ensure_ascii=False),
352
+ )
353
+ if minted is not None:
354
+ response.headers[SESSION_HEADER] = minted
355
+ return response
356
+
357
+ def stream_adapter(self, request: web.Request, session: SessionRecord | None) -> Adapter:
358
+ """Select a GET stream revision from the session or protocol header."""
359
+ version = stored_version(session) or request.headers.get("MCP-Protocol-Version")
360
+ return self.adapters.resolve_version(version) if version else self.adapters.fallback()
361
+
362
+ async def notifications(self, request: web.Request) -> web.StreamResponse:
363
+ """Serve legacy notifications, re-reading subscriptions to include changes from other
364
+ nodes.
365
+ """
366
+ try:
367
+ self.check_origin(request)
368
+ except Rejected as e:
369
+ return self.render_failure(self.adapters.fallback(), e.failure)
370
+ if not self.accepts(request, "text/event-stream"):
371
+ return web.Response(status=406, text="this stream is text/event-stream")
372
+
373
+ try:
374
+ principal = await self.verified(request)
375
+ except Unauthorized as refusal:
376
+ assert self.registry.auth is not None
377
+ return self.refuse(self.registry.auth, refusal)
378
+
379
+ record = await self.load_session(request)
380
+ if not self.owns(record, principal):
381
+ record = None
382
+ adapter = self.stream_adapter(request, record)
383
+ if not adapter.has_handshake:
384
+ return web.Response(
385
+ status=405,
386
+ headers={"Allow": "POST"},
387
+ text=f"{adapter.version} reads notifications with subscriptions/listen",
388
+ )
389
+
390
+ response = SSEResponse(compress=self.compress)
391
+ await response.prepare(request)
392
+ relay = asyncio.create_task(self.relay_notifications(request, response, adapter))
393
+ try:
394
+ while not relay.done():
395
+ await asyncio.wait({relay}, timeout=0.05)
396
+ transport = request.transport
397
+ if transport is None or transport.is_closing():
398
+ break
399
+ finally:
400
+ relay.cancel()
401
+ with suppress(asyncio.CancelledError, ConnectionError):
402
+ await relay
403
+ return response
404
+
405
+ async def relay_notifications(
406
+ self, request: web.Request, response: SSEResponse, adapter: Adapter
407
+ ) -> None:
408
+ """Relay changes, re-reading subscriptions each pass so updates reach an open stream."""
409
+ capabilities = adapter.capabilities(self.registry)
410
+ hub = self.registry.hub
411
+ where = topic(NOTIFICATIONS)
412
+ cursor = await hub.position(where)
413
+ while True:
414
+ messages, cursor = await hub.poll(where, cursor, timeout=self.registry.hub_poll_seconds)
415
+ if not messages:
416
+ continue
417
+ accepted = wanted(
418
+ capabilities, self.open_values(request, await self.load_session(request))
419
+ )
420
+ for payload in messages:
421
+ if relays(payload, accepted):
422
+ text = json.dumps(payload, ensure_ascii=False)
423
+ log.debug("-> [%s] %s", adapter.version, text)
424
+ await response.send(text)
425
+
426
+ def owns(self, record: SessionRecord | None, principal: Any) -> bool:
427
+ """Whether this caller may use this session.
428
+
429
+ A session id travels in a header, so a copied one is a credential.
430
+ The owner is compared through the store rather than through anything
431
+ held in this process, which is what lets a session opened on one
432
+ worker be used on another.
433
+ """
434
+ auth = self.registry.auth
435
+ if record is None or auth is None or not auth.bind_sessions:
436
+ return True
437
+ owner = stored_owner(record)
438
+ if owner is None:
439
+ return True
440
+ return principal is not None and principal.identity == owner
441
+
442
+ async def load_session(self, request: web.Request) -> SessionRecord | None:
443
+ """Load a legacy handshake, or None if no session exists or it has expired."""
444
+ session_id = request.headers.get(SESSION_HEADER)
445
+ if not session_id:
446
+ return None
447
+ return await self.registry.session_store.get(scoped(session_id))
448
+
449
+ def open_values(self, request: web.Request, record: SessionRecord | None) -> Session | None:
450
+ """The application-owned half of this request's session, if any."""
451
+ session_id = request.headers.get(SESSION_HEADER)
452
+ if record is None or not session_id:
453
+ return None
454
+ return Session(
455
+ self.registry.session_store, session_id, record, self.registry.session_ttl_seconds
456
+ )
457
+
458
+ async def open_session(
459
+ self, adapter: Adapter, call: Call, outcome: Outcome, owner: str | None = None
460
+ ) -> str | None:
461
+ """Persist a successful legacy handshake when a store is configured."""
462
+ store = self.registry.session_store
463
+ if not adapter.has_handshake or not isinstance(outcome, Value):
464
+ return None
465
+ negotiated = getattr(outcome.result, "protocol_version", None)
466
+ if not isinstance(negotiated, str):
467
+ return None
468
+ session_id = new_session_id()
469
+ created = await store.create(
470
+ scoped(session_id),
471
+ handshake_data(negotiated, call.client.capabilities, owner),
472
+ ttl_seconds=self.registry.session_ttl_seconds,
473
+ )
474
+ return session_id if created else None
475
+
476
+ def encode(
477
+ self, adapter: Adapter, call: Call, outcome: Outcome
478
+ ) -> tuple[int, Mapping[str, Any]]:
479
+ status = adapter.http_status(outcome) if isinstance(outcome, Failure) else 200
480
+ payload = adapter.encode(call, self.registry, outcome)
481
+ log.debug("-> [%s] %s %s", adapter.version, status, json.dumps(payload, ensure_ascii=False))
482
+ return status, payload
483
+
484
+ def render_failure(self, adapter: Adapter, failure: Failure) -> web.Response:
485
+ payload = adapter.encode_failure(None, failure)
486
+ text = json.dumps(payload, ensure_ascii=False)
487
+ log.debug("-> [%s] %s %s", adapter.version, adapter.http_status(failure), text)
488
+ return web.Response(
489
+ status=adapter.http_status(failure), content_type="application/json", text=text
490
+ )
491
+
492
+ async def stream(self, request: web.Request, ex: Exchange) -> web.StreamResponse:
493
+ """Stream request-scoped notifications and the final result."""
494
+ sse = ex.open(compress=self.compress)
495
+ await sse.prepare(request)
496
+ outcome = await self.run_until_disconnect(
497
+ request, ex, asyncio.create_task(self.dispatcher.run(ex))
498
+ )
499
+ if outcome is None:
500
+ return sse
501
+ try:
502
+ _, payload = self.encode(ex.adapter, ex.call, outcome)
503
+ await ex.emit(payload)
504
+ await sse.write_eof()
505
+ except (ConnectionResetError, ConnectionError):
506
+ pass
507
+ return sse
508
+
509
+ async def run_until_disconnect(
510
+ self, request: web.Request, ex: Exchange, task: asyncio.Task[Outcome]
511
+ ) -> Outcome | None:
512
+ """Cancel and join request work on disconnect or parent cancellation."""
513
+ try:
514
+ while not task.done():
515
+ await asyncio.wait({task}, timeout=0.05)
516
+ transport = request.transport
517
+ if transport is None or transport.is_closing():
518
+ ex.cancel()
519
+ return None
520
+ return task.result()
521
+ except asyncio.CancelledError:
522
+ ex.cancel()
523
+ raise
524
+ finally:
525
+ if not task.done():
526
+ task.cancel()
527
+ with suppress(asyncio.CancelledError):
528
+ await task
529
+
530
+
531
+ MCP_ENDPOINT = web.AppKey("mcp_endpoint", Endpoint)