Files
OpenViking/openviking/server/app.py
T
Zayn JarvisandClaude Opus 5 4920297ccc feat(mcp): add write/edit/tree tools for viking:// as agent working directory (#3936)
* fix(storage): keep non-memory appends free of memory trailers

ContentWriteCoordinator._write_in_place routed every append through
MemoryFileUtils, which strips the existing trailing newline and appends
a reserved MEMORY_FIELDS metadata trailer, even for resource/skill files
where MEMORY_FIELDS is not a reserved format (see content_visibility).
Append to non-memory files now concatenates raw content instead, matching
POSIX append semantics and the documented visibility rules.

* feat(mcp): add write tool with exact-string edit support

Agents could not use viking:// as a working directory through MCP: no
tool could create or update file content. Add a write tool covering full
writes (mode=replace as create-or-overwrite, append, strict create) and
targeted edits (a list of {old_string, new_string, replace_all}
exact-string replacements applied in order, all-or-nothing), following
the Write/Edit conventions of common agent harnesses.

Edits read via read_visible and write back through the content-write
coordinator, so memory metadata trailers are preserved and semantic /
vector re-indexing triggers as with any other write. Parent directories
are created automatically by the storage layer. Descriptions spell out
writable scopes (resources, user memories/resources, agent) and the
wait=true knob for read-after-write search consistency.

Also update the stale tool-count comment in app.py and the MCP tool
tables in the en/zh guides (13 -> 14 tools).

* feat(mcp): add tree tool, split targeted edits into edit tool

tree renders the recursive directory tree under a viking:// URI,
indented by depth with file sizes, for whole-layout orientation;
level_limit/node_limit bound the output and include_abstract adds
per-file summaries. Missing directories report "(nothing under ...)"
instead of an error, matching the read tool's convention.

edit(uri, old_string, new_string, replace_all) takes over the targeted
exact-string replacement that previously lived in write's edits array,
matching the classic Edit tool signature harnesses already train on.
write now only does full-content writes (content + mode), removing the
mutually-exclusive content/edits schema ambiguity. Edits still read via
read_visible and write back through the content-write coordinator, so
memory metadata trailers are preserved and re-indexing triggers as with
any other write.

* test(plugin): update canonical MCP tool list for tree/write/edit

The marketplace test pins the server-registered MCP tool list; add the
new tree, write, and edit tools to fix plugin-tests CI.

* feat(storage): support plain files at the user scope root

Agents treating viking:// as a working directory naturally drop files
like viking://user/zeus-persona.md at the user root, but the write
coordinator only accepted the memories/ and resources/ subtrees.

Two changes make that work:

- Namespace shorthand: a dotted first segment under viking://user/ is a
  file name, not a user id (canonical user ids are dot-free by
  convention), so viking://user/zeus-persona.md now canonicalizes to
  viking://user/<current-user>/zeus-persona.md, matching how the
  reserved memories/resources/skills segments already shorthand.
  Dot-free segments still address an explicit user, and an exact match
  with the current user id still wins.

- Coordinator: plain files directly under the user root (or in
  non-managed subdirectories) anchor their semantic refresh at the
  parent directory. The managed subtrees skills/, peers/, privacy/ and
  sessions/ remain read-only with an actionable error message.

* fix(namespace): narrow user-root shorthand to text-file extensions

Review on #3936 (codex /review-pr) flagged that treating any dotted
segment as a user-root file shorthand would silently re-route canonical
URIs for valid dotted user ids (e.g. alice.smith) into the current
user space. Shorthand now triggers only when the first segment ends
in a common text-file extension; dotted or email-style user ids keep
resolving as canonical user ids. Adds regression tests pinning both
behaviors.

* fix(mcp): resolve user URIs against current user

* test(mcp): pin plain-file writes directly at the user root

The user-root shorthand exists so an agent can drop viking://user/persona.md
into its workspace, but every new test went through an intermediate directory
(viking://user/project/zeus-persona.md), leaving the no-directory shape — the
one that anchors the write coordinator's refresh at the user root itself —
uncovered. Add the missing case.

Also correct the write tool docstring: the create-extension allowlist applies
to any newly created file, including one created by mode="replace" falling
back to create, not only to an explicit mode="create".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:11:27 +08:00

805 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.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,
relations_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,
shared_upload_prefix=config.temp_upload.shared_prefix,
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)
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)
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"
return JSONResponse(
status_code=ERROR_CODE_TO_HTTP_STATUS[code],
content=Response(
status="error",
error=ErrorInfo(
code=code,
message=_validation_error_message(errors),
details={"validation_errors": errors},
),
).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}
return JSONResponse(
status_code=response_status,
headers=exc.headers,
content=Response(
status="error",
error=ErrorInfo(
code=code,
message=_message_from_http_detail(exc.detail),
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(relations_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 16 tools (find, search, recall, 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