big rework
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
---
|
||||
name: Pytest Scaffolding Guidance
|
||||
description: Route tests edits to the Personal MCP pytesting resource.
|
||||
description: Use when working under tests/. Route test edits to pytesting guidance and never create or expand tests unless the user explicitly asks.
|
||||
applyTo: 'tests/**'
|
||||
---
|
||||
|
||||
When editing files under `tests/`, use `skill://pytesting/SKILL.md` as the primary guidance source for test scaffolding and pytest authoring decisions.
|
||||
|
||||
Hard rule:
|
||||
|
||||
- Do not create new test files, test cases, or test scaffolding unless the user explicitly asks for tests in the current request.
|
||||
- If tests could help but were not requested, mention them as an optional next step instead of adding them.
|
||||
|
||||
Execution pattern:
|
||||
|
||||
1. Load `skill://pytesting/SKILL.md` first.
|
||||
|
||||
@@ -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}")
|
||||
|
||||
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()
|
||||
@lifespan
|
||||
async def skill_lifespan(server: FastMCP):
|
||||
with skills_provider() as provider:
|
||||
server.add_provider(provider)
|
||||
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
|
||||
yield {}
|
||||
finally:
|
||||
server.providers.remove(provider)
|
||||
|
||||
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"}
|
||||
@@ -1 +0,0 @@
|
||||
"""Test package marker for intra-suite imports."""
|
||||
@@ -1,3 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Global lightweight fixtures can be added here as the suite grows.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from personal_mcp.prompts.content import load_prompt_definition
|
||||
from personal_mcp.prompts.content import render_prompt
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class TestPromptContentRenderer:
|
||||
def test_renders_arguments_without_frontmatter(self) -> None:
|
||||
rendered = render_prompt(
|
||||
"jsfiddle-page-layout",
|
||||
{"domain": "public library", "layout_brief": None},
|
||||
)
|
||||
|
||||
assert not rendered.startswith("---")
|
||||
assert "`domain`: public library" in rendered
|
||||
assert "`layout_brief`: Not provided" in rendered
|
||||
|
||||
def test_rejects_placeholder_drift(self) -> None:
|
||||
with pytest.raises(ValueError, match="placeholders do not match arguments"):
|
||||
render_prompt("jsfiddle-page-layout", {"domain": "public library"})
|
||||
|
||||
def test_rejects_invalid_prompt_id(self) -> None:
|
||||
with pytest.raises(ValueError, match="lowercase kebab-case"):
|
||||
render_prompt("../outside", {})
|
||||
|
||||
def test_rejects_missing_prompt_metadata(self, tmp_path: Path) -> None:
|
||||
document = tmp_path / "PROMPT.md"
|
||||
document.write_text("---\nicon: lucide/messages-square\n---\n\n# Body\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="missing the 'prompt' block"):
|
||||
load_prompt_definition("demo", document)
|
||||
|
||||
def test_rejects_unknown_prompt_metadata(self, tmp_path: Path) -> None:
|
||||
document = tmp_path / "PROMPT.md"
|
||||
document.write_text(
|
||||
"---\nprompt: {version: '1', description: Demo, tags: [demo], arguments: {}, unknown: true}\n"
|
||||
"---\n\n# Body\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Extra inputs are not permitted"):
|
||||
load_prompt_definition("demo", document)
|
||||
|
||||
def test_rejects_declared_placeholder_drift(self, tmp_path: Path) -> None:
|
||||
document = tmp_path / "PROMPT.md"
|
||||
document.write_text(
|
||||
"---\nprompt: {version: '1', description: Demo, tags: [demo], arguments: "
|
||||
"{topic: {description: Topic, required: true}}}\n---\n\n# Body\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="placeholders do not match arguments"):
|
||||
load_prompt_definition("demo", document)
|
||||
@@ -1,121 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import PromptError
|
||||
|
||||
from personal_mcp.prompts import create_prompts_provider
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
EXPECTED_PROMPTS = {
|
||||
"authoring",
|
||||
"greenfield-architecture",
|
||||
"jsfiddle-page-layout",
|
||||
"mcp-consumer-repo-shim",
|
||||
"nicegui-component-extraction",
|
||||
"pytest-fill-scaffold",
|
||||
"pytest-scaffold",
|
||||
}
|
||||
|
||||
|
||||
def write_prompt(document: Path, *, description: str, heading: str = "Demo") -> None:
|
||||
document.parent.mkdir(parents=True, exist_ok=True)
|
||||
document.write_text(
|
||||
"---\n"
|
||||
f"prompt: {{version: '1.0.0', description: {description!r}, tags: [demo], arguments: "
|
||||
"{kind: {description: 'Kind to render.', required: true, choices: [first, second]}, "
|
||||
"note: {description: 'Optional note.', required: false}}}\n"
|
||||
"---\n\n"
|
||||
f"# {heading}\n\nKind: {{{{kind}}}}\n\nNote: {{{{note}}}}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class TestMarkdownPromptsProvider:
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovers_exact_authored_set(self) -> None:
|
||||
mcp = FastMCP("prompts-test")
|
||||
mcp.add_provider(create_prompts_provider())
|
||||
|
||||
async with Client(mcp) as client:
|
||||
prompts = await client.list_prompts()
|
||||
|
||||
assert {prompt.name for prompt in prompts} == EXPECTED_PROMPTS
|
||||
assert all(prompt.description for prompt in prompts)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exposes_typed_arguments_and_renders_markdown(self) -> None:
|
||||
mcp = FastMCP("prompts-test")
|
||||
mcp.add_provider(create_prompts_provider())
|
||||
|
||||
async with Client(mcp) as client:
|
||||
prompts = await client.list_prompts()
|
||||
authoring = next(prompt for prompt in prompts if prompt.name == "authoring")
|
||||
result = await client.get_prompt(
|
||||
"authoring",
|
||||
{
|
||||
"artifact_type": "skill",
|
||||
"artifact_id": "demo-skill",
|
||||
"goal": "Demonstrate typed prompts.",
|
||||
},
|
||||
)
|
||||
|
||||
required = {argument.name for argument in authoring.arguments or [] if argument.required}
|
||||
artifact_type = next(argument for argument in authoring.arguments or [] if argument.name == "artifact_type")
|
||||
assert required == {"artifact_type", "artifact_id", "goal"}
|
||||
assert artifact_type.description == "Artifact type to create.\n\nAccepted values: skill, prompt, shim."
|
||||
assert result.messages
|
||||
assert "`artifact_id`: demo-skill" in result.messages[0].content.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enforces_required_arguments_and_choices(self) -> None:
|
||||
provider = create_prompts_provider()
|
||||
prompt = await provider.get_prompt("authoring")
|
||||
|
||||
assert prompt is not None
|
||||
with pytest.raises(PromptError, match="Missing required arguments"):
|
||||
await prompt.render({"artifact_type": "skill"})
|
||||
with pytest.raises(PromptError, match="must be one of"):
|
||||
await prompt.render(
|
||||
{
|
||||
"artifact_type": "unsupported",
|
||||
"artifact_id": "demo-skill",
|
||||
"goal": "Demonstrate validation.",
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_loads_edits_without_python_components(self, tmp_path: Path) -> None:
|
||||
prompts_root = tmp_path / "prompts"
|
||||
prompts_root.mkdir()
|
||||
provider = create_prompts_provider(prompts_root)
|
||||
mcp = FastMCP("prompts-test")
|
||||
mcp.add_provider(provider)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
assert await client.list_prompts() == []
|
||||
|
||||
document = prompts_root / "dynamic-demo" / "PROMPT.md"
|
||||
write_prompt(document, description="Initial description")
|
||||
|
||||
prompts = await client.list_prompts()
|
||||
assert [prompt.name for prompt in prompts] == ["dynamic-demo"]
|
||||
assert prompts[0].description == "Initial description"
|
||||
result = await client.get_prompt("dynamic-demo", {"kind": "first"})
|
||||
assert "# Demo" in result.messages[0].content.text
|
||||
assert "Note: Not provided" in result.messages[0].content.text
|
||||
|
||||
write_prompt(document, description="Updated description", heading="Updated")
|
||||
|
||||
prompts = await client.list_prompts()
|
||||
assert prompts[0].description == "Updated description"
|
||||
result = await client.get_prompt("dynamic-demo", {"kind": "second", "note": "ready"})
|
||||
assert "# Updated" in result.messages[0].content.text
|
||||
assert "Note: ready" in result.messages[0].content.text
|
||||
|
||||
document.unlink()
|
||||
document.parent.rmdir()
|
||||
|
||||
assert await client.list_prompts() == []
|
||||
@@ -1,21 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from personal_mcp.registry.load import get_docs_registry
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class TestCurrentDocsIngestion:
|
||||
"""Covers ingestion of the repository's current docs tree."""
|
||||
|
||||
def test_registry_includes_docs_and_excludes_skills(self) -> None:
|
||||
"""Ensures the docs registry cannot duplicate native skill resources."""
|
||||
registry = get_docs_registry()
|
||||
|
||||
assert PurePosixPath("index.md") in registry.docs_markdown_by_path
|
||||
assert any(path.parts[0] == "prompts" for path in registry.docs_markdown_by_path)
|
||||
assert all(path.parts[0] != "skills" for path in registry.docs_markdown_by_path)
|
||||
@@ -1,33 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from personal_mcp.registry.models import parse_docs_path
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class TestDocsPathValidation:
|
||||
"""Covers canonical resource-path contracts."""
|
||||
|
||||
def test_parse_docs_path_returns_pure_posix_path(self) -> None:
|
||||
path = parse_docs_path("guides/demo.md")
|
||||
|
||||
assert path == PurePosixPath("guides/demo.md")
|
||||
assert isinstance(path, PurePosixPath)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
(
|
||||
"/absolute.md",
|
||||
"../outside.md",
|
||||
"guides\\demo.md",
|
||||
"guides//demo.md",
|
||||
"guides/demo.txt",
|
||||
),
|
||||
)
|
||||
def test_parse_docs_path_rejects_invalid_paths(self, value: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
parse_docs_path(value)
|
||||
@@ -1,27 +0,0 @@
|
||||
from pathlib import Path
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from personal_mcp.registry.load import load_markdown
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class TestLoadMarkdown:
|
||||
"""Covers recursive Markdown discovery and loading."""
|
||||
|
||||
def test_loads_markdown_in_stable_order(self, tmp_path: Path) -> None:
|
||||
nested = tmp_path / "guides"
|
||||
nested.mkdir()
|
||||
(nested / "b.md").write_text("caf\u00e9\n", encoding="utf-8")
|
||||
(nested / "a.md").write_text("alpha\n", encoding="utf-8")
|
||||
(nested / "ignored.txt").write_text("ignored\n", encoding="utf-8")
|
||||
|
||||
docs = load_markdown(tmp_path)
|
||||
|
||||
assert list(docs) == [
|
||||
PurePosixPath("guides/a.md"),
|
||||
PurePosixPath("guides/b.md"),
|
||||
]
|
||||
assert docs[PurePosixPath("guides/b.md")] == "caf\u00e9\n"
|
||||
@@ -1,37 +0,0 @@
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from personal_mcp.registry.load import read_docs_markdown_path
|
||||
from personal_mcp.registry.models import DocsRegistry
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _make_registry() -> DocsRegistry:
|
||||
index_path = PurePosixPath("index.md")
|
||||
return DocsRegistry(
|
||||
docs_markdown_by_path={index_path: "# index"},
|
||||
docs_markdown_path_index=(index_path,),
|
||||
)
|
||||
|
||||
|
||||
def test_reads_docs_path_from_string_boundary() -> None:
|
||||
payload = read_docs_markdown_path(_make_registry(), "index.md")
|
||||
|
||||
assert payload == {
|
||||
"uri": "resource://docs/index.md",
|
||||
"format": "markdown",
|
||||
"source_path": "docs/index.md",
|
||||
"content": "# index",
|
||||
}
|
||||
|
||||
|
||||
def test_rejects_skill_docs_path() -> None:
|
||||
with pytest.raises(KeyError, match="unknown docs path"):
|
||||
read_docs_markdown_path(_make_registry(), "skills/demo/SKILL.md")
|
||||
|
||||
|
||||
def test_rejects_non_posix_docs_path() -> None:
|
||||
with pytest.raises(ValueError, match="POSIX separators"):
|
||||
read_docs_markdown_path(_make_registry(), "guides\\demo.md")
|
||||
@@ -1,67 +0,0 @@
|
||||
from importlib.resources import files
|
||||
from importlib.resources.abc import Traversable
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastmcp import Client
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.skills import get_skill_manifest
|
||||
from fastmcp.utilities.skills import list_skills
|
||||
|
||||
from personal_mcp.skills import create_skills_provider
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
SKILLS_ROOT = files("personal_mcp").joinpath("docs", "skills")
|
||||
|
||||
|
||||
def skill_directories() -> list[Traversable]:
|
||||
return [
|
||||
directory
|
||||
for directory in SKILLS_ROOT.iterdir()
|
||||
if directory.is_dir() and directory.joinpath("SKILL.md").is_file()
|
||||
]
|
||||
|
||||
|
||||
class TestSkillsProvider:
|
||||
"""Covers native FastMCP skill discovery and retrieval."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovers_authored_skills(self) -> None:
|
||||
"""Ensures each authored skill is exposed with its description."""
|
||||
expected_names = {directory.name for directory in skill_directories()}
|
||||
mcp = FastMCP("skills-test")
|
||||
mcp.add_provider(create_skills_provider())
|
||||
|
||||
async with Client(mcp) as client:
|
||||
skills = await list_skills(client)
|
||||
|
||||
assert {skill.name for skill in skills} == expected_names
|
||||
assert all(skill.description for skill in skills)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reads_manifest_and_supporting_file(self) -> None:
|
||||
"""Ensures manifests disclose hashed files that remain directly readable."""
|
||||
mcp = FastMCP("skills-test")
|
||||
mcp.add_provider(create_skills_provider())
|
||||
|
||||
async with Client(mcp) as client:
|
||||
manifest = await get_skill_manifest(client, "mcp-details")
|
||||
reference = next(file for file in manifest.files if file.path.startswith("references/"))
|
||||
contents = await client.read_resource(f"skill://mcp-details/{reference.path}")
|
||||
|
||||
assert any(file.path == "SKILL.md" for file in manifest.files)
|
||||
assert all(file.hash.startswith("sha256:") for file in manifest.files)
|
||||
assert reference.size > 0
|
||||
assert contents
|
||||
|
||||
def test_frontmatter_names_match_directories(self) -> None:
|
||||
"""Ensures provider identity and authored skill names remain aligned."""
|
||||
for directory in skill_directories():
|
||||
skill_file = directory.joinpath("SKILL.md")
|
||||
raw = skill_file.read_text(encoding="utf-8")
|
||||
frontmatter = yaml.safe_load(raw.split("---", 2)[1])
|
||||
|
||||
assert set(frontmatter) == {"name", "description"}
|
||||
assert frontmatter["name"] == directory.name
|
||||
assert frontmatter["description"]
|
||||
@@ -1,55 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from httpx import AsyncClient
|
||||
from httpx2 import ASGITransport as McpASGITransport
|
||||
from httpx2 import AsyncClient as McpAsyncClient
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
from personal_mcp.web.app import create_app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient]:
|
||||
"""Provides an AsyncClient bound to a fresh application instance."""
|
||||
app = create_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
timeout=10.0,
|
||||
) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_session_factory():
|
||||
"""Provides an in-process context manager factory for MCP SDK sessions."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_session(*, initialize: bool = True) -> AsyncGenerator[ClientSession]:
|
||||
app = create_app()
|
||||
mcp_url = f"http://testserver{app.state.settings.mounts.mcp}"
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
McpAsyncClient(
|
||||
transport=McpASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
timeout=10.0,
|
||||
) as http_client,
|
||||
streamable_http_client(
|
||||
mcp_url,
|
||||
http_client=http_client,
|
||||
) as (read_stream, write_stream),
|
||||
ClientSession(read_stream, write_stream) as session,
|
||||
):
|
||||
if initialize:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
return create_session
|
||||
@@ -1,58 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.smoke
|
||||
|
||||
|
||||
class TestMcpHttpEndpoints:
|
||||
"""Covers smoke-level HTTP checks for mounted MCP runtime endpoints."""
|
||||
|
||||
class TestHealthz:
|
||||
"""Covers health endpoint smoke behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_ok_payload(self, client: AsyncClient) -> None:
|
||||
"""Ensures GET /healthz responds with a healthy status payload."""
|
||||
response = await client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
class TestDocsRoute:
|
||||
"""Covers static docs route smoke behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serves_docs_entrypoint(self, client: AsyncClient) -> None:
|
||||
"""Ensures GET /docs returns the docs site entrypoint response."""
|
||||
response = await client.get("/docs", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
class TestMcpRoute:
|
||||
"""Covers MCP transport endpoint smoke behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_publish_legacy_resource_bridge_tools(self, mcp_session_factory) -> None:
|
||||
"""Ensures deprecated compatibility tools are not exposed on the MCP route."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
tools_result = await mcp_session.list_tools()
|
||||
|
||||
tool_names = {tool.name for tool in tools_result.tools}
|
||||
assert "search_skills" not in tool_names
|
||||
assert "list_resources" not in tool_names
|
||||
assert "read_resource" not in tool_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accepts_initialize_jsonrpc_request(
|
||||
self,
|
||||
mcp_session_factory,
|
||||
) -> None:
|
||||
"""Ensures POST /mcp accepts an initialize JSON-RPC request."""
|
||||
async with mcp_session_factory(initialize=False) as mcp_session_uninitialized:
|
||||
initialize_result = await mcp_session_uninitialized.initialize()
|
||||
|
||||
assert initialize_result.protocol_version
|
||||
assert initialize_result.server_info.name
|
||||
@@ -1,79 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from mcp_types import PromptReference
|
||||
|
||||
pytestmark = pytest.mark.smoke
|
||||
|
||||
EXPECTED_PROMPTS = {
|
||||
"authoring",
|
||||
"greenfield-architecture",
|
||||
"jsfiddle-page-layout",
|
||||
"mcp-consumer-repo-shim",
|
||||
"nicegui-component-extraction",
|
||||
"pytest-fill-scaffold",
|
||||
"pytest-scaffold",
|
||||
}
|
||||
|
||||
|
||||
class TestMcpPromptSurface:
|
||||
"""Covers smoke-level MCP prompt discovery and retrieval paths."""
|
||||
|
||||
class TestServerMetadata:
|
||||
"""Covers client-facing identity and completion capabilities."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertises_instructions_and_completions(self, mcp_session_factory) -> None:
|
||||
"""Ensures VS Code receives server guidance and completion support."""
|
||||
async with mcp_session_factory(initialize=False) as mcp_session:
|
||||
result = await mcp_session.initialize()
|
||||
|
||||
assert result.instructions is not None
|
||||
assert "skill://<name>/SKILL.md" in result.instructions
|
||||
assert result.server_info.icons
|
||||
assert result.server_info.icons[0].src.startswith("data:image/svg+xml;base64,")
|
||||
assert result.capabilities.completions is not None
|
||||
|
||||
class TestPromptDiscovery:
|
||||
"""Covers MCP prompts/list behavior using native prompt objects."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_prompt_objects(self, mcp_session_factory) -> None:
|
||||
"""Ensures prompts/list returns native prompt objects with stable names."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.list_prompts()
|
||||
|
||||
assert {prompt.name for prompt in result.prompts} == EXPECTED_PROMPTS
|
||||
assert all(prompt.description for prompt in result.prompts)
|
||||
assert all(prompt.title for prompt in result.prompts)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completes_authored_prompt_choices(self, mcp_session_factory) -> None:
|
||||
"""Ensures finite prompt choices are available as IDE-style suggestions."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.complete(
|
||||
PromptReference(type="ref/prompt", name="authoring"),
|
||||
{"name": "artifact_type", "value": "pr"},
|
||||
)
|
||||
|
||||
assert result.completion.values == ["prompt"]
|
||||
|
||||
class TestPromptResolution:
|
||||
"""Covers MCP prompts/get behavior using native request and response objects."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gets_prompt_as_native_object(self, mcp_session_factory) -> None:
|
||||
"""Ensures prompts/get resolves a listed prompt into structured message objects."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
resolved_prompt = await mcp_session.get_prompt(
|
||||
name="authoring",
|
||||
arguments={
|
||||
"artifact_type": "skill",
|
||||
"artifact_id": "demo-skill",
|
||||
"goal": "Demonstrate native prompt rendering.",
|
||||
},
|
||||
)
|
||||
|
||||
assert resolved_prompt.messages
|
||||
assert all(message.content for message in resolved_prompt.messages)
|
||||
assert "`artifact_id`: demo-skill" in resolved_prompt.messages[0].content.text
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.smoke
|
||||
|
||||
|
||||
class TestMcpSkillsSurface:
|
||||
"""Covers native skill resources over the HTTP MCP surface."""
|
||||
|
||||
class TestTools:
|
||||
"""Covers absence of deprecated compatibility tools."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_expose_resource_bridge_tools(self, mcp_session_factory) -> None:
|
||||
"""Ensures native resources are not projected through legacy compatibility tools."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.list_tools()
|
||||
|
||||
tool_names = {tool.name for tool in result.tools}
|
||||
assert "search_skills" not in tool_names
|
||||
assert "list_resources" not in tool_names
|
||||
assert "read_resource" not in tool_names
|
||||
|
||||
class TestResources:
|
||||
"""Covers native skill resources, manifests, and file templates."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_main_file_and_manifest(self, mcp_session_factory) -> None:
|
||||
"""Ensures resources/list exposes native skill entry points."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.list_resources()
|
||||
resource_uris = {str(resource.uri) for resource in result.resources}
|
||||
|
||||
assert "skill://mcp-details/SKILL.md" in resource_uris
|
||||
assert "skill://mcp-details/_manifest" in resource_uris
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_resource_templates(self, mcp_session_factory) -> None:
|
||||
"""Ensures supporting files use per-skill wildcard templates."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.list_resource_templates()
|
||||
template_uris = {template.uri_template for template in result.resource_templates}
|
||||
|
||||
assert "skill://mcp-details/{path*}" in template_uris
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reads_manifest_and_supporting_file(self, mcp_session_factory) -> None:
|
||||
"""Ensures manifest paths resolve through the supporting-file template."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
manifest_result = await mcp_session.read_resource("skill://mcp-details/_manifest")
|
||||
manifest = json.loads(manifest_result.contents[0].text)
|
||||
reference = next(file["path"] for file in manifest["files"] if file["path"].startswith("references/"))
|
||||
reference_result = await mcp_session.read_resource(f"skill://mcp-details/{reference}")
|
||||
|
||||
assert manifest["skill"] == "mcp-details"
|
||||
assert reference_result.contents
|
||||
@@ -1104,6 +1104,61 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "personal-mcp"
|
||||
version = "2.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-json-logger" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "zensical" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ipywidgets" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
test = [
|
||||
{ name = "httpx2" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.133.0" },
|
||||
{ name = "fastmcp", specifier = "==4.0.0b4" },
|
||||
{ name = "pydantic-settings", specifier = ">=2" },
|
||||
{ name = "python-json-logger", specifier = ">=4" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
||||
{ name = "zensical", specifier = ">=0.0.45" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "ipywidgets", specifier = ">=8.1.8" },
|
||||
{ name = "pre-commit", specifier = ">=4.6.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.18" },
|
||||
{ name = "ty", specifier = ">=0.0.51" },
|
||||
]
|
||||
test = [
|
||||
{ name = "httpx2", specifier = ">=2.9.1" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pexpect"
|
||||
version = "4.9.0"
|
||||
@@ -1162,61 +1217,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompts"
|
||||
version = "2.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-json-logger" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "zensical" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ipywidgets" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
test = [
|
||||
{ name = "httpx2" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.133.0" },
|
||||
{ name = "fastmcp", specifier = "==4.0.0b4" },
|
||||
{ name = "pydantic-settings", specifier = ">=2" },
|
||||
{ name = "python-json-logger", specifier = ">=4" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
||||
{ name = "zensical", specifier = ">=0.0.45" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "ipywidgets", specifier = ">=8.1.8" },
|
||||
{ name = "pre-commit", specifier = ">=4.6.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.18" },
|
||||
{ name = "ty", specifier = ">=0.0.51" },
|
||||
]
|
||||
test = [
|
||||
{ name = "httpx2", specifier = ">=2.9.1" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.2"
|
||||
|
||||
+114
-109
@@ -13,6 +13,9 @@
|
||||
# Read more: https://zensical.org/docs/setup/basics/#site_name
|
||||
site_name = "Documentation"
|
||||
|
||||
site_dir = "src/personal_mcp/site"
|
||||
docs_dir = "src/personal_mcp/docs"
|
||||
|
||||
# The site_description is included in the HTML head and should contain a
|
||||
# meaningful description of the site content for use by search engines.
|
||||
#
|
||||
@@ -27,7 +30,7 @@ site_author = "<your name here>"
|
||||
# The site_url is the canonical URL for your site. When building online
|
||||
# documentation you should set this.
|
||||
# Read more: https://zensical.org/docs/setup/basics/#site_url
|
||||
#site_url = "https://www.example.com/"
|
||||
site_url = "https://mcp.john-stream.com/docs"
|
||||
|
||||
# The copyright notice appears in the page footer and can contain an HTML
|
||||
# fragment.
|
||||
@@ -44,114 +47,115 @@ Copyright © 2026 The authors
|
||||
# can be defined using TOML syntax.
|
||||
#
|
||||
# Read more: https://zensical.org/docs/setup/navigation/
|
||||
nav = [
|
||||
{ "Home" = "index.md" },
|
||||
{ "Guide" = [
|
||||
{ "Arch" = "architecture.md" },
|
||||
{ "Contracts" = [
|
||||
{ "Overview" = "contracts/index.md" },
|
||||
{ "Prompt" = "contracts/prompt.md" },
|
||||
{ "Skill" = "contracts/skill_contract.md" },
|
||||
{ "Frontmatter" = "contracts/frontmatter.md" },
|
||||
{ "URIs" = "contracts/uris.md" },
|
||||
] },
|
||||
{ "MCP" = "mcp_layout.md" },
|
||||
{ "Copilot" = "copilot.md" },
|
||||
{ "Usage" = "usage.md" },
|
||||
{ "Authoring" = "authoring.md" },
|
||||
{ "Future Work" = "future_work.md" },
|
||||
{ "Testing" = "testing.md" },
|
||||
{ "Security" = "securing.md" },
|
||||
] },
|
||||
{ "Prompts" = [
|
||||
{ "Authoring" = "prompts/authoring/PROMPT.md" },
|
||||
{ "JSFiddle Page Layout" = "prompts/jsfiddle-page-layout/PROMPT.md" },
|
||||
{ "NiceGUI Component Extraction" = "prompts/nicegui-component-extraction/PROMPT.md" },
|
||||
{ "Pytest Fill Scaffold" = "prompts/pytest-fill-scaffold/PROMPT.md" },
|
||||
{ "Pytest Scaffold" = "prompts/pytest-scaffold/PROMPT.md" },
|
||||
{ "Greenfield Architecture" = "prompts/greenfield-architecture/PROMPT.md" },
|
||||
{ "MCP Consumer Repo Shim" = "prompts/mcp-consumer-repo-shim/PROMPT.md" },
|
||||
] },
|
||||
{ "Skills" = [
|
||||
{ "Copilot" = [
|
||||
{ "Overview" = "skills/copilot-customization/SKILL.md" },
|
||||
{ "VS Code" = "skills/copilot-customization/references/vscode-customization.md" },
|
||||
] },
|
||||
{ "VS Code Config" = [
|
||||
{ "Overview" = "skills/vscode-configuration/SKILL.md" },
|
||||
{ "Debug Launch" = "skills/vscode-configuration/references/debug-launch-configurations.md" },
|
||||
{ "FastAPI Debug" = "skills/vscode-configuration/references/fastapi-debugpy-launch.md" },
|
||||
{ "Tasks" = "skills/vscode-configuration/references/tasks-json-configuration.md" },
|
||||
] },
|
||||
{ "FastAPI UV" = [
|
||||
{ "Overview" = "skills/fastapi-uv-docker/SKILL.md" },
|
||||
{ "Best" = "skills/fastapi-uv-docker/references/fastapi-best-practices.md" },
|
||||
{ "Layout" = "skills/fastapi-uv-docker/references/uv-project-layout.md" },
|
||||
{ "Uvicorn" = "skills/fastapi-uv-docker/references/uvicorn-settings.md" },
|
||||
{ "Docker" = "skills/fastapi-uv-docker/references/docker-cloud-native.md" },
|
||||
] },
|
||||
{ "Async SQLA" = [
|
||||
{ "Overview" = "skills/async-fastapi-sqlmodel/SKILL.md" },
|
||||
{ "Engine" = "skills/async-fastapi-sqlmodel/references/engine.md" },
|
||||
{ "Session" = "skills/async-fastapi-sqlmodel/references/session.md" },
|
||||
{ "FastAPI" = "skills/async-fastapi-sqlmodel/references/fastapi.md" },
|
||||
{ "Tx" = "skills/async-fastapi-sqlmodel/references/transactions.md" },
|
||||
{ "SQLModel" = "skills/async-fastapi-sqlmodel/references/sqlmodel.md" },
|
||||
{ "CRUD" = "skills/async-fastapi-sqlmodel/references/crud.md" },
|
||||
{ "Testing" = "skills/async-fastapi-sqlmodel/references/testing.md" },
|
||||
{ "IO" = "skills/async-fastapi-sqlmodel/references/implicit_io.md" },
|
||||
{ "Obs" = "skills/async-fastapi-sqlmodel/references/observability.md" },
|
||||
{ "Template" = "skills/async-fastapi-sqlmodel/references/template.md" },
|
||||
] },
|
||||
{ "NiceGUI" = [
|
||||
{ "Overview" = "skills/nicegui/SKILL.md" },
|
||||
{ "App Architecture" = "skills/nicegui/references/architecture.md" },
|
||||
{ "Startup" = "skills/nicegui/references/fastapi-uvicorn-startup.md" },
|
||||
{ "Visual Styling" = "skills/nicegui/references/styling-and-customization.md" },
|
||||
{ "Component Mechanics" = "skills/nicegui/references/component-mechanics-and-customization.md" },
|
||||
{ "Binding" = "skills/nicegui/references/binding-dataclasses.md" },
|
||||
{ "Flows" = "skills/nicegui/references/interaction-patterns.md" },
|
||||
{ "Quality" = "skills/nicegui/references/troubleshooting-and-quality-gates.md" },
|
||||
{ "Sources" = "skills/nicegui/references/source-documentation.md" },
|
||||
] },
|
||||
{ "Pytest" = [
|
||||
{ "Overview" = "skills/pytesting/SKILL.md" },
|
||||
{ "Docs" = "skills/pytesting/references/pytest-docs.md" },
|
||||
{ "AsyncIO" = "skills/pytesting/references/asyncio-testing.md" },
|
||||
] },
|
||||
{ "MCP Details" = [
|
||||
{ "Overview" = "skills/mcp-details/SKILL.md" },
|
||||
{ "Protocol" = "skills/mcp-details/references/mcp-protocol-and-spec.md" },
|
||||
{ "SDKs and FastMCP" = "skills/mcp-details/references/sdk-and-fastmcp.md" },
|
||||
{ "Ecosystem" = "skills/mcp-details/references/ecosystem-and-tooling.md" },
|
||||
] },
|
||||
{ "Logging" = [
|
||||
{ "Overview" = "skills/python-logging/SKILL.md" },
|
||||
{ "Docs" = "skills/python-logging/references/python-logging-docs.md" },
|
||||
{ "JSON File" = "skills/python-logging/references/json-file-logging.md" },
|
||||
{ "Network" = "skills/python-logging/references/network-logging-minimal-example.md" },
|
||||
{ "HTTPX" = "skills/python-logging/references/httpx-logging-handler-example.md" },
|
||||
] },
|
||||
{ "Pydantic Settings" = [
|
||||
{ "Overview" = "skills/pydantic-settings/SKILL.md" },
|
||||
{ "Source Docs" = "skills/pydantic-settings/references/source-documentation.md" },
|
||||
{ "Workflow" = "skills/pydantic-settings/references/implementation-workflow.md" },
|
||||
] },
|
||||
{ "Ruff" = [
|
||||
{ "Overview" = "skills/ruff-linting-formating/SKILL.md" },
|
||||
{ "Docs" = "skills/ruff-linting-formating/references/ruff-docs.md" },
|
||||
{ "Integrations" = "skills/ruff-linting-formating/references/ruff-integrations.md" },
|
||||
] },
|
||||
{ "Zensical" = [
|
||||
{ "Overview" = "skills/zensical-docs/SKILL.md" },
|
||||
{ "Features" = "skills/zensical-docs/references/zensical-features.md" },
|
||||
{ "Theme" = "skills/zensical-docs/references/theme-customization-and-icons.md" },
|
||||
{ "Quality" = "skills/zensical-docs/references/documentation-quality.md" },
|
||||
{ "IA" = "skills/zensical-docs/references/discoverability-and-ia.md" },
|
||||
{ "API Docs" = "skills/zensical-docs/references/code-heavy-docs-and-mkdocstrings.md" },
|
||||
] },
|
||||
] },
|
||||
]
|
||||
# nav = [
|
||||
# { "Home" = "index.md" },
|
||||
# { "Guide" = [
|
||||
# { "Arch" = "architecture.md" },
|
||||
# { "Contracts" = [
|
||||
# { "Overview" = "contracts/index.md" },
|
||||
# { "Prompt" = "contracts/prompt.md" },
|
||||
# { "Skill" = "contracts/skill_contract.md" },
|
||||
# { "Frontmatter" = "contracts/frontmatter.md" },
|
||||
# { "URIs" = "contracts/uris.md" },
|
||||
# ] },
|
||||
# { "MCP" = "mcp_layout.md" },
|
||||
# { "Copilot" = "copilot.md" },
|
||||
# { "Usage" = "usage.md" },
|
||||
# { "Authoring" = "authoring.md" },
|
||||
# { "Future Work" = "future_work.md" },
|
||||
# { "Testing" = "testing.md" },
|
||||
# { "Security" = "securing.md" },
|
||||
# ] },
|
||||
# { "Prompts" = [
|
||||
# { "Authoring" = "prompts/authoring/PROMPT.md" },
|
||||
# { "JSFiddle Page Layout" = "prompts/jsfiddle-page-layout/PROMPT.md" },
|
||||
# { "NiceGUI Component Extraction" = "prompts/nicegui-component-extraction/PROMPT.md" },
|
||||
# { "Pytest Fill Scaffold" = "prompts/pytest-fill-scaffold/PROMPT.md" },
|
||||
# { "Pytest Scaffold" = "prompts/pytest-scaffold/PROMPT.md" },
|
||||
# { "Greenfield Architecture" = "prompts/greenfield-architecture/PROMPT.md" },
|
||||
# { "MCP Consumer Repo Shim" = "prompts/mcp-consumer-repo-shim/PROMPT.md" },
|
||||
# ] },
|
||||
# { "Skills" = [
|
||||
# { "Copilot" = [
|
||||
# { "Overview" = "skills/copilot-customization/SKILL.md" },
|
||||
# { "VS Code" = "skills/copilot-customization/references/vscode-customization.md" },
|
||||
# ] },
|
||||
# { "VS Code Config" = [
|
||||
# { "Overview" = "skills/vscode-configuration/SKILL.md" },
|
||||
# { "Debug Launch" = "skills/vscode-configuration/references/debug-launch-configurations.md" },
|
||||
# { "FastAPI Debug" = "skills/vscode-configuration/references/fastapi-debugpy-launch.md" },
|
||||
# { "Tasks" = "skills/vscode-configuration/references/tasks-json-configuration.md" },
|
||||
# ] },
|
||||
# { "FastAPI UV" = [
|
||||
# { "Overview" = "skills/fastapi-uv-docker/SKILL.md" },
|
||||
# { "Best" = "skills/fastapi-uv-docker/references/fastapi-best-practices.md" },
|
||||
# { "Layout" = "skills/fastapi-uv-docker/references/uv-project-layout.md" },
|
||||
# { "Uvicorn" = "skills/fastapi-uv-docker/references/uvicorn-settings.md" },
|
||||
# { "Docker" = "skills/fastapi-uv-docker/references/docker-cloud-native.md" },
|
||||
# ] },
|
||||
# { "Async SQLA" = [
|
||||
# { "Overview" = "skills/async-fastapi-sqlmodel/SKILL.md" },
|
||||
# { "Engine" = "skills/async-fastapi-sqlmodel/references/engine.md" },
|
||||
# { "Session" = "skills/async-fastapi-sqlmodel/references/session.md" },
|
||||
# { "FastAPI" = "skills/async-fastapi-sqlmodel/references/fastapi.md" },
|
||||
# { "Tx" = "skills/async-fastapi-sqlmodel/references/transactions.md" },
|
||||
# { "SQLModel" = "skills/async-fastapi-sqlmodel/references/sqlmodel.md" },
|
||||
# { "CRUD" = "skills/async-fastapi-sqlmodel/references/crud.md" },
|
||||
# { "Testing" = "skills/async-fastapi-sqlmodel/references/testing.md" },
|
||||
# { "IO" = "skills/async-fastapi-sqlmodel/references/implicit_io.md" },
|
||||
# { "Obs" = "skills/async-fastapi-sqlmodel/references/observability.md" },
|
||||
# { "Template" = "skills/async-fastapi-sqlmodel/references/template.md" },
|
||||
# ] },
|
||||
# { "NiceGUI" = [
|
||||
# { "Overview" = "skills/nicegui/SKILL.md" },
|
||||
# { "App Architecture" = "skills/nicegui/references/architecture.md" },
|
||||
# { "Startup" = "skills/nicegui/references/fastapi-uvicorn-startup.md" },
|
||||
# { "Visual Styling" = "skills/nicegui/references/styling-and-customization.md" },
|
||||
# { "Component Mechanics" = "skills/nicegui/references/component-mechanics.md" },
|
||||
# { "Tables" = "skills/nicegui/references/tables.md" },
|
||||
# { "Binding" = "skills/nicegui/references/binding-dataclasses.md" },
|
||||
# { "Flows" = "skills/nicegui/references/interaction-patterns.md" },
|
||||
# { "Quality" = "skills/nicegui/references/troubleshooting-and-quality-gates.md" },
|
||||
# { "Sources" = "skills/nicegui/references/source-documentation.md" },
|
||||
# ] },
|
||||
# { "Pytest" = [
|
||||
# { "Overview" = "skills/pytesting/SKILL.md" },
|
||||
# { "Docs" = "skills/pytesting/references/pytest-docs.md" },
|
||||
# { "AsyncIO" = "skills/pytesting/references/asyncio-testing.md" },
|
||||
# ] },
|
||||
# { "MCP Details" = [
|
||||
# { "Overview" = "skills/mcp-details/SKILL.md" },
|
||||
# { "Protocol" = "skills/mcp-details/references/mcp-protocol-and-spec.md" },
|
||||
# { "SDKs and FastMCP" = "skills/mcp-details/references/sdk-and-fastmcp.md" },
|
||||
# { "Ecosystem" = "skills/mcp-details/references/ecosystem-and-tooling.md" },
|
||||
# ] },
|
||||
# { "Logging" = [
|
||||
# { "Overview" = "skills/python-logging/SKILL.md" },
|
||||
# { "Docs" = "skills/python-logging/references/python-logging-docs.md" },
|
||||
# { "JSON File" = "skills/python-logging/references/json-file-logging.md" },
|
||||
# { "Network" = "skills/python-logging/references/network-logging-minimal-example.md" },
|
||||
# { "HTTPX" = "skills/python-logging/references/httpx-logging-handler-example.md" },
|
||||
# ] },
|
||||
# { "Pydantic Settings" = [
|
||||
# { "Overview" = "skills/pydantic-settings/SKILL.md" },
|
||||
# { "Source Docs" = "skills/pydantic-settings/references/source-documentation.md" },
|
||||
# { "Workflow" = "skills/pydantic-settings/references/implementation-workflow.md" },
|
||||
# ] },
|
||||
# { "Ruff" = [
|
||||
# { "Overview" = "skills/ruff-linting-formating/SKILL.md" },
|
||||
# { "Docs" = "skills/ruff-linting-formating/references/ruff-docs.md" },
|
||||
# { "Integrations" = "skills/ruff-linting-formating/references/ruff-integrations.md" },
|
||||
# ] },
|
||||
# { "Zensical" = [
|
||||
# { "Overview" = "skills/zensical-docs/SKILL.md" },
|
||||
# { "Features" = "skills/zensical-docs/references/zensical-features.md" },
|
||||
# { "Theme" = "skills/zensical-docs/references/theme-customization-and-icons.md" },
|
||||
# { "Quality" = "skills/zensical-docs/references/documentation-quality.md" },
|
||||
# { "IA" = "skills/zensical-docs/references/discoverability-and-ia.md" },
|
||||
# { "API Docs" = "skills/zensical-docs/references/code-heavy-docs-and-mkdocstrings.md" },
|
||||
# ] },
|
||||
# ] },
|
||||
# ]
|
||||
|
||||
# With the "extra_css" option you can add your own CSS styling to customize
|
||||
# your Zensical project according to your needs. You can add any number of
|
||||
@@ -441,6 +445,7 @@ anchor_linenums = true
|
||||
line_spans = "__span"
|
||||
pygments_lang_class = true
|
||||
[project.markdown_extensions.pymdownx.inlinehilite]
|
||||
[project.markdown_extensions.pymdownx.snippets]
|
||||
[project.markdown_extensions.pymdownx.keys]
|
||||
[project.markdown_extensions.pymdownx.magiclink]
|
||||
[project.markdown_extensions.pymdownx.mark]
|
||||
|
||||
Reference in New Issue
Block a user