Files
OpenViking/openviking/server/app.py
T
Hao Zhe a809a0aaef feat(studio): show error details in request logs (#4172)
* feat(studio): show error details in request logs

* fix(observability): harden audit error details
2026-08-21 11:57:08 +08:00

822 lines
32 KiB
Python

# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
"""FastAPI application for OpenViking HTTP Server."""
import asyncio
import logging
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Callable, Optional
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.exceptions import ExceptionMiddleware
from openviking.observability.http_error_context import capture_public_http_error
from openviking.server.config import (
ServerConfig,
load_bot_gateway_token,
load_server_config,
validate_server_config,
)
from openviking.server.dependencies import set_server_config, set_service
from openviking.server.error_mapping import map_exception
from openviking.server.identity import Role
from openviking.server.models import ERROR_CODE_TO_HTTP_STATUS, ErrorInfo, Response
from openviking.server.profile_middleware import create_profile_http_middleware
from openviking.server.request_id import REQUEST_ID_HEADER, RequestIdMiddleware
from openviking.server.routers import (
admin_router,
agent_evolution_router,
bot_router,
console_router,
content_router,
debug_router,
filesystem_router,
metrics_router,
observer_router,
openviking_assets_router,
pack_router,
privacy_configs_router,
resources_router,
search_router,
sessions_router,
skills_router,
snapshot_router,
stats_router,
system_router,
tasks_router,
user_settings_router,
watches_router,
webdav_router,
)
from openviking.service.core import OpenVikingService
from openviking.service.task_tracker import get_task_tracker
from openviking_cli.exceptions import OpenVikingError
from openviking_cli.utils import get_logger
from openviking_cli.utils.config import (
DEFAULT_OV_CONF,
OPENVIKING_CONFIG_ENV,
get_openviking_config,
resolve_config_path,
)
from openviking_cli.utils.logger import init_otel_log_handler_from_server_config
logger = get_logger(__name__)
WORKER_WITH_BOT_ENV = "OPENVIKING_WORKER_WITH_BOT"
WORKER_BOT_API_URL_ENV = "OPENVIKING_WORKER_BOT_API_URL"
def create_worker_app() -> FastAPI:
"""Load file config and replay parent-process Bot CLI overrides."""
resolved_config_path = resolve_config_path(
None,
OPENVIKING_CONFIG_ENV,
DEFAULT_OV_CONF,
)
config_path = str(resolved_config_path) if resolved_config_path is not None else None
config = load_server_config(config_path)
with_bot = os.environ.get(WORKER_WITH_BOT_ENV)
if with_bot is not None:
config.with_bot = with_bot == "1"
bot_api_url = os.environ.get(WORKER_BOT_API_URL_ENV)
if bot_api_url is not None:
config.bot_api_url = bot_api_url
return create_app(config, config_path=config_path)
async def _initialize_auth_plugin(
app: FastAPI,
service: OpenVikingService,
config: ServerConfig,
) -> None:
"""Initialize the auth plugin before the app serves authenticated requests."""
from openviking.server.auth.registry import get_registry
effective_auth_mode = config.get_effective_auth_mode()
registry = get_registry()
# Ensure built-in plugins are registered
from openviking.server.auth.plugins import (
ApiKeyAuthPlugin,
DevAuthPlugin,
TrustedAuthPlugin,
)
if registry.get("dev") is None:
registry.register(DevAuthPlugin)
if registry.get("api_key") is None:
registry.register(ApiKeyAuthPlugin)
if registry.get("trusted") is None:
registry.register(TrustedAuthPlugin)
plugin_cls = registry.get(effective_auth_mode)
if plugin_cls is None:
logger.error(
"Unknown auth_mode: %r. No auth plugin registered. Registered modes: %s.",
effective_auth_mode,
", ".join(registry.list_modes()),
)
raise RuntimeError(f"Unknown auth_mode: {effective_auth_mode}")
plugin = plugin_cls()
app.state.auth_plugin = plugin
await plugin.initialize(app, service, config)
logger.info("Auth plugin initialized: %s", effective_auth_mode)
async def _initialize_runtime_state(
app: FastAPI,
service: OpenVikingService,
config: ServerConfig,
) -> None:
"""Initialize service and auth dependencies before traffic is accepted."""
await service.initialize()
await _initialize_auth_plugin(app, service, config)
from openviking.service.user_deletion import setup_user_deletion
app.state.user_deletion_service = await setup_user_deletion(
service=service,
manager=app.state.api_key_manager,
oauth_store=getattr(app.state, "oauth_store", None),
usage_audit_runtime=getattr(app.state, "usage_audit_runtime", None),
)
logger.info("OpenVikingService initialization complete")
def _format_error_location(loc: object) -> str:
if not isinstance(loc, (list, tuple)):
return "request"
parts = [str(part) for part in loc if part is not None]
return ".".join(parts) if parts else "request"
def _normalize_validation_error(error: object) -> dict:
if not isinstance(error, dict):
return {"loc": ["request"], "message": str(error), "type": "value_error"}
loc = error.get("loc", ["request"])
if not isinstance(loc, (list, tuple)):
loc = [loc]
return {
"loc": [str(part) for part in loc],
"message": str(error.get("msg") or "Invalid value"),
"type": str(error.get("type") or "value_error"),
}
def _validation_error_message(errors: list[dict]) -> str:
if not errors:
return "Invalid request parameters"
first = errors[0]
location = _format_error_location(first.get("loc"))
message = first.get("message") or "Invalid value"
return f"Invalid request parameters: {location}: {message}"
_FRAMEWORK_HTTP_STATUS_TO_ERROR_CODE = {
400: "INVALID_ARGUMENT",
401: "UNAUTHENTICATED",
403: "PERMISSION_DENIED",
404: "NOT_FOUND",
409: "CONFLICT",
422: "INVALID_ARGUMENT",
429: "RESOURCE_EXHAUSTED",
502: "UNAVAILABLE",
503: "UNAVAILABLE",
504: "DEADLINE_EXCEEDED",
}
def _error_code_from_framework_http_status(status_code: int) -> str:
"""Best-effort envelope code for framework/proxy HTTPException fallbacks.
Business routes should raise OpenVikingError subclasses directly instead
of relying on this status-code conversion.
"""
if status_code in _FRAMEWORK_HTTP_STATUS_TO_ERROR_CODE:
return _FRAMEWORK_HTTP_STATUS_TO_ERROR_CODE[status_code]
return "INTERNAL" if status_code >= 500 else "UNKNOWN"
def _message_from_http_detail(detail: object) -> str:
if isinstance(detail, str) and detail:
return detail
if isinstance(detail, list):
errors = [_normalize_validation_error(item) for item in detail]
return _validation_error_message(errors)
if isinstance(detail, dict):
for key in ("message", "detail", "error"):
value = detail.get(key)
if isinstance(value, str) and value:
return value
if detail:
return str(detail)
return "HTTP request failed"
def create_app(
config: Optional[ServerConfig] = None,
service: Optional[OpenVikingService] = None,
config_path: Optional[str] = None,
) -> FastAPI:
"""Create FastAPI application.
Args:
config: Server configuration. If None, loads from default location.
service: Pre-initialized OpenVikingService (optional).
config_path: Resolved ov.conf path used for live configuration reload.
Returns:
FastAPI application instance
"""
resolved_config_path = (
resolve_config_path(config_path, OPENVIKING_CONFIG_ENV, DEFAULT_OV_CONF)
if config_path is not None or config is None
else None
)
if config is None:
config = load_server_config(
str(resolved_config_path) if resolved_config_path is not None else config_path
)
validate_server_config(config)
usage_reporter_unset = object()
usage_reporter = usage_reporter_unset
def _get_usage_reporter(): # noqa: ANN202
nonlocal usage_reporter
if usage_reporter is usage_reporter_unset:
from openviking.usage_reporter.config import build_usage_reporter
usage_reporter = build_usage_reporter(config.usage_reporter)
return usage_reporter
def _configure_session_runtime(service_obj) -> None: # noqa: ANN001
sessions = getattr(service_obj, "sessions", None)
tool_output_setter = getattr(sessions, "set_tool_output_externalization_config", None)
if callable(tool_output_setter):
tool_output_setter(config.tool_output_externalization)
usage_reporter_setter = getattr(sessions, "set_usage_reporter", None)
if callable(usage_reporter_setter):
usage_reporter_setter(_get_usage_reporter())
agent_evolution_setter = getattr(sessions, "set_agent_evolution_config", None)
if callable(agent_evolution_setter):
agent_evolution_setter(config.agent_evolution)
user_memory_policy_setter = getattr(
sessions,
"set_default_user_memory_policy",
None,
)
if callable(user_memory_policy_setter):
user_memory_policy_setter(config.user_config_defaults.memory_policy)
agent_evolution_path_setter = getattr(
sessions,
"set_agent_evolution_config_path",
None,
)
if callable(agent_evolution_path_setter):
agent_evolution_path_setter(
str(resolved_config_path) if resolved_config_path is not None else None
)
if service is not None:
_configure_session_runtime(service)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
nonlocal service
owns_service = service is None
if owns_service:
service = OpenVikingService()
assert service is not None
_configure_session_runtime(service)
set_service(service)
from openviking.metrics.global_api import (
init_metrics_from_server_config,
)
from openviking.observability.usage_audit import init_usage_audit_from_server_config
init_metrics_from_server_config(config, app=app, service=service)
if config.observability.metrics.enabled:
logger.info("Prometheus metrics enabled at /metrics")
await init_usage_audit_from_server_config(config, app=app, service=service)
# Initialize OAuth 2.1 store + provider when enabled in OpenViking config.
# The store + provider instances were already constructed at app
# creation time so the SDK routes could capture them; here we just
# async-initialize the SQLite connection on the same instance.
oauth_store = getattr(app.state, "oauth_store", None)
oauth_gc_task: Optional[asyncio.Task] = None
if oauth_store is not None:
await oauth_store.initialize()
async def _oauth_gc_loop(store) -> None: # noqa: ANN001
while True:
try:
await asyncio.sleep(60)
await store.gc_expired()
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
logger.warning("OAuth GC loop error: %s", e)
oauth_gc_task = asyncio.create_task(_oauth_gc_loop(oauth_store))
app.state.oauth_gc_task = oauth_gc_task
logger.info("OAuth 2.1 store initialized at %s", oauth_store._db_path)
# Start TaskTracker cleanup loop
task_tracker = get_task_tracker()
task_tracker.start_cleanup_loop()
# Initialize tracing and OTLP log export from server.observability.
from openviking.telemetry import tracer_module
tracer_module.init_tracer_from_server_config(config)
init_otel_log_handler_from_server_config(config)
# Start MCP session manager (must be active before /mcp requests)
from openviking.server.mcp_endpoint import mcp_lifespan
async with mcp_lifespan():
if service is not None:
await _initialize_runtime_state(app, service, config)
yield
# Cleanup
from openviking.metrics.global_api import shutdown_metrics_async
from openviking.observability.usage_audit import shutdown_usage_audit
await shutdown_usage_audit(app=app)
await shutdown_metrics_async(app=app)
task_tracker.stop_cleanup_loop()
auth_plugin_state = getattr(app.state, "auth_plugin", None)
if auth_plugin_state is not None:
try:
await auth_plugin_state.shutdown()
except Exception as e: # noqa: BLE001
logger.warning("Auth plugin shutdown failed: %s", e)
if oauth_gc_task is not None:
oauth_gc_task.cancel()
try:
await oauth_gc_task
except (asyncio.CancelledError, Exception):
pass
oauth_store_state = getattr(app.state, "oauth_store", None)
if oauth_store_state is not None:
try:
await oauth_store_state.close()
except Exception as e: # noqa: BLE001
logger.warning("OAuth store close failed: %s", e)
if owns_service and service:
try:
await service.close()
logger.info("OpenVikingService closed")
except asyncio.CancelledError as e:
logger.warning(f"OpenVikingService close cancelled during shutdown: {e}")
except Exception as e:
logger.warning(f"OpenVikingService close failed during shutdown: {e}")
if usage_reporter is not usage_reporter_unset and usage_reporter is not None:
await usage_reporter.close()
app = FastAPI(
title="OpenViking API",
description="OpenViking HTTP Server - Agent-native context database",
version="0.1.0",
lifespan=lifespan,
)
app.state.config = config
app.state.api_key_manager = None
app.state.user_deletion_service = None
set_server_config(config)
# Body dump middleware must be registered BEFORE observability so it ends up
# nested inside the trace span (in Starlette, middleware added later wraps
# earlier-added ones — so earlier registration = inner layer).
if config.observability.dump_body.enabled:
from openviking.server.body_dump_middleware import (
create_dump_http_body_middleware,
)
_dump_body_fn = create_dump_http_body_middleware(
max_bytes=config.observability.dump_body.max_bytes,
)
@app.middleware("http")
async def dump_http_body(request: Request, call_next: Callable):
return await _dump_body_fn(request, call_next)
logger.info(
"HTTP body dump middleware enabled (max_bytes=%d) — bodies will be "
"attached to trace spans. Disable in production via "
"server.observability.dump_body.enabled=false.",
config.observability.dump_body.max_bytes,
)
# Add HTTP observability middleware (metrics, tracing).
# Note: In FastAPI/Starlette, middleware added later executes first (outer layer).
# We want timing to be the outermost layer to measure the full request duration.
from openviking.observability.http_observability_middleware import (
create_http_observability_middleware,
)
http_observability_middleware = create_http_observability_middleware()
profile_http_middleware = create_profile_http_middleware()
@app.middleware("http")
async def add_http_observability(request: Request, call_next: Callable):
return await http_observability_middleware(request, call_next)
@app.middleware("http")
async def add_profile_output(request: Request, call_next: Callable):
return await profile_http_middleware(request, call_next)
# Add request timing middleware last (so it executes first as the outermost layer)
# This ensures X-Process-Time includes the full request duration including
# observability middleware overhead.
# Add request header logging middleware (for debug)
@app.middleware("http")
async def log_request_headers(request: Request, call_next: Callable):
access_logger = logging.getLogger("uvicorn.access")
if access_logger.isEnabledFor(logging.DEBUG):
headers = dict(request.headers)
header_names = ", ".join(sorted(headers.keys()))
access_logger.debug(
f"Request headers for {request.method} {request.url.path}: {header_names}"
)
response = await call_next(request)
return response
# Add request timing middleware
@app.middleware("http")
async def add_timing(request: Request, call_next: Callable):
"""
Middleware to measure request processing time.
This middleware is added last so it executes as the outermost layer,
ensuring X-Process-Time includes the full request duration including
all other middleware overhead.
Args:
request: The incoming HTTP request.
call_next: The next middleware/handler in the chain.
Returns:
The response with X-Process-Time header added.
"""
start_time = time.perf_counter()
response = await call_next(request)
process_time = time.perf_counter() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
# Add exception handler for OpenVikingError
@app.exception_handler(OpenVikingError)
async def openviking_error_handler(request: Request, exc: OpenVikingError):
http_status = ERROR_CODE_TO_HTTP_STATUS.get(exc.code, 500)
capture_public_http_error(code=exc.code, message=exc.message, details=exc.details)
return JSONResponse(
status_code=http_status,
content=Response(
status="error",
error=ErrorInfo(
code=exc.code,
message=exc.message,
details=exc.details,
),
).model_dump(),
)
@app.exception_handler(RequestValidationError)
async def request_validation_error_handler(request: Request, exc: RequestValidationError):
errors = [_normalize_validation_error(error) for error in exc.errors()]
code = "INVALID_ARGUMENT"
message = _validation_error_message(errors)
details = {"validation_errors": errors}
capture_public_http_error(
code=code,
message=message,
details=details,
)
return JSONResponse(
status_code=ERROR_CODE_TO_HTTP_STATUS[code],
content=Response(
status="error",
error=ErrorInfo(
code=code,
message=message,
details=details,
),
).model_dump(exclude_none=True),
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
code = _error_code_from_framework_http_status(exc.status_code)
response_status = exc.status_code
if code != "UNKNOWN":
response_status = ERROR_CODE_TO_HTTP_STATUS.get(code, exc.status_code)
details = None
if exc.status_code != response_status:
details = {"original_http_status_code": exc.status_code}
message = _message_from_http_detail(exc.detail)
capture_public_http_error(code=code, message=message, details=details)
return JSONResponse(
status_code=response_status,
headers=exc.headers,
content=Response(
status="error",
error=ErrorInfo(
code=code,
message=message,
details=details,
),
).model_dump(exclude_none=True),
)
# Catch-all for unhandled exceptions so clients always get JSON
async def general_error_handler(_request: Request, exc: Exception):
mapped = map_exception(exc)
if mapped is not None:
http_status = ERROR_CODE_TO_HTTP_STATUS.get(mapped.code, 500)
logger.warning(
"Mapped unhandled exception to structured API error",
extra={"error_code": mapped.code, "error_message": mapped.message},
exc_info=exc,
)
return JSONResponse(
status_code=http_status,
content=Response(
status="error",
error=ErrorInfo(
code=mapped.code,
message=mapped.message,
details=mapped.details,
),
).model_dump(),
)
logger.exception("Unhandled exception")
return JSONResponse(
status_code=500,
content=Response(
status="error",
error=ErrorInfo(
code="INTERNAL",
message="Internal server error",
),
).model_dump(),
)
# Keep exception rendering inside the request-ID and CORS layers. This lets
# those middleware own their response headers without special error paths.
app.add_middleware(ExceptionMiddleware, handlers={Exception: general_error_handler})
app.add_middleware(RequestIdMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=config.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=[REQUEST_ID_HEADER],
)
# Configure Bot API if --with-bot is enabled
if config.with_bot:
import openviking.server.routers.bot as bot_module
bot_module.set_bot_api_url(config.bot_api_url)
bot_module.set_bot_api_key(load_bot_gateway_token())
logger.info(f"Bot API proxy enabled, forwarding to {config.bot_api_url}")
else:
logger.info("Bot API proxy disabled (use --with-bot to enable)")
# Register routers
app.include_router(system_router)
app.include_router(admin_router)
app.include_router(agent_evolution_router)
app.include_router(resources_router)
app.include_router(filesystem_router)
app.include_router(content_router)
app.include_router(console_router)
app.include_router(search_router)
app.include_router(privacy_configs_router)
app.include_router(skills_router)
app.include_router(sessions_router)
app.include_router(snapshot_router)
app.include_router(stats_router)
app.include_router(pack_router)
app.include_router(debug_router)
app.include_router(observer_router)
app.include_router(openviking_assets_router)
app.include_router(metrics_router)
app.include_router(tasks_router)
app.include_router(user_settings_router)
app.include_router(watches_router)
app.include_router(webdav_router)
app.include_router(bot_router, prefix="/bot/v1")
# OAuth 2.1: when enabled, mount the official MCP SDK auth routes
# (DCR / authorize / token / metadata) plus our authorize page + consent /
# verify endpoints. The Provider that backs the SDK routes is built
# in the lifespan; here we only register the route handlers, since the
# SDK routes inspect request.app.state at call time.
try:
ov_cfg = get_openviking_config()
if ov_cfg.oauth.enabled:
from mcp.server.auth.routes import create_auth_routes
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
from pydantic import AnyHttpUrl
from openviking.server.oauth.router import router as oauth_router
# Custom routes (authorize page + consent / verify endpoints).
app.include_router(oauth_router)
# SDK-owned routes (DCR / authorize / token / metadata / revoke).
# We need a live Provider here; create_auth_routes captures it by
# reference. Re-build the same construction the lifespan does so
# the routes work as soon as they're hit (lifespan re-binds the
# same instance to app.state for the consent / authorize-page path).
from pathlib import Path as _Path
from openviking.server.oauth.provider import OpenVikingOAuthProvider
from openviking.server.oauth.storage import OAuthStore
_workspace = _Path(ov_cfg.storage.workspace).expanduser().resolve()
_workspace.mkdir(parents=True, exist_ok=True)
_route_store = OAuthStore(_workspace / ov_cfg.oauth.db_filename)
# Resolution order for the AS issuer URL:
# 1. OPENVIKING_PUBLIC_BASE_URL env var (deployment override)
# 2. oauth.issuer in ov.conf (operator config)
# 3. http://127.0.0.1:1933 (dev default; SDK accepts loopback http)
import os as _os
_route_issuer = (
_os.environ.get("OPENVIKING_PUBLIC_BASE_URL", "").strip().rstrip("/")
or ov_cfg.oauth.issuer
or "http://127.0.0.1:1933"
)
# Late-binding role resolver: app.state.api_key_manager is wired
# during lifespan, after the provider is constructed. Lambda
# closes over `app` and looks up at call time.
def _current_role(account_id: str, user_id: str) -> Role:
mgr = getattr(app.state, "api_key_manager", None)
if mgr is None or not hasattr(mgr, "get_user_role"):
return Role.USER
return mgr.get_user_role(account_id, user_id)
_route_provider = OpenVikingOAuthProvider(
store=_route_store,
issuer=_route_issuer,
access_token_ttl_seconds=ov_cfg.oauth.access_token_ttl_seconds,
refresh_token_ttl_seconds=ov_cfg.oauth.refresh_token_ttl_seconds,
auth_code_ttl_seconds=ov_cfg.oauth.auth_code_ttl_seconds,
role_resolver=_current_role,
)
# Stash the route-time instances; the lifespan replaces these with
# initialized copies before the first request lands.
app.state.oauth_store = _route_store
app.state.oauth_provider = _route_provider
from openviking.server.oauth.provider import MCP_SCOPE
sdk_routes = create_auth_routes(
provider=_route_provider,
issuer_url=AnyHttpUrl(_route_issuer),
# default_scopes covers clients whose DCR omits `scope`
# (ChatGPT does): without it they register scope-less and then
# fail /authorize with invalid_scope when they request the
# "mcp" scope advertised in the PRM document. valid_scopes is
# deliberately NOT set — the SDK would 400 any DCR that carries
# a scope outside the list, and clients like Claude register
# with their own scope strings.
client_registration_options=ClientRegistrationOptions(
enabled=True, default_scopes=[MCP_SCOPE]
),
revocation_options=RevocationOptions(enabled=True),
)
app.routes.extend(sdk_routes)
app.state.oauth_config = ov_cfg.oauth
logger.info(
"OAuth 2.1 routes mounted (SDK + authorize-page + consent): %s",
[r.path for r in sdk_routes],
)
except Exception as e: # noqa: BLE001
logger.warning("Skipping OAuth router registration: %s", e)
# Favicon routes — always registered so /favicon.* and /mcp/favicon.* never
# 404, even when web-studio isn't bundled. Source files live in
# openviking/server/static/ (shipped via package-data, ~30KB total) so they
# are available in every pip-install / docker / source-tree scenario.
_server_static_dir = Path(__file__).resolve().parent / "static"
_favicon_headers = {"Cache-Control": "public, max-age=86400"}
_favicon_files = {
"/favicon.ico": ("favicon.ico", "image/x-icon"),
"/favicon.png": ("favicon-32.png", "image/png"),
"/apple-touch-icon.png": ("apple-touch-icon.png", "image/png"),
"/mcp/favicon.ico": ("favicon.ico", "image/x-icon"),
"/mcp/favicon.png": ("favicon-32.png", "image/png"),
"/mcp/apple-touch-icon.png": ("apple-touch-icon.png", "image/png"),
}
def _make_favicon_handler(filename: str, media_type: str):
path = _server_static_dir / filename
async def _handler():
return FileResponse(path, media_type=media_type, headers=_favicon_headers)
return _handler
for _route, (_fname, _mime) in _favicon_files.items():
app.add_api_route(_route, _make_favicon_handler(_fname, _mime), include_in_schema=False)
# Web Studio SPA: serve the static bundle when present so the same OV
# server origin can host the new frontend at /studio. The directory is
# populated by the docker `web-studio-builder` stage and shipped inside
# the openviking python package (see pyproject.toml package-data). Outside
# docker, set OPENVIKING_WEB_STUDIO_DIR to a local `web-studio/dist` to
# enable a dev build without rebuilding the wheel.
_studio_env = os.environ.get("OPENVIKING_WEB_STUDIO_DIR", "").strip()
if _studio_env:
_studio_dir = Path(_studio_env)
else:
_studio_dir = Path(__file__).resolve().parent.parent / "web_studio" / "dist"
if _studio_dir.is_dir() and (_studio_dir / "index.html").is_file():
_studio_root = _studio_dir.resolve()
_studio_index = _studio_root / "index.html"
_studio_no_store = {"Cache-Control": "no-store"}
def _studio_response(path: Path, *, no_store: bool = False) -> FileResponse:
return FileResponse(path, headers=_studio_no_store if no_store else None)
@app.get("/", include_in_schema=False)
async def _root_redirect_to_studio():
# When web-studio is bundled, treat / as a convenience entry to
# /studio/ so users hitting the bare origin land on the UI.
return RedirectResponse(url="/studio/", status_code=302)
@app.get("/studio", include_in_schema=False)
async def _studio_root_handler():
return _studio_response(_studio_index, no_store=True)
@app.get("/studio/{path:path}", include_in_schema=False)
async def _studio_assets(path: str):
# SPA fallback: serve real files when present, otherwise return
# index.html so TanStack Router can resolve the deep link.
try:
requested = (_studio_root / path).resolve()
except OSError:
return _studio_response(_studio_index, no_store=True)
if not requested.is_relative_to(_studio_root):
return _studio_response(_studio_index, no_store=True)
if requested.is_file():
return _studio_response(requested)
return _studio_response(_studio_index, no_store=True)
logger.info("Web Studio mounted at /studio from %s", _studio_root)
else:
logger.info("Web Studio bundle not found at %s; skipping /studio mount", _studio_dir)
# MCP endpoint — serves 15 tools (find, search, read, write, edit,
# list, tree, remember, add_resource, list_watches, cancel_watch, grep,
# glob, forget, health) via streamable HTTP for MCP clients.
from starlette.routing import Match, Route
from openviking.server.mcp_endpoint import create_mcp_app
class _ScopedRoute(Route):
"""Expose the selected route through ``scope["route"]``, matching
``APIRoute.matches``, so outer observability can resolve the static
``/mcp`` route template."""
def matches(self, scope):
match, child_scope = super().matches(scope)
if match != Match.NONE:
child_scope["route"] = self
return match, child_scope
app.routes.append(
_ScopedRoute("/mcp", endpoint=create_mcp_app(), methods=["GET", "POST", "DELETE"])
)
return app