This commit is contained in:
John Lancaster
2026-08-07 20:47:37 -05:00
parent 88ff4c2c71
commit 5005cd7001
14 changed files with 92 additions and 121 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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<yaml>.*?)\r?\n---(?:\r?\n|$)", re.DOTALL)
+2 -2
View File
@@ -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):
+7
View File
@@ -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"]
-3
View File
@@ -1,3 +0,0 @@
from .models.registry import DocsRegistry
__all__ = ["DocsRegistry"]
@@ -1 +0,0 @@
"""Functions to produce immutable dataclasses representing the document registry."""
@@ -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
+46 -4
View File
@@ -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],
}
@@ -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,
)
@@ -1 +0,0 @@
"""Pydantic models for the document registry."""
@@ -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)
-16
View File
@@ -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],
}
@@ -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"
+2 -2
View File
@@ -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