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.prompts import create_prompts_provider
from personal_mcp.registry.load import get_docs_registry from personal_mcp.registry.load import get_docs_registry
from personal_mcp.registry.models.registry import DocsRegistry from personal_mcp.registry.load import read_docs_markdown_path
from personal_mcp.registry.read import read_docs_markdown_path from personal_mcp.registry.models import DocsRegistry
from personal_mcp.skills import create_skills_provider from personal_mcp.skills import create_skills_provider
+2 -2
View File
@@ -6,8 +6,8 @@ from typing import Any
import yaml import yaml
from pydantic import ValidationError from pydantic import ValidationError
from personal_mcp.prompts.models import PromptDefinition from .models import PromptDefinition
from personal_mcp.prompts.models import PromptMetadata from .models import PromptMetadata
_PROMPT_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$") _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) _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.prompts import Prompt
from fastmcp.server.providers import Provider from fastmcp.server.providers import Provider
from personal_mcp.prompts.content import load_prompt_definition from .content import load_prompt_definition
from personal_mcp.prompts.models import MarkdownPrompt from .models import MarkdownPrompt
class MarkdownPromptsProvider(Provider): 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 __future__ import annotations
from collections.abc import Iterator
from functools import cache from functools import cache
from importlib.resources import files from importlib.resources import files
from importlib.resources.abc import Traversable
from pathlib import PurePosixPath
from personal_mcp.registry.ingest.document import MarkdownDocument from .models import DocsPath
from personal_mcp.registry.models.registry import DocsRegistry from .models import DocsRegistry
from .models import parse_docs_path
@cache @cache
@@ -12,11 +16,49 @@ def get_docs_registry() -> DocsRegistry:
root = files("personal_mcp").joinpath("docs") root = files("personal_mcp").joinpath("docs")
if not root.is_dir(): if not root.is_dir():
raise FileNotFoundError(f"docs root does not exist or is not a directory: {root}") 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( return DocsRegistry(
docs_markdown_by_path=docs_markdown_by_path, docs_markdown_by_path=docs_markdown_by_path,
docs_markdown_path_index=tuple(sorted(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 BaseModel
from pydantic import BeforeValidator from pydantic import BeforeValidator
from pydantic import ConfigDict from pydantic import ConfigDict
from pydantic import Field
from pydantic import field_validator
__all__ = ["DocsRegistry"]
class StrictFrozenModel(BaseModel):
"""Immutable base model with strict field validation rules."""
model_config: ClassVar[ConfigDict] = ConfigDict(
extra="forbid",
frozen=True,
str_strip_whitespace=True,
)
def frozen_mapping[K, V](value: Mapping[K, V] | None = None) -> Mapping[K, V]: 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)] 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 import pytest
from personal_mcp.registry.ingest.document import MarkdownDocument from personal_mcp.registry.load import load_markdown
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
class TestMarkdownDocument: class TestLoadMarkdown:
"""Covers recursive Markdown discovery and loading.""" """Covers recursive Markdown discovery and loading."""
def test_loads_markdown_in_stable_order(self, tmp_path: Path) -> None: 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 / "a.md").write_text("alpha\n", encoding="utf-8")
(nested / "ignored.txt").write_text("ignored\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) == [ assert list(docs) == [
PurePosixPath("guides/a.md"), PurePosixPath("guides/a.md"),
PurePosixPath("guides/b.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 from pathlib import PurePosixPath
import pytest import pytest
from personal_mcp.registry.models.registry import DocsRegistry 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 pytestmark = pytest.mark.unit