diff --git a/.github/instructions/pytesting.instructions.md b/.github/instructions/pytesting.instructions.md index 4190c79..8c86dc7 100644 --- a/.github/instructions/pytesting.instructions.md +++ b/.github/instructions/pytesting.instructions.md @@ -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. diff --git a/docs b/docs deleted file mode 120000 index 834956d..0000000 --- a/docs +++ /dev/null @@ -1 +0,0 @@ -src/personal_mcp/docs \ No newline at end of file diff --git a/src/personal_mcp/app.py b/src/personal_mcp/app.py new file mode 100644 index 0000000..7da1a63 --- /dev/null +++ b/src/personal_mcp/app.py @@ -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, + ) diff --git a/src/personal_mcp/config.py b/src/personal_mcp/config.py index 9496911..2aea6cd 100644 --- a/src/personal_mcp/config.py +++ b/src/personal_mcp/config.py @@ -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 diff --git a/src/personal_mcp/docs/skills/nicegui/examples/editable_table.py b/src/personal_mcp/docs/skills/nicegui/examples/editable_table.py new file mode 100755 index 0000000..53b906a --- /dev/null +++ b/src/personal_mcp/docs/skills/nicegui/examples/editable_table.py @@ -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) diff --git a/src/personal_mcp/mcp.py b/src/personal_mcp/mcp.py index d1b76c6..d736589 100644 --- a/src/personal_mcp/mcp.py +++ b/src/personal_mcp/mcp.py @@ -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() diff --git a/src/personal_mcp/prompts/__init__.py b/src/personal_mcp/prompts/__init__.py index 0f13a94..7e5abbf 100644 --- a/src/personal_mcp/prompts/__init__.py +++ b/src/personal_mcp/prompts/__init__.py @@ -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", +] diff --git a/src/personal_mcp/prompts/models.py b/src/personal_mcp/prompts/models.py index 188c496..34ab60b 100644 --- a/src/personal_mcp/prompts/models.py +++ b/src/personal_mcp/prompts/models.py @@ -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) diff --git a/src/personal_mcp/prompts/provider.py b/src/personal_mcp/prompts/provider.py index b84e0dd..b9be600 100644 --- a/src/personal_mcp/prompts/provider.py +++ b/src/personal_mcp/prompts/provider.py @@ -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) diff --git a/src/personal_mcp/registry/models.py b/src/personal_mcp/registry/models.py index a962ca1..fbd526b 100644 --- a/src/personal_mcp/registry/models.py +++ b/src/personal_mcp/registry/models.py @@ -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.""" diff --git a/src/personal_mcp/skills.py b/src/personal_mcp/skills.py index e6cd8f8..dc95c88 100644 --- a/src/personal_mcp/skills.py +++ b/src/personal_mcp/skills.py @@ -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) diff --git a/src/personal_mcp/web/__init__.py b/src/personal_mcp/web/__init__.py deleted file mode 100644 index 377a9c2..0000000 --- a/src/personal_mcp/web/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""FastAPI web runtime for personal MCP.""" diff --git a/src/personal_mcp/web/app.py b/src/personal_mcp/web/app.py deleted file mode 100644 index 1f058ba..0000000 --- a/src/personal_mcp/web/app.py +++ /dev/null @@ -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 diff --git a/src/personal_mcp/web/docs_mount.py b/src/personal_mcp/web/docs_mount.py deleted file mode 100644 index 9a7eb67..0000000 --- a/src/personal_mcp/web/docs_mount.py +++ /dev/null @@ -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, - ) diff --git a/src/personal_mcp/web/health.py b/src/personal_mcp/web/health.py deleted file mode 100644 index 3576de8..0000000 --- a/src/personal_mcp/web/health.py +++ /dev/null @@ -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"} diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index d12aadb..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test package marker for intra-suite imports.""" diff --git a/tests/conftest.py b/tests/conftest.py index 1d9929c..e69de29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +0,0 @@ -from __future__ import annotations - -# Global lightweight fixtures can be added here as the suite grows. diff --git a/tests/prompts/test_content_renderer.py b/tests/prompts/test_content_renderer.py deleted file mode 100644 index 9c17d6e..0000000 --- a/tests/prompts/test_content_renderer.py +++ /dev/null @@ -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) diff --git a/tests/prompts/test_filesystem_provider.py b/tests/prompts/test_filesystem_provider.py deleted file mode 100644 index 38e0a10..0000000 --- a/tests/prompts/test_filesystem_provider.py +++ /dev/null @@ -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() == [] diff --git a/tests/registry/ingest/test_current_docs.py b/tests/registry/ingest/test_current_docs.py deleted file mode 100644 index a6adad2..0000000 --- a/tests/registry/ingest/test_current_docs.py +++ /dev/null @@ -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) diff --git a/tests/registry/models/test_document_validation.py b/tests/registry/models/test_document_validation.py deleted file mode 100644 index c1a9132..0000000 --- a/tests/registry/models/test_document_validation.py +++ /dev/null @@ -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) diff --git a/tests/registry/test_load.py b/tests/registry/test_load.py deleted file mode 100644 index b54e7ed..0000000 --- a/tests/registry/test_load.py +++ /dev/null @@ -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" diff --git a/tests/registry/test_read.py b/tests/registry/test_read.py deleted file mode 100644 index ebb5092..0000000 --- a/tests/registry/test_read.py +++ /dev/null @@ -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") diff --git a/tests/skills/test_provider.py b/tests/skills/test_provider.py deleted file mode 100644 index 88bf952..0000000 --- a/tests/skills/test_provider.py +++ /dev/null @@ -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"] diff --git a/tests/web/conftest.py b/tests/web/conftest.py deleted file mode 100644 index ff2e3ec..0000000 --- a/tests/web/conftest.py +++ /dev/null @@ -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 diff --git a/tests/web/test_endpoint_connections.py b/tests/web/test_endpoint_connections.py deleted file mode 100644 index 01e52ba..0000000 --- a/tests/web/test_endpoint_connections.py +++ /dev/null @@ -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 diff --git a/tests/web/test_mcp_prompts.py b/tests/web/test_mcp_prompts.py deleted file mode 100644 index 2309df9..0000000 --- a/tests/web/test_mcp_prompts.py +++ /dev/null @@ -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:///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 diff --git a/tests/web/test_mcp_skills.py b/tests/web/test_mcp_skills.py deleted file mode 100644 index be1064b..0000000 --- a/tests/web/test_mcp_skills.py +++ /dev/null @@ -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 diff --git a/uv.lock b/uv.lock index 6030cc1..aefbb54 100644 --- a/uv.lock +++ b/uv.lock @@ -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" diff --git a/zensical.toml b/zensical.toml index a574fdd..e2b9188 100644 --- a/zensical.toml +++ b/zensical.toml @@ -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 = "" # 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]