big rework
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from importlib.resources import as_file
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Response
|
||||
from fastapi import status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import Settings
|
||||
from .config import get_settings
|
||||
from .mcp import create_mcp
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
runtime_settings = settings if settings is not None else get_settings()
|
||||
mcp_app = create_mcp().http_app(
|
||||
json_response=True,
|
||||
stateless_http=True,
|
||||
transport="http",
|
||||
)
|
||||
app = FastAPI(
|
||||
debug=runtime_settings.debug,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
lifespan=app_lifespan,
|
||||
)
|
||||
app.state.settings = runtime_settings
|
||||
app.mount(runtime_settings.mounts.mcp, mcp_app, name="mcp")
|
||||
return app
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(app: FastAPI):
|
||||
from . import __name__ as package_root_name
|
||||
|
||||
site_resource = files(package_root_name).joinpath("site")
|
||||
with as_file(site_resource) as site_dir:
|
||||
mount_docs(
|
||||
app,
|
||||
docs_route=app.state.settings.mounts.docs,
|
||||
site_dir=site_dir,
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
def mount_docs(app: FastAPI, *, docs_route: str, site_dir: Path) -> None:
|
||||
"""Mount the pre-built static docs site, or expose a clear missing-build response."""
|
||||
normalized_route = docs_route.rstrip("/") or "/docs"
|
||||
docs_root = f"{normalized_route}/"
|
||||
|
||||
async def redirect_to_docs_root() -> RedirectResponse:
|
||||
return RedirectResponse(
|
||||
url=docs_root,
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
normalized_route,
|
||||
redirect_to_docs_root,
|
||||
methods=["GET", "HEAD"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
if site_dir.is_dir():
|
||||
app.mount(
|
||||
normalized_route,
|
||||
StaticFiles(directory=site_dir, html=True),
|
||||
name="docs",
|
||||
)
|
||||
return
|
||||
|
||||
async def docs_not_built() -> Response:
|
||||
return Response(
|
||||
content=("Static docs have not been built yet. Run `uv run zensical build` before using this route."),
|
||||
media_type="text/plain",
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
normalized_route,
|
||||
docs_not_built,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
app.add_api_route(
|
||||
f"{normalized_route}/{{path:path}}",
|
||||
docs_not_built,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
@@ -2,7 +2,6 @@ from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import DirectoryPath
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import SettingsConfigDict
|
||||
@@ -29,7 +28,6 @@ class Settings(BaseSettings):
|
||||
debug: bool = False
|
||||
log_level: str = "info"
|
||||
mounts: Mounts = Field(default_factory=Mounts)
|
||||
site_dir: DirectoryPath = Field(default=DEFAULT_SITE_DIR)
|
||||
host: str = "localhost"
|
||||
port: int = 8080
|
||||
reload: bool = True
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "nicegui==3.16.0",
|
||||
# "pandas",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
from nicegui import binding
|
||||
from nicegui import events
|
||||
from nicegui import ui
|
||||
|
||||
STATUS_OPTIONS = ["draft", "active", "archived"]
|
||||
EDITABLE_FIELDS = ("name", "quantity", "status")
|
||||
TableValue = str | int
|
||||
TableRow = dict[str, TableValue]
|
||||
|
||||
|
||||
@binding.bindable_dataclass(bindable_fields=EDITABLE_FIELDS)
|
||||
class EditableRow:
|
||||
id: int
|
||||
name: str
|
||||
quantity: int
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EditableTableState:
|
||||
rows_by_id: dict[int, EditableRow]
|
||||
table_rows_by_id: dict[int, TableRow]
|
||||
|
||||
def table_rows(self) -> list[TableRow]:
|
||||
return list(self.table_rows_by_id.values())
|
||||
|
||||
|
||||
def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState:
|
||||
required_columns = {"id", *EDITABLE_FIELDS}
|
||||
missing_columns = required_columns.difference(dataframe.columns)
|
||||
if missing_columns:
|
||||
raise ValueError(f"Missing columns: {sorted(missing_columns)}")
|
||||
if not dataframe["id"].is_unique:
|
||||
raise ValueError("The id column must contain unique row keys")
|
||||
|
||||
rows_by_id: dict[int, EditableRow] = {}
|
||||
table_rows_by_id: dict[int, TableRow] = {}
|
||||
for record in dataframe.to_dict(orient="records"):
|
||||
row_state = EditableRow(
|
||||
id=int(record["id"]),
|
||||
name=str(record["name"]),
|
||||
quantity=int(record["quantity"]),
|
||||
status=str(record["status"]),
|
||||
)
|
||||
if row_state.status not in STATUS_OPTIONS:
|
||||
raise ValueError(f"Unknown status {row_state.status!r}")
|
||||
if row_state.id in rows_by_id:
|
||||
raise ValueError("Row keys must remain unique after normalization")
|
||||
|
||||
table_row: TableRow = {
|
||||
"id": row_state.id,
|
||||
"name": row_state.name,
|
||||
"quantity": row_state.quantity,
|
||||
"status": row_state.status,
|
||||
}
|
||||
for field_name in EDITABLE_FIELDS:
|
||||
binding.bind_to(
|
||||
row_state,
|
||||
field_name,
|
||||
table_row,
|
||||
field_name,
|
||||
other_strict=True,
|
||||
)
|
||||
rows_by_id[row_state.id] = row_state
|
||||
table_rows_by_id[row_state.id] = table_row
|
||||
|
||||
return EditableTableState(rows_by_id, table_rows_by_id)
|
||||
|
||||
|
||||
def normalize_edit(field: str, raw_value: object) -> TableValue:
|
||||
match field:
|
||||
case "name":
|
||||
if not isinstance(raw_value, str) or not (name := raw_value.strip()):
|
||||
raise ValueError("Name is required")
|
||||
return name
|
||||
case "quantity":
|
||||
if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float, str)):
|
||||
raise TypeError("Quantity must be an integer")
|
||||
if isinstance(raw_value, float) and not raw_value.is_integer():
|
||||
raise ValueError("Quantity must be an integer")
|
||||
try:
|
||||
quantity = int(raw_value)
|
||||
except (ValueError, OverflowError) as error:
|
||||
raise ValueError("Quantity must be an integer") from error
|
||||
if not 0 <= quantity <= 1_000:
|
||||
raise ValueError("Quantity must be between 0 and 1000")
|
||||
return quantity
|
||||
case "status":
|
||||
if not isinstance(raw_value, str) or raw_value not in STATUS_OPTIONS:
|
||||
raise ValueError("Unknown status")
|
||||
return raw_value
|
||||
case _:
|
||||
raise ValueError(f"Field {field!r} is not editable")
|
||||
|
||||
|
||||
def save_row(dataframe: pd.DataFrame, row_state: EditableRow) -> None:
|
||||
matching_rows = dataframe["id"].eq(row_state.id)
|
||||
if int(matching_rows.sum()) != 1:
|
||||
raise ValueError("This row no longer exists")
|
||||
dataframe.loc[matching_rows, "name"] = row_state.name
|
||||
dataframe.loc[matching_rows, "quantity"] = row_state.quantity
|
||||
dataframe.loc[matching_rows, "status"] = row_state.status
|
||||
|
||||
|
||||
def render_table(dataframe: pd.DataFrame) -> EditableTableState:
|
||||
state = dataframe_to_state(dataframe)
|
||||
columns = [
|
||||
{"name": "name", "label": "Name", "field": "name", "align": "left"},
|
||||
{"name": "quantity", "label": "Quantity", "field": "quantity", "align": "right"},
|
||||
{"name": "status", "label": "Status", "field": "status", "align": "left"},
|
||||
]
|
||||
table = ui.table(
|
||||
columns=columns,
|
||||
rows=state.table_rows(),
|
||||
row_key="id",
|
||||
selection="multiple",
|
||||
).classes("w-full")
|
||||
|
||||
def apply_edit(event: events.GenericEventArguments) -> None:
|
||||
try:
|
||||
raw_row_id, raw_field, raw_value = event.args
|
||||
row_id = int(raw_row_id)
|
||||
field_name = str(raw_field)
|
||||
row_state = state.rows_by_id.get(row_id)
|
||||
if row_state is None:
|
||||
raise ValueError("This row no longer exists")
|
||||
|
||||
normalized_value = normalize_edit(field_name, raw_value)
|
||||
previous_value = getattr(row_state, field_name)
|
||||
setattr(row_state, field_name, normalized_value)
|
||||
try:
|
||||
save_row(dataframe, row_state)
|
||||
except Exception:
|
||||
setattr(row_state, field_name, previous_value)
|
||||
raise
|
||||
except (TypeError, ValueError) as error:
|
||||
ui.notify(str(error), type="negative")
|
||||
finally:
|
||||
table.update_rows(state.table_rows(), clear_selection=False)
|
||||
|
||||
with table.add_slot("body-cell-name"), table.cell("name"):
|
||||
ui.input().props(':model-value="props.value" dense borderless debounce=400').on(
|
||||
"update:model-value",
|
||||
handler=apply_edit,
|
||||
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
||||
)
|
||||
|
||||
with table.add_slot("body-cell-quantity"), table.cell("quantity"):
|
||||
ui.number(min=0, max=1_000).props(':model-value="props.value" dense borderless debounce=400').on(
|
||||
"update:model-value",
|
||||
handler=apply_edit,
|
||||
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
||||
)
|
||||
|
||||
with table.add_slot("body-cell-status"), table.cell("status"):
|
||||
ui.select(STATUS_OPTIONS).props(':model-value="props.value" dense borderless options-dense').on(
|
||||
"update:model-value",
|
||||
handler=apply_edit,
|
||||
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
if __name__ in {"__main__", "__mp_main__"}:
|
||||
items = pd.DataFrame(
|
||||
[
|
||||
{"id": 101, "name": "Desk", "quantity": 4, "status": "active"},
|
||||
{"id": 102, "name": "Lamp", "quantity": 12, "status": "draft"},
|
||||
]
|
||||
)
|
||||
table_state = render_table(items)
|
||||
|
||||
ui.run(port=8888, reload=True)
|
||||
+27
-55
@@ -1,18 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import CompletionArgument
|
||||
from mcp_types import CompletionContext
|
||||
from mcp_types import Icon
|
||||
from mcp_types import PromptReference
|
||||
|
||||
from personal_mcp.prompts import create_prompts_provider
|
||||
from personal_mcp.prompts.models import MarkdownPrompt
|
||||
from personal_mcp.prompts.provider import MarkdownPromptsProvider
|
||||
from personal_mcp.registry.load import get_docs_registry
|
||||
from personal_mcp.registry.load import read_docs_markdown_path
|
||||
from personal_mcp.registry.models import DocsRegistry
|
||||
from personal_mcp.skills import create_skills_provider
|
||||
from .prompts.provider import prompt_lifespan
|
||||
from .registry.load import get_docs_registry
|
||||
from .registry.load import read_docs_markdown_path
|
||||
from .skills import skill_lifespan
|
||||
|
||||
_SERVER_INSTRUCTIONS = """Personal development guidance exposed as native MCP resources and prompts.
|
||||
|
||||
@@ -33,15 +27,22 @@ _SERVER_ICON = Icon(
|
||||
)
|
||||
|
||||
|
||||
def _ro_annotations() -> dict[str, bool]:
|
||||
return {
|
||||
"readOnlyHint": True,
|
||||
"idempotentHint": True,
|
||||
"openWorldHint": False,
|
||||
}
|
||||
def run_stdio() -> None:
|
||||
"""Create the MCP server and expose it over stdio"""
|
||||
create_mcp().run()
|
||||
|
||||
|
||||
def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
|
||||
def create_mcp() -> FastMCP:
|
||||
mcp = FastMCP(
|
||||
"personal-mcp",
|
||||
instructions=_SERVER_INSTRUCTIONS,
|
||||
icons=[_SERVER_ICON],
|
||||
on_duplicate="error",
|
||||
lifespan=skill_lifespan | prompt_lifespan,
|
||||
)
|
||||
|
||||
registry = get_docs_registry()
|
||||
|
||||
@mcp.resource(
|
||||
"resource://docs/{path*}",
|
||||
name="docs_markdown",
|
||||
@@ -49,46 +50,17 @@ def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
|
||||
description="Read a packaged documentation page by its path relative to the docs root.",
|
||||
mime_type="text/markdown",
|
||||
tags={"docs"},
|
||||
annotations=_ro_annotations(),
|
||||
annotations={
|
||||
"readOnlyHint": True,
|
||||
"idempotentHint": True,
|
||||
"openWorldHint": False,
|
||||
},
|
||||
)
|
||||
def docs_markdown(path: str) -> dict[str, str]:
|
||||
return read_docs_markdown_path(registry, path)
|
||||
|
||||
|
||||
def _register_prompt_completions(mcp: FastMCP, provider: MarkdownPromptsProvider) -> None:
|
||||
@mcp.completion
|
||||
async def complete_prompt_argument(
|
||||
ref: object,
|
||||
argument: CompletionArgument,
|
||||
context: CompletionContext | None,
|
||||
) -> list[str] | None:
|
||||
del context
|
||||
if not isinstance(ref, PromptReference):
|
||||
return None
|
||||
|
||||
prompt = await provider.get_prompt(ref.name)
|
||||
if not isinstance(prompt, MarkdownPrompt):
|
||||
return None
|
||||
|
||||
definition = prompt.definitions.get(argument.name)
|
||||
if definition is None or definition.choices is None:
|
||||
return None
|
||||
|
||||
prefix = argument.value.casefold()
|
||||
return [choice for choice in definition.choices if choice.casefold().startswith(prefix)]
|
||||
|
||||
|
||||
def create_mcp() -> FastMCP:
|
||||
registry = get_docs_registry()
|
||||
mcp = FastMCP(
|
||||
"personal-mcp",
|
||||
instructions=_SERVER_INSTRUCTIONS,
|
||||
icons=[_SERVER_ICON],
|
||||
on_duplicate="error",
|
||||
)
|
||||
_register_components(mcp, registry)
|
||||
prompts_provider = create_prompts_provider()
|
||||
mcp.add_provider(prompts_provider)
|
||||
mcp.add_provider(create_skills_provider())
|
||||
_register_prompt_completions(mcp, prompts_provider)
|
||||
return mcp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_stdio()
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
from .provider import complete_prompt_argument_choices
|
||||
from .provider import create_prompts_provider
|
||||
from .provider import prompt_lifespan
|
||||
|
||||
__all__ = ["create_prompts_provider"]
|
||||
__all__ = [
|
||||
"complete_prompt_argument_choices",
|
||||
"create_prompts_provider",
|
||||
"prompt_lifespan",
|
||||
]
|
||||
|
||||
@@ -85,6 +85,14 @@ class MarkdownPrompt(Prompt):
|
||||
definitions=metadata.arguments,
|
||||
)
|
||||
|
||||
def complete_argument(self, argument_name: str, argument_value: str) -> list[str] | None:
|
||||
definition = self.definitions.get(argument_name)
|
||||
if definition is None or definition.choices is None:
|
||||
return None
|
||||
|
||||
prefix = argument_value.casefold()
|
||||
return [choice for choice in definition.choices if choice.casefold().startswith(prefix)]
|
||||
|
||||
async def render(self, arguments: dict[str, object] | None = None) -> str:
|
||||
provided = arguments or {}
|
||||
declared_names = set(self.definitions)
|
||||
|
||||
@@ -2,8 +2,14 @@ from collections.abc import Sequence
|
||||
from importlib.resources import files
|
||||
from importlib.resources.abc import Traversable
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.prompts import Prompt
|
||||
from fastmcp.server.lifespan import lifespan
|
||||
from fastmcp.server.providers import Provider
|
||||
from mcp_types import CompletionArgument
|
||||
from mcp_types import CompletionContext
|
||||
from mcp_types import PromptReference
|
||||
from mcp_types import ResourceTemplateReference
|
||||
|
||||
from .content import load_prompt_definition
|
||||
from .models import MarkdownPrompt
|
||||
@@ -29,6 +35,41 @@ class MarkdownPromptsProvider(Provider):
|
||||
return prompts
|
||||
|
||||
|
||||
@lifespan
|
||||
async def prompt_lifespan(server: FastMCP):
|
||||
provider = create_prompts_provider()
|
||||
server.add_provider(provider)
|
||||
|
||||
@server.completion
|
||||
async def complete_prompt_argument(
|
||||
ref: PromptReference | ResourceTemplateReference,
|
||||
argument: CompletionArgument,
|
||||
context: CompletionContext | None,
|
||||
) -> list[str] | None:
|
||||
del context
|
||||
return await complete_prompt_argument_choices(provider, ref, argument)
|
||||
|
||||
try:
|
||||
yield {}
|
||||
finally:
|
||||
server.providers.remove(provider)
|
||||
|
||||
|
||||
def create_prompts_provider(root: Traversable | None = None) -> MarkdownPromptsProvider:
|
||||
prompts_root = root or files("personal_mcp").joinpath("docs", "prompts")
|
||||
return MarkdownPromptsProvider(prompts_root)
|
||||
|
||||
|
||||
async def complete_prompt_argument_choices(
|
||||
prompts_provider: MarkdownPromptsProvider,
|
||||
ref: object,
|
||||
argument: CompletionArgument,
|
||||
) -> list[str] | None:
|
||||
if not isinstance(ref, PromptReference):
|
||||
return None
|
||||
|
||||
prompt = await prompts_provider.get_prompt(ref.name)
|
||||
if not isinstance(prompt, MarkdownPrompt):
|
||||
return None
|
||||
|
||||
return prompt.complete_argument(argument.name, argument.value)
|
||||
|
||||
@@ -13,10 +13,6 @@ from pydantic import field_validator
|
||||
__all__ = ["DocsRegistry"]
|
||||
|
||||
|
||||
def frozen_mapping[K, V](value: Mapping[K, V] | None = None) -> Mapping[K, V]:
|
||||
return MappingProxyType(dict(value) if value is not None else {})
|
||||
|
||||
|
||||
def parse_docs_path(value: str | PurePosixPath) -> PurePosixPath:
|
||||
raw = value.as_posix() if isinstance(value, PurePosixPath) else value
|
||||
if "\\" in raw:
|
||||
@@ -39,6 +35,10 @@ def _empty_docs_mapping() -> Mapping[DocsPath, str]:
|
||||
return frozen_mapping()
|
||||
|
||||
|
||||
def frozen_mapping[K, V](value: Mapping[K, V] | None = None) -> Mapping[K, V]:
|
||||
return MappingProxyType(dict(value) if value is not None else {})
|
||||
|
||||
|
||||
class DocsRegistry(BaseModel):
|
||||
"""In-memory index of documentation content."""
|
||||
|
||||
|
||||
+17
-26
@@ -1,34 +1,25 @@
|
||||
from contextlib import ExitStack
|
||||
from contextlib import contextmanager
|
||||
from importlib.resources import as_file
|
||||
from importlib.resources import files
|
||||
from weakref import finalize
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.lifespan import lifespan
|
||||
from fastmcp.server.providers.skills import SkillsDirectoryProvider
|
||||
|
||||
|
||||
def create_skills_provider() -> SkillsDirectoryProvider:
|
||||
"""Create the provider for skills packaged with personal-mcp."""
|
||||
skills_resource = files("personal_mcp").joinpath("docs", "skills")
|
||||
if not skills_resource.is_dir():
|
||||
raise FileNotFoundError(f"packaged skills directory does not exist: {skills_resource}")
|
||||
@lifespan
|
||||
async def skill_lifespan(server: FastMCP):
|
||||
with skills_provider() as provider:
|
||||
server.add_provider(provider)
|
||||
try:
|
||||
yield {}
|
||||
finally:
|
||||
server.providers.remove(provider)
|
||||
|
||||
has_skills = any(
|
||||
skill_dir.is_dir() and skill_dir.joinpath("SKILL.md").is_file() for skill_dir in skills_resource.iterdir()
|
||||
)
|
||||
if not has_skills:
|
||||
raise ValueError(f"packaged skills directory contains no skills: {skills_resource}")
|
||||
|
||||
resources = ExitStack()
|
||||
try:
|
||||
skills_root = resources.enter_context(as_file(skills_resource))
|
||||
provider = SkillsDirectoryProvider(
|
||||
roots=skills_root,
|
||||
reload=False,
|
||||
supporting_files="template",
|
||||
)
|
||||
except BaseException:
|
||||
resources.close()
|
||||
raise
|
||||
|
||||
finalize(provider, resources.close)
|
||||
return provider
|
||||
@contextmanager
|
||||
def skills_provider():
|
||||
with as_file(files(__package__).joinpath("docs", "skills")) as skills_root:
|
||||
if not skills_root.is_dir():
|
||||
raise FileNotFoundError(f"packaged skills directory does not exist: {skills_root}")
|
||||
yield SkillsDirectoryProvider(roots=skills_root, reload=True)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""FastAPI web runtime for personal MCP."""
|
||||
@@ -1,34 +0,0 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ..config import Settings
|
||||
from ..config import get_settings
|
||||
from ..mcp import create_mcp
|
||||
from .docs_mount import mount_docs_static
|
||||
from .health import router as health_router
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
runtime_settings = settings if settings is not None else get_settings()
|
||||
mcp_app = create_mcp().http_app(
|
||||
path=runtime_settings.mounts.mcp,
|
||||
json_response=True,
|
||||
stateless_http=True,
|
||||
transport="http",
|
||||
)
|
||||
app = FastAPI(
|
||||
debug=runtime_settings.debug,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
lifespan=mcp_app.lifespan,
|
||||
)
|
||||
app.state.settings = runtime_settings
|
||||
|
||||
app.include_router(health_router)
|
||||
mount_docs_static(
|
||||
app,
|
||||
docs_route=runtime_settings.mounts.docs,
|
||||
site_dir=runtime_settings.site_dir,
|
||||
)
|
||||
app.mount("/", mcp_app, name="mcp")
|
||||
return app
|
||||
@@ -1,56 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Response
|
||||
from fastapi import status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
|
||||
def mount_docs_static(app: FastAPI, *, docs_route: str, site_dir: Path) -> None:
|
||||
"""Mount the pre-built static docs site, or expose a clear missing-build response."""
|
||||
normalized_route = docs_route.rstrip("/") or "/docs"
|
||||
docs_root = f"{normalized_route}/"
|
||||
|
||||
async def redirect_to_docs_root() -> RedirectResponse:
|
||||
return RedirectResponse(
|
||||
url=docs_root, status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
normalized_route,
|
||||
redirect_to_docs_root,
|
||||
methods=["GET", "HEAD"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
if site_dir.is_dir():
|
||||
app.mount(
|
||||
normalized_route,
|
||||
StaticFiles(directory=site_dir, html=True),
|
||||
name="docs",
|
||||
)
|
||||
return
|
||||
|
||||
async def docs_not_built() -> Response:
|
||||
return Response(
|
||||
content=(
|
||||
"Static docs have not been built yet. "
|
||||
"Run `uv run zensical build` before using this route."
|
||||
),
|
||||
media_type="text/plain",
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
normalized_route,
|
||||
docs_not_built,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
app.add_api_route(
|
||||
f"{normalized_route}/{{path:path}}",
|
||||
docs_not_built,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/healthz", include_in_schema=False)
|
||||
def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user