toolframe-core 0.6.2__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 (46) hide show
  1. toolframe_core/__init__.py +3 -0
  2. toolframe_core/_state_postgres.py +420 -0
  3. toolframe_core/_state_sqlite.py +339 -0
  4. toolframe_core/access.py +513 -0
  5. toolframe_core/api.py +700 -0
  6. toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  7. toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
  8. toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
  9. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
  10. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
  11. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
  12. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
  13. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
  14. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
  15. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
  16. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
  17. toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
  18. toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
  19. toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
  20. toolframe_core/cli.py +681 -0
  21. toolframe_core/config.py +267 -0
  22. toolframe_core/diagnostics.py +200 -0
  23. toolframe_core/graph.py +207 -0
  24. toolframe_core/host.py +2610 -0
  25. toolframe_core/maintenance.py +868 -0
  26. toolframe_core/mcp_access.py +259 -0
  27. toolframe_core/network.py +97 -0
  28. toolframe_core/oidc_access.py +187 -0
  29. toolframe_core/packages.py +728 -0
  30. toolframe_core/process.py +212 -0
  31. toolframe_core/redaction.py +120 -0
  32. toolframe_core/scaffold.py +142 -0
  33. toolframe_core/skills.py +210 -0
  34. toolframe_core/state.py +226 -0
  35. toolframe_core/state_admin.py +270 -0
  36. toolframe_core/stores.py +88 -0
  37. toolframe_core/templates.json +6 -0
  38. toolframe_core/templates.py +56 -0
  39. toolframe_core-0.6.2.dist-info/METADATA +465 -0
  40. toolframe_core-0.6.2.dist-info/RECORD +46 -0
  41. toolframe_core-0.6.2.dist-info/WHEEL +4 -0
  42. toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
  43. toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
  44. toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
  45. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  46. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
toolframe_core/api.py ADDED
@@ -0,0 +1,700 @@
1
+ """Stable management API and generation-gated HTTP/WebSocket entrance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hmac
7
+ import json
8
+ import time
9
+ from contextlib import asynccontextmanager, suppress
10
+ from http.cookies import SimpleCookie
11
+ from urllib.parse import parse_qs, urlsplit
12
+
13
+ import httpx
14
+ from fastapi import FastAPI, Request, WebSocket
15
+ from fastapi.responses import (
16
+ HTMLResponse,
17
+ JSONResponse,
18
+ RedirectResponse,
19
+ Response,
20
+ StreamingResponse,
21
+ )
22
+ from pydantic import BaseModel, ConfigDict, Field
23
+ from starlette.background import BackgroundTask
24
+ from starlette.requests import ClientDisconnect
25
+ from toolframe_sdk import PluginError, Principal
26
+ from toolframe_sdk.tracing import TracingMiddleware
27
+ from websockets.asyncio.client import connect
28
+ from websockets.exceptions import ConnectionClosed
29
+
30
+ from .access import CONTEXT_HEADER, PASSWORD_COOKIE, operation_scopes
31
+ from .network import trusted
32
+ from .oidc_access import BROWSER_COOKIE, PUBLIC_PATHS, cross_site_route, install_routes
33
+ from .redaction import redact_toml
34
+
35
+ COOKIE = "toolframe_session"
36
+ HOP_HEADERS = {
37
+ "connection",
38
+ "keep-alive",
39
+ "proxy-authenticate",
40
+ "proxy-authorization",
41
+ "te",
42
+ "trailer",
43
+ "transfer-encoding",
44
+ "upgrade",
45
+ "host",
46
+ "content-length",
47
+ }
48
+
49
+
50
+ def provider_cookies(connection):
51
+ cookies = SimpleCookie()
52
+ for name, value in connection.cookies.items():
53
+ if name not in {COOKIE, PASSWORD_COOKIE, BROWSER_COOKIE}:
54
+ cookies[name] = value
55
+ return cookies.output(header="", sep=";").strip()
56
+
57
+
58
+ class OperationRequest(BaseModel):
59
+ model_config = ConfigDict(extra="forbid")
60
+ action: str
61
+ params: dict = Field(default_factory=dict)
62
+
63
+
64
+ def error_status(error):
65
+ code = error.code
66
+ if code in {
67
+ "stale_plan",
68
+ "stale_revision",
69
+ "idempotency_conflict",
70
+ "already_installed",
71
+ "mutation_conflict",
72
+ }:
73
+ return 409
74
+ if code in {"plugin_missing", "operation_missing", "candidate_missing"}:
75
+ return 404
76
+ if code == "forbidden":
77
+ return 403
78
+ if code in {"authentication_required", "invalid_credentials", "session_invalid"}:
79
+ return 401
80
+ if code == "rate_limited":
81
+ return 429
82
+ if code in {
83
+ "invalid_request",
84
+ "action_unknown",
85
+ "configuration_invalid",
86
+ "config_invalid",
87
+ }:
88
+ return 422
89
+ if code in {
90
+ "host_closing",
91
+ "core_state_unavailable",
92
+ "core_state_ownership_lost",
93
+ "plugin_reloading",
94
+ "dependency_unavailable",
95
+ "presentation_unavailable",
96
+ "peer_closed",
97
+ "cleanup_failed",
98
+ "auth_unavailable",
99
+ "auth_transition",
100
+ }:
101
+ return 503
102
+ return 400
103
+
104
+
105
+ def provider_for(host, path):
106
+ kind = (
107
+ "mcp"
108
+ if path == "mcp" or path.startswith("mcp/")
109
+ else "llms"
110
+ if path == "llms.txt" or path.startswith("llms/")
111
+ else "dashboard"
112
+ )
113
+ name = host.graph.providers.get(kind)
114
+ runtime = host.runtimes.get(name)
115
+ if (
116
+ not runtime
117
+ or not host.healthy_runtime(name)
118
+ or not runtime.admitted
119
+ or name in host.gated
120
+ or runtime.epoch != host.epoch
121
+ or host.publication.get(kind, {}).get("state") != "available"
122
+ ):
123
+ raise PluginError("presentation_unavailable", f"{kind} entrance is unavailable")
124
+ address = runtime.health.get("http_url")
125
+ if not address or urlsplit(address).hostname != "127.0.0.1":
126
+ raise PluginError(
127
+ "provider_address_invalid",
128
+ "Provider must report a private loopback HTTP address",
129
+ )
130
+ return kind, runtime, address
131
+
132
+
133
+ def create_app(host, *, manage_lifespan=True):
134
+ @asynccontextmanager
135
+ async def lifespan(app):
136
+ try:
137
+ if manage_lifespan:
138
+ await host.start(wait=False)
139
+ yield
140
+ finally:
141
+ if manage_lifespan:
142
+ await host.close()
143
+
144
+ app = FastAPI(
145
+ title="Toolframe core",
146
+ lifespan=lifespan,
147
+ docs_url=None,
148
+ redoc_url=None,
149
+ openapi_url=None,
150
+ )
151
+ @app.middleware("http")
152
+ async def application_operator(request, call_next):
153
+ path = request.url.path
154
+ identity = host.access.identity(request)
155
+ if not trusted(
156
+ request.headers,
157
+ host.listen_host,
158
+ public_url=host.composition.application.public_url,
159
+ cross_site=cross_site_route(path, request.method),
160
+ navigation=request.method == "GET"
161
+ and path in {"/", "/_toolframe/auth/login"}
162
+ and not identity.local,
163
+ ):
164
+ return JSONResponse(
165
+ {
166
+ "error": {
167
+ "code": "untrusted_origin",
168
+ "message": "Use the application origin",
169
+ }
170
+ },
171
+ status_code=403,
172
+ )
173
+ request.state.identity = identity
174
+ try:
175
+ response = await call_next(request)
176
+ if path in PUBLIC_PATHS:
177
+ response.headers["access-control-allow-origin"] = "*"
178
+ response.headers["access-control-allow-methods"] = "GET, POST, OPTIONS"
179
+ response.headers["access-control-allow-headers"] = (
180
+ "Authorization, Content-Type"
181
+ )
182
+ response.headers["cache-control"] = "no-store"
183
+ if path.startswith("/_toolframe/"):
184
+ response.headers["cache-control"] = "no-store"
185
+ response.headers["referrer-policy"] = "no-referrer"
186
+ return response
187
+ except PluginError as error:
188
+ return JSONResponse(
189
+ {"error": host.redact(error.as_dict())}, status_code=error_status(error)
190
+ )
191
+
192
+ @app.exception_handler(PluginError)
193
+ async def plugin_error(request, error):
194
+ return JSONResponse(
195
+ {"error": host.redact(error.as_dict())}, status_code=error_status(error)
196
+ )
197
+
198
+ async def require(request, action, params=None, *, activity=False):
199
+ return await host.access.admit(
200
+ request.state.identity,
201
+ operation_scopes(action, params),
202
+ effective=False,
203
+ activity=activity,
204
+ )
205
+
206
+ async def bounded_body(request):
207
+ body = bytearray()
208
+ async for chunk in request.stream():
209
+ body.extend(chunk)
210
+ if len(body) > 16384:
211
+ raise PluginError("invalid_request", "Auth request exceeds 16 KiB")
212
+ try:
213
+ if request.headers.get("content-type", "").startswith("application/json"):
214
+ result = json.loads(body)
215
+ else:
216
+ result = {k: v[0] for k, v in parse_qs(body.decode()).items()}
217
+ if not isinstance(result, dict):
218
+ raise TypeError()
219
+ return result
220
+ except ValueError, UnicodeError, TypeError:
221
+ raise PluginError("invalid_request", "Supply an object or form") from None
222
+
223
+ @app.get("/healthz")
224
+ async def health():
225
+ return {"core": "ready", "host_run": host.host_run}
226
+
227
+ install_routes(app, host)
228
+
229
+ @app.get("/_toolframe/auth/context")
230
+ async def access_context(request: Request):
231
+ return await host.access.presentation(request.state.identity)
232
+
233
+ @app.api_route("/_toolframe/auth/login", methods=["GET", "POST"])
234
+ async def password_login(request: Request):
235
+ if request.method == "POST":
236
+ data = await bounded_body(request)
237
+ result = await host.access.login(
238
+ data.get("password"),
239
+ request.client.host if request.client else "unknown",
240
+ )
241
+ response = (
242
+ JSONResponse({"status": "signed_in"})
243
+ if "application/json" in request.headers.get("accept", "")
244
+ else RedirectResponse("/", status_code=303)
245
+ )
246
+ response.set_cookie(
247
+ PASSWORD_COOKIE,
248
+ result["token"],
249
+ httponly=True,
250
+ samesite="strict",
251
+ secure=host.composition.application.public_url.startswith("https://")
252
+ or request.url.scheme == "https",
253
+ max_age=max(1, int(result["expires_at"] - time.time())),
254
+ )
255
+ return response
256
+ name = host.composition.access.dashboard_auth
257
+ try:
258
+ result = await host.call_service(
259
+ name, "dashboard-auth.presentation", "login", {}
260
+ )
261
+ if not isinstance(result.get("html"), str):
262
+ raise TypeError("Invalid login presentation")
263
+ return HTMLResponse(result["html"])
264
+ except Exception: # noqa: BLE001 - an unavailable login integration selects fallback
265
+ if name not in host.transitioning and not host.starting:
266
+ host.access.mark_failed(name)
267
+ return HTMLResponse(
268
+ '<!doctype html><title>Toolframe</title><p>Login is unavailable.</p><a href="/">Open Dashboard</a>',
269
+ status_code=503,
270
+ )
271
+
272
+ @app.post("/_toolframe/auth/logout")
273
+ async def logout(request: Request):
274
+ token = request.cookies.get(PASSWORD_COOKIE)
275
+ if token:
276
+ with suppress(PluginError):
277
+ await host.access.auth("logout", {"token": token})
278
+ response = RedirectResponse("/", status_code=303)
279
+ response.delete_cookie(PASSWORD_COOKIE)
280
+ response.delete_cookie(COOKIE)
281
+ return response
282
+
283
+ @app.post("/_toolframe/v1/auth/{action}")
284
+ async def auth_operation(action: str, request: Request):
285
+ return await host.access.manage(
286
+ request.state.identity, action, await bounded_body(request)
287
+ )
288
+
289
+ @app.post("/_toolframe/v1/mcp-auth/{action}")
290
+ async def mcp_auth_operation(action: str, request: Request):
291
+ return await host.mcp_access.manage(
292
+ request.state.identity, action, await bounded_body(request)
293
+ )
294
+
295
+ @app.api_route("/_toolframe/session", methods=["GET", "POST"])
296
+ async def session(request: Request):
297
+ token = request.query_params.get("token", "")
298
+ if request.method == "POST":
299
+ data = await request.body()
300
+ if len(data) > 4096:
301
+ return JSONResponse({"error": "Request too large"}, status_code=413)
302
+ try:
303
+ token = parse_qs(data.decode()).get("token", [""])[0]
304
+ except UnicodeError:
305
+ return JSONResponse(
306
+ {"error": "Invalid request encoding"}, status_code=422
307
+ )
308
+ if token:
309
+ if not hmac.compare_digest(token.encode(), host.token.encode()):
310
+ return JSONResponse(
311
+ {"error": "Invalid management token"}, status_code=401
312
+ )
313
+ response = RedirectResponse("/", status_code=303)
314
+ response.set_cookie(
315
+ COOKIE,
316
+ token,
317
+ httponly=True,
318
+ samesite="strict",
319
+ secure=host.composition.application.public_url.startswith("https://")
320
+ or request.url.scheme == "https",
321
+ )
322
+ response.headers["referrer-policy"] = "no-referrer"
323
+ response.headers["cache-control"] = "no-store"
324
+ return response
325
+ return HTMLResponse(
326
+ '<!doctype html><title>Toolframe sign in</title><h1>Toolframe</h1><form method="post">'
327
+ '<label>Management token <input type="password" name="token" required></label>'
328
+ "<button>Open Dashboard</button></form><p>On the server, run <code>toolframe open</code> for a local sign-in link. "
329
+ "For LAN access, enter the token from <code>APP/.toolframe/management.token</code>.</p>"
330
+ )
331
+
332
+ @app.get("/_toolframe/v1/status")
333
+ async def status(request: Request):
334
+ await require(request, "status")
335
+ return await host.status()
336
+
337
+ @app.get("/_toolframe/v1/readiness")
338
+ async def readiness(request: Request):
339
+ await require(request, "readiness")
340
+ report = await host.status()
341
+ return JSONResponse(report, status_code=200 if report["ready"] else 503)
342
+
343
+ @app.get("/_toolframe/v1/composition")
344
+ async def composition(request: Request):
345
+ await require(request, "composition")
346
+ try:
347
+ text = redact_toml(host.config.path.read_text(), host.redact)
348
+ except ValueError as error:
349
+ raise PluginError(
350
+ "configuration_invalid", "Repair invalid TOML before exporting"
351
+ ) from error
352
+ return {"toml": text, "revision": host.config.revision, "secrets": "redacted"}
353
+
354
+ @app.get("/_toolframe/v1/operations")
355
+ async def operations(
356
+ request: Request,
357
+ page: int | None = None,
358
+ page_size: int = 10,
359
+ through: int | None = None,
360
+ target: str | None = None,
361
+ kind: str | None = None,
362
+ status: str | None = None,
363
+ ):
364
+ await require(request, "operations")
365
+ if request.query_params:
366
+ return host.redact(
367
+ await host.state.operation_page(
368
+ {
369
+ "page": page if page is not None else 1,
370
+ "page_size": page_size,
371
+ "through": through,
372
+ "target": target,
373
+ "kind": kind,
374
+ "status": status,
375
+ }
376
+ )
377
+ )
378
+ # Preserve the existing bounded list response for clients without queries.
379
+ return host.redact(await host.state.operations())
380
+
381
+ @app.get("/_toolframe/v1/operations/{identifier}")
382
+ async def operation(identifier: str, request: Request):
383
+ await require(request, "operation")
384
+ return host.redact(await host.state.operation(identifier))
385
+
386
+ @app.post("/_toolframe/v1/operations/{identifier}/cancel")
387
+ async def cancel(identifier: str, request: Request):
388
+ current = await host.state.operation(identifier)
389
+ await require(request, current["kind"], current["request"], activity=True)
390
+ if current["status"] not in ("queued", "running"):
391
+ return {"id": identifier, "requested": False, "status": current["status"]}
392
+ host.cancellations.add(identifier)
393
+ host.changed.set()
394
+ return {"id": identifier, "requested": True, "phase": current["phase"]}
395
+
396
+ @app.post("/_toolframe/v1/operations", status_code=202)
397
+ async def submit(request: Request, body: OperationRequest):
398
+ await require(request, body.action, body.params)
399
+ return host.redact(
400
+ await host.submit(
401
+ body.action,
402
+ body.params,
403
+ idempotency=request.headers.get("idempotency-key"),
404
+ identity=request.state.identity,
405
+ )
406
+ )
407
+
408
+ @app.post("/_toolframe/v1/plugins/{identifier}/{action}", status_code=202)
409
+ async def plugin_operation(
410
+ identifier: str, action: str, request: Request, body: dict
411
+ ):
412
+ await require(request, action, {**body, "id": identifier})
413
+ return host.redact(
414
+ await host.submit(
415
+ action,
416
+ {**body, "id": identifier},
417
+ idempotency=request.headers.get("idempotency-key"),
418
+ identity=request.state.identity,
419
+ )
420
+ )
421
+
422
+ @app.get("/_toolframe/v1/plugins/{identifier}/logs")
423
+ async def logs(identifier: str, request: Request):
424
+ await require(request, "logs")
425
+ runtime = host.runtimes.get(identifier)
426
+ return host.redact(
427
+ {
428
+ "plugin_id": identifier,
429
+ "lines": list(runtime.worker.logs) if runtime else [],
430
+ }
431
+ )
432
+
433
+ @app.post("/_toolframe/v1/services/{owner}/{service}/{method}")
434
+ async def service_call(owner: str, service: str, method: str, request: Request):
435
+ definition = host.graph.definitions.get(owner)
436
+ spec = (
437
+ definition.services.get(service).methods.get(method)
438
+ if definition and service in definition.services
439
+ else None
440
+ )
441
+ if spec is None or spec.audience == "internal":
442
+ raise PluginError("forbidden", "This service is not a visitor-facing API")
443
+ actor = await host.access.admit(
444
+ request.state.identity, spec.required_scopes, activity=True
445
+ )
446
+ return await host.call_service(
447
+ owner, service, method, await request.json(), principal=actor
448
+ )
449
+
450
+ @app.post("/api/{owner}/{service}/{method}")
451
+ async def public_business_call(
452
+ owner: str, service: str, method: str, request: Request
453
+ ):
454
+ definition = host.graph.definitions.get(owner)
455
+ spec = (
456
+ definition.services.get(service).methods.get(method)
457
+ if definition and service in definition.services
458
+ else None
459
+ )
460
+ if spec is None or not spec.public_api or spec.audience != "business":
461
+ raise PluginError("forbidden", "This method has no public API declaration")
462
+ return await host.call_service(
463
+ owner,
464
+ service,
465
+ method,
466
+ await request.json(),
467
+ principal=Principal(kind="public", all_business=True),
468
+ )
469
+
470
+ @app.api_route(
471
+ "/{path:path}",
472
+ methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
473
+ )
474
+ async def gateway(path: str, request: Request):
475
+ if path.startswith("_toolframe/"):
476
+ return JSONResponse({"error": "Unknown management route"}, status_code=404)
477
+ if not path and "dashboard" not in host.graph.providers:
478
+ return JSONResponse(
479
+ {
480
+ "name": host.composition.application.name,
481
+ "core": "ready",
482
+ "management": "/_toolframe/v1/status",
483
+ "dashboard": "not_installed",
484
+ }
485
+ )
486
+ try:
487
+ kind, runtime, address = provider_for(host, path)
488
+ except PluginError as error:
489
+ return JSONResponse(
490
+ {"error": error.as_dict()},
491
+ status_code=503,
492
+ headers={"Retry-After": "1"},
493
+ )
494
+ if kind == "dashboard":
495
+ try:
496
+ await host.access.admit(request.state.identity)
497
+ except PluginError as error:
498
+ if error.code == "authentication_required":
499
+ return RedirectResponse("/_toolframe/auth/login", status_code=303)
500
+ raise
501
+ mcp_reference = None
502
+ if kind == "mcp":
503
+ try:
504
+ mcp_reference = await host.mcp_access.register(runtime, request.headers)
505
+ except PluginError as error:
506
+ status = error_status(error)
507
+ challenge = (
508
+ 'Bearer error="invalid_token"'
509
+ if error.code == "invalid_credentials"
510
+ else "Bearer"
511
+ )
512
+ if (
513
+ host.composition.access.oidc_provider
514
+ and host.composition.application.public_url
515
+ ):
516
+ separator = ", " if challenge != "Bearer" else " "
517
+ challenge += (
518
+ separator
519
+ + 'resource_metadata="'
520
+ + host.composition.application.public_url
521
+ + '/.well-known/oauth-protected-resource/mcp"'
522
+ )
523
+ return JSONResponse(
524
+ {"error": error.as_dict()},
525
+ status_code=status,
526
+ headers={
527
+ "Cache-Control": "no-store",
528
+ **({"WWW-Authenticate": challenge} if status == 401 else {}),
529
+ **({"Retry-After": "1"} if status == 503 else {}),
530
+ },
531
+ )
532
+ headers = {
533
+ key: value
534
+ for key, value in request.headers.items()
535
+ if key.lower() not in HOP_HEADERS
536
+ and key.lower() not in {"authorization", "cookie"}
537
+ and not key.lower().startswith("x-toolframe-")
538
+ }
539
+ if "origin" in headers:
540
+ headers["origin"] = address
541
+ if mcp_reference:
542
+ headers["authorization"] = "Bearer " + mcp_reference
543
+ if cookies := provider_cookies(request):
544
+ headers["cookie"] = cookies
545
+ if host.tracing:
546
+ host.tracing.inject(headers)
547
+ url = f"{address}/{path}"
548
+ if request.url.query:
549
+ url += "?" + request.url.query
550
+ client = httpx.AsyncClient(timeout=None, trust_env=False)
551
+ try:
552
+ upstream_request = client.build_request(
553
+ request.method, url, headers=headers, content=request.stream()
554
+ )
555
+ upstream = await client.send(upstream_request, stream=True)
556
+ except ClientDisconnect:
557
+ host.mcp_access.release(mcp_reference)
558
+ await client.aclose()
559
+ # The caller left before its upload finished; this is not a server error.
560
+ return Response(status_code=499)
561
+ except BaseException:
562
+ host.mcp_access.release(mcp_reference)
563
+ await client.aclose()
564
+ raise
565
+
566
+ async def close():
567
+ host.mcp_access.release(mcp_reference)
568
+ await upstream.aclose()
569
+ await client.aclose()
570
+
571
+ async def stream():
572
+ task = asyncio.current_task()
573
+ host.gateway_sessions.setdefault(runtime.name, set()).add(task)
574
+ try:
575
+ async for chunk in upstream.aiter_raw():
576
+ yield chunk
577
+ finally:
578
+ host.gateway_sessions.get(runtime.name, set()).discard(task)
579
+ await close()
580
+
581
+ response = StreamingResponse(
582
+ stream(), status_code=upstream.status_code, background=BackgroundTask(close)
583
+ )
584
+ response.raw_headers = [
585
+ (key, value)
586
+ for key, value in upstream.headers.raw
587
+ if key.decode().lower() not in HOP_HEADERS
588
+ ]
589
+ return response
590
+
591
+ @app.websocket("/{path:path}")
592
+ async def websocket(path: str, downstream: WebSocket):
593
+ if not trusted(
594
+ downstream.headers,
595
+ host.listen_host,
596
+ public_url=host.composition.application.public_url,
597
+ ) or path.startswith("_toolframe/"):
598
+ await downstream.close(code=1008)
599
+ return
600
+ try:
601
+ kind, runtime, address = provider_for(host, path)
602
+ except PluginError:
603
+ await downstream.close(code=1013)
604
+ return
605
+ if kind != "dashboard":
606
+ await downstream.close(code=1008)
607
+ return
608
+ identity = host.access.identity(downstream)
609
+ try:
610
+ initial_actor = await host.access.admit(identity)
611
+ reference = host.access.register(identity)
612
+ except PluginError:
613
+ await downstream.close(code=1008)
614
+ return
615
+ initial_mode = await host.access.mode()
616
+ url = address.replace("http://", "ws://", 1) + "/" + path
617
+ if downstream.url.query:
618
+ url += "?" + downstream.url.query
619
+ protocols = downstream.scope.get("subprotocols", [])
620
+ headers = {
621
+ key: value
622
+ for key, value in downstream.headers.items()
623
+ if key.lower() not in HOP_HEADERS
624
+ and not key.lower().startswith("sec-websocket-")
625
+ and key.lower() not in ("origin", "authorization", "cookie")
626
+ and not key.lower().startswith("x-toolframe-")
627
+ }
628
+ headers[CONTEXT_HEADER] = reference
629
+ if cookies := provider_cookies(downstream):
630
+ headers["cookie"] = cookies
631
+ task = asyncio.current_task()
632
+ host.gateway_sessions.setdefault(runtime.name, set()).add(task)
633
+ try:
634
+ async with connect(
635
+ url,
636
+ origin=address,
637
+ subprotocols=protocols or None,
638
+ additional_headers=headers,
639
+ max_size=200 * 1024 * 1024,
640
+ proxy=None,
641
+ ) as upstream:
642
+ await downstream.accept(subprotocol=upstream.subprotocol)
643
+
644
+ async def incoming():
645
+ while True:
646
+ event = await downstream.receive()
647
+ if event["type"] == "websocket.disconnect":
648
+ return
649
+ await upstream.send(
650
+ event.get("text")
651
+ if event.get("text") is not None
652
+ else event["bytes"]
653
+ )
654
+
655
+ async def outgoing():
656
+ async for message in upstream:
657
+ if isinstance(message, str):
658
+ await downstream.send_text(message)
659
+ else:
660
+ await downstream.send_bytes(message)
661
+
662
+ async def session_validity():
663
+ while True:
664
+ await asyncio.sleep(1)
665
+ try:
666
+ await host.access.admit(identity)
667
+ except PluginError:
668
+ return
669
+ if (
670
+ not initial_actor.unrestricted
671
+ and (await host.access.mode()) != initial_mode
672
+ ):
673
+ return
674
+
675
+ pumps = [
676
+ asyncio.create_task(incoming()),
677
+ asyncio.create_task(outgoing()),
678
+ asyncio.create_task(session_validity()),
679
+ ]
680
+ try:
681
+ done, _ = await asyncio.wait(
682
+ pumps, return_when=asyncio.FIRST_COMPLETED
683
+ )
684
+ for finished in done:
685
+ await finished
686
+ finally:
687
+ for pump in pumps:
688
+ pump.cancel()
689
+ await asyncio.gather(*pumps, return_exceptions=True)
690
+ except ConnectionClosed, asyncio.CancelledError, OSError:
691
+ pass
692
+ finally:
693
+ host.access.contexts.pop(reference, None)
694
+ host.gateway_sessions.get(runtime.name, set()).discard(task)
695
+ with suppress(RuntimeError):
696
+ await downstream.close(code=1012)
697
+
698
+ if getattr(host, "tracing", None):
699
+ app.add_middleware(TracingMiddleware, tracing=host.tracing)
700
+ return app