From 5005cd70010cb758a6893ec135695690901fcb98 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:38:26 -0500 Subject: [PATCH] reorg --- src/personal_mcp/mcp.py | 4 +- src/personal_mcp/prompts/content.py | 4 +- src/personal_mcp/prompts/provider.py | 4 +- src/personal_mcp/registry/__init__.py | 7 +++ src/personal_mcp/registry/contracts.py | 3 -- src/personal_mcp/registry/ingest/__init__.py | 1 - src/personal_mcp/registry/ingest/document.py | 49 ------------------ src/personal_mcp/registry/load.py | 50 +++++++++++++++++-- .../registry/{models/common.py => models.py} | 36 +++++++++---- src/personal_mcp/registry/models/__init__.py | 1 - src/personal_mcp/registry/models/registry.py | 26 ---------- src/personal_mcp/registry/read.py | 16 ------ .../{ingest/test_document.py => test_load.py} | 8 +-- tests/registry/test_read.py | 4 +- 14 files changed, 92 insertions(+), 121 deletions(-) delete mode 100644 src/personal_mcp/registry/contracts.py delete mode 100644 src/personal_mcp/registry/ingest/__init__.py delete mode 100644 src/personal_mcp/registry/ingest/document.py rename src/personal_mcp/registry/{models/common.py => models.py} (61%) delete mode 100644 src/personal_mcp/registry/models/__init__.py delete mode 100644 src/personal_mcp/registry/models/registry.py delete mode 100644 src/personal_mcp/registry/read.py rename tests/registry/{ingest/test_document.py => test_load.py} (74%) diff --git a/src/personal_mcp/mcp.py b/src/personal_mcp/mcp.py index 64e0ed4..cabf781 100644 --- a/src/personal_mcp/mcp.py +++ b/src/personal_mcp/mcp.py @@ -4,8 +4,8 @@ from fastmcp import FastMCP from personal_mcp.prompts import create_prompts_provider from personal_mcp.registry.load import get_docs_registry -from personal_mcp.registry.models.registry import DocsRegistry -from personal_mcp.registry.read import read_docs_markdown_path +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 diff --git a/src/personal_mcp/prompts/content.py b/src/personal_mcp/prompts/content.py index b764cb9..c015640 100644 --- a/src/personal_mcp/prompts/content.py +++ b/src/personal_mcp/prompts/content.py @@ -6,8 +6,8 @@ from typing import Any import yaml from pydantic import ValidationError -from personal_mcp.prompts.models import PromptDefinition -from personal_mcp.prompts.models import PromptMetadata +from .models import PromptDefinition +from .models import PromptMetadata _PROMPT_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$") _FRONTMATTER_RE = re.compile(r"\A---\r?\n(?P.*?)\r?\n---(?:\r?\n|$)", re.DOTALL) diff --git a/src/personal_mcp/prompts/provider.py b/src/personal_mcp/prompts/provider.py index 73c1b4d..b84e0dd 100644 --- a/src/personal_mcp/prompts/provider.py +++ b/src/personal_mcp/prompts/provider.py @@ -5,8 +5,8 @@ from importlib.resources.abc import Traversable from fastmcp.prompts import Prompt from fastmcp.server.providers import Provider -from personal_mcp.prompts.content import load_prompt_definition -from personal_mcp.prompts.models import MarkdownPrompt +from .content import load_prompt_definition +from .models import MarkdownPrompt class MarkdownPromptsProvider(Provider): diff --git a/src/personal_mcp/registry/__init__.py b/src/personal_mcp/registry/__init__.py index e69de29..c0c5d2b 100644 --- a/src/personal_mcp/registry/__init__.py +++ b/src/personal_mcp/registry/__init__.py @@ -0,0 +1,7 @@ +from . import load as load +from . import models as models +from .load import get_docs_registry +from .models import DocsPath +from .models import DocsRegistry + +__all__ = ["DocsPath", "DocsRegistry", "get_docs_registry", "load", "models"] diff --git a/src/personal_mcp/registry/contracts.py b/src/personal_mcp/registry/contracts.py deleted file mode 100644 index 8042589..0000000 --- a/src/personal_mcp/registry/contracts.py +++ /dev/null @@ -1,3 +0,0 @@ -from .models.registry import DocsRegistry - -__all__ = ["DocsRegistry"] diff --git a/src/personal_mcp/registry/ingest/__init__.py b/src/personal_mcp/registry/ingest/__init__.py deleted file mode 100644 index f225055..0000000 --- a/src/personal_mcp/registry/ingest/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Functions to produce immutable dataclasses representing the document registry.""" diff --git a/src/personal_mcp/registry/ingest/document.py b/src/personal_mcp/registry/ingest/document.py deleted file mode 100644 index 561bf1a..0000000 --- a/src/personal_mcp/registry/ingest/document.py +++ /dev/null @@ -1,49 +0,0 @@ -from collections.abc import Iterator -from dataclasses import dataclass -from importlib.resources.abc import Traversable -from itertools import starmap -from pathlib import PurePosixPath -from typing import Self - -from personal_mcp.registry.models.common import DocsPath -from personal_mcp.registry.models.common import parse_docs_path - - -@dataclass(frozen=True, slots=True) -class MarkdownDocument: - """Represents a loaded markdown document with its content and frontmatter.""" - - relpath: DocsPath - """The relative path of the document within the package resources.""" - content: str - """The raw markdown content of the document.""" - - def __post_init__(self) -> None: - object.__setattr__(self, "relpath", parse_docs_path(self.relpath)) - - @classmethod - def from_root(cls, root: Traversable) -> dict[DocsPath, Self]: - """Recursively load all markdown documents from the root resource.""" - mapped = starmap(cls.from_resource, walk_resources(root)) - return {d.relpath: d for d in mapped} - - @classmethod - def from_resource(cls, relpath: DocsPath, resource: Traversable) -> Self: - """Load a markdown document from a package resource.""" - return cls(relpath=relpath, content=resource.read_text(encoding="utf-8")) - - -def walk_resources( - node: Traversable, - *, - suffix: str = ".md", - prefix: PurePosixPath | None = None, -) -> Iterator[tuple[PurePosixPath, Traversable]]: - """Recursively yield all resources in node, with their full path.""" - prefix = prefix or PurePosixPath() - for child in sorted(node.iterdir(), key=lambda item: item.name): - relpath = prefix / child.name - if child.is_dir(): - yield from walk_resources(child, suffix=suffix, prefix=relpath) - elif child.is_file() and child.name.lower().endswith(suffix): - yield relpath, child diff --git a/src/personal_mcp/registry/load.py b/src/personal_mcp/registry/load.py index 5e61f1f..5137f35 100644 --- a/src/personal_mcp/registry/load.py +++ b/src/personal_mcp/registry/load.py @@ -1,10 +1,14 @@ from __future__ import annotations +from collections.abc import Iterator from functools import cache from importlib.resources import files +from importlib.resources.abc import Traversable +from pathlib import PurePosixPath -from personal_mcp.registry.ingest.document import MarkdownDocument -from personal_mcp.registry.models.registry import DocsRegistry +from .models import DocsPath +from .models import DocsRegistry +from .models import parse_docs_path @cache @@ -12,11 +16,49 @@ def get_docs_registry() -> DocsRegistry: root = files("personal_mcp").joinpath("docs") if not root.is_dir(): raise FileNotFoundError(f"docs root does not exist or is not a directory: {root}") - docs = MarkdownDocument.from_root(root) + docs = load_markdown(root) - docs_markdown_by_path = {relpath: doc.content for relpath, doc in docs.items() if relpath.parts[0] != "skills"} + docs_markdown_by_path = {relpath: content for relpath, content in docs.items() if relpath.parts[0] != "skills"} return DocsRegistry( docs_markdown_by_path=docs_markdown_by_path, docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)), ) + + +def load_markdown(root: Traversable) -> dict[DocsPath, str]: + """Recursively load Markdown content keyed by package-relative path.""" + return { + parse_docs_path(relpath): resource.read_text(encoding="utf-8") + for relpath, resource in walk_resources(root) + } # fmt: skip + + +def walk_resources( + node: Traversable, + *, + suffix: str = ".md", + prefix: PurePosixPath | None = None, +) -> Iterator[tuple[PurePosixPath, Traversable]]: + """Recursively yield all resources in node, with their full path.""" + prefix = prefix or PurePosixPath() + for child in sorted(node.iterdir(), key=lambda item: item.name): + relpath = prefix / child.name + if child.is_dir(): + yield from walk_resources(child, suffix=suffix, prefix=relpath) + elif child.is_file() and child.name.lower().endswith(suffix): + yield relpath, child + + +def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]: + docs_path = parse_docs_path(path) + if docs_path.parts[0] == "skills": + raise KeyError(f"unknown docs path: {docs_path.as_posix()}") + if docs_path not in registry.docs_markdown_by_path: + raise KeyError(f"unknown docs path: {docs_path.as_posix()}") + return { + "uri": f"resource://docs/{docs_path.as_posix()}", + "format": "markdown", + "source_path": f"docs/{docs_path.as_posix()}", + "content": registry.docs_markdown_by_path[docs_path], + } diff --git a/src/personal_mcp/registry/models/common.py b/src/personal_mcp/registry/models.py similarity index 61% rename from src/personal_mcp/registry/models/common.py rename to src/personal_mcp/registry/models.py index 38d2827..a962ca1 100644 --- a/src/personal_mcp/registry/models/common.py +++ b/src/personal_mcp/registry/models.py @@ -7,16 +7,10 @@ from typing import ClassVar from pydantic import BaseModel from pydantic import BeforeValidator from pydantic import ConfigDict +from pydantic import Field +from pydantic import field_validator - -class StrictFrozenModel(BaseModel): - """Immutable base model with strict field validation rules.""" - - model_config: ClassVar[ConfigDict] = ConfigDict( - extra="forbid", - frozen=True, - str_strip_whitespace=True, - ) +__all__ = ["DocsRegistry"] def frozen_mapping[K, V](value: Mapping[K, V] | None = None) -> Mapping[K, V]: @@ -39,3 +33,27 @@ def parse_docs_path(value: str | PurePosixPath) -> PurePosixPath: type DocsPath = Annotated[PurePosixPath, BeforeValidator(parse_docs_path)] + + +def _empty_docs_mapping() -> Mapping[DocsPath, str]: + return frozen_mapping() + + +class DocsRegistry(BaseModel): + """In-memory index of documentation content.""" + + docs_markdown_by_path: Mapping[DocsPath, str] = Field(default_factory=_empty_docs_mapping) + """Maps each documentation path to its loaded Markdown content.""" + docs_markdown_path_index: tuple[DocsPath, ...] + """Lists documentation paths in deterministic index order.""" + + @field_validator("docs_markdown_by_path", mode="before") + @classmethod + def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]: + return frozen_mapping(value) + + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", + frozen=True, + str_strip_whitespace=True, + ) diff --git a/src/personal_mcp/registry/models/__init__.py b/src/personal_mcp/registry/models/__init__.py deleted file mode 100644 index b322aaa..0000000 --- a/src/personal_mcp/registry/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Pydantic models for the document registry.""" diff --git a/src/personal_mcp/registry/models/registry.py b/src/personal_mcp/registry/models/registry.py deleted file mode 100644 index c76a4a5..0000000 --- a/src/personal_mcp/registry/models/registry.py +++ /dev/null @@ -1,26 +0,0 @@ -from collections.abc import Mapping - -from pydantic import Field -from pydantic import field_validator - -from .common import DocsPath -from .common import StrictFrozenModel -from .common import frozen_mapping - - -def _empty_docs_mapping() -> Mapping[DocsPath, str]: - return frozen_mapping() - - -class DocsRegistry(StrictFrozenModel): - """In-memory index of documentation content.""" - - docs_markdown_by_path: Mapping[DocsPath, str] = Field(default_factory=_empty_docs_mapping) - """Maps each documentation path to its loaded Markdown content.""" - docs_markdown_path_index: tuple[DocsPath, ...] - """Lists documentation paths in deterministic index order.""" - - @field_validator("docs_markdown_by_path", mode="before") - @classmethod - def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]: - return frozen_mapping(value) diff --git a/src/personal_mcp/registry/read.py b/src/personal_mcp/registry/read.py deleted file mode 100644 index 1831f07..0000000 --- a/src/personal_mcp/registry/read.py +++ /dev/null @@ -1,16 +0,0 @@ -from .models.common import parse_docs_path -from .models.registry import DocsRegistry - - -def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]: - docs_path = parse_docs_path(path) - if docs_path.parts[0] == "skills": - raise KeyError(f"unknown docs path: {docs_path.as_posix()}") - if docs_path not in registry.docs_markdown_by_path: - raise KeyError(f"unknown docs path: {docs_path.as_posix()}") - return { - "uri": f"resource://docs/{docs_path.as_posix()}", - "format": "markdown", - "source_path": f"docs/{docs_path.as_posix()}", - "content": registry.docs_markdown_by_path[docs_path], - } diff --git a/tests/registry/ingest/test_document.py b/tests/registry/test_load.py similarity index 74% rename from tests/registry/ingest/test_document.py rename to tests/registry/test_load.py index 74026a7..b54e7ed 100644 --- a/tests/registry/ingest/test_document.py +++ b/tests/registry/test_load.py @@ -3,12 +3,12 @@ from pathlib import PurePosixPath import pytest -from personal_mcp.registry.ingest.document import MarkdownDocument +from personal_mcp.registry.load import load_markdown pytestmark = pytest.mark.unit -class TestMarkdownDocument: +class TestLoadMarkdown: """Covers recursive Markdown discovery and loading.""" def test_loads_markdown_in_stable_order(self, tmp_path: Path) -> None: @@ -18,10 +18,10 @@ class TestMarkdownDocument: (nested / "a.md").write_text("alpha\n", encoding="utf-8") (nested / "ignored.txt").write_text("ignored\n", encoding="utf-8") - docs = MarkdownDocument.from_root(tmp_path) + docs = load_markdown(tmp_path) assert list(docs) == [ PurePosixPath("guides/a.md"), PurePosixPath("guides/b.md"), ] - assert docs[PurePosixPath("guides/b.md")].content == "caf\u00e9\n" + assert docs[PurePosixPath("guides/b.md")] == "caf\u00e9\n" diff --git a/tests/registry/test_read.py b/tests/registry/test_read.py index 1a82317..535db1e 100644 --- a/tests/registry/test_read.py +++ b/tests/registry/test_read.py @@ -1,9 +1,9 @@ from pathlib import PurePosixPath import pytest - from personal_mcp.registry.models.registry import DocsRegistry -from personal_mcp.registry.read import read_docs_markdown_path + +from personal_mcp.registry.load import read_docs_markdown_path pytestmark = pytest.mark.unit