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,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)
|