big rework

This commit is contained in:
John Lancaster
2026-08-29 20:10:30 -05:00
parent 73eb490537
commit de95477480
30 changed files with 558 additions and 971 deletions
+94
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+7 -1
View File
@@ -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",
]
+8
View File
@@ -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)
+41
View File
@@ -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)
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -1 +0,0 @@
"""FastAPI web runtime for personal MCP."""
-34
View File
@@ -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
-56
View File
@@ -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,
)
-8
View File
@@ -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"}