WIP simplifying load/startup

This commit is contained in:
John Lancaster
2026-07-26 17:23:39 -05:00
parent 5e20f69cfe
commit 42ea105bee
30 changed files with 855 additions and 766 deletions
+1 -1
View File
@@ -115,7 +115,7 @@ def build_skill_detail_payload(registry: DocsRegistry, skill_id: str) -> dict[st
"uri": ref.uri,
"mime_type": ref.mime_type,
"title": ref.title,
"path": ref.relpath,
"path": ref.relpath.as_posix(),
}
for ref_id, ref in sorted(skill.references.items())
},
-10
View File
@@ -1,5 +1,4 @@
from functools import cache
from importlib.resources import files
from pathlib import Path
from typing import Literal
@@ -13,17 +12,8 @@ DEFAULT_ENV_FILE = Path(".env").resolve()
_REPO_ROOT = Path(__file__).resolve().parents[2]
@cache
def _package_docs_dir() -> Path:
docs_dir = files("personal_mcp").joinpath("docs")
if not isinstance(docs_dir, Path):
raise TypeError("personal_mcp docs must be installed as a filesystem directory")
return docs_dir
class Mounts(BaseModel):
docs: str = "/docs"
docs_dir: DirectoryPath = Field(default_factory=_package_docs_dir)
mcp: str = "/mcp"
+1
View File
@@ -0,0 +1 @@
../../docs
+3 -3
View File
@@ -1,9 +1,9 @@
from fastapi import FastAPI
from personal_mcp.mcp import mcp
from personal_mcp.mcp import create_mcp
from personal_mcp.web.app import create_app as create_fastapi
__all__ = ["create_app", "main", "mcp"]
__all__ = ["create_app", "main"]
def create_app() -> FastAPI:
@@ -13,7 +13,7 @@ def create_app() -> FastAPI:
def main() -> None:
"""Run the root MCP server."""
mcp.run()
create_mcp().run()
if __name__ == "__main__":
+172 -187
View File
@@ -20,22 +20,15 @@ from personal_mcp.catalog.server import get_pattern_by_id_payload
from personal_mcp.catalog.server import get_prompt_by_id_payload
from personal_mcp.catalog.server import search_patterns_payload
from personal_mcp.catalog.server import search_prompts_payload
from personal_mcp.registry.load import load_docs_registry
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.read import read_prompt_document
from personal_mcp.registry.read import read_skill_document
from personal_mcp.registry.read import read_skill_reference
DOCS_ROOT = os.getenv("PERSONAL_MCP_DOCS_ROOT", "../../docs")
TOOL_SEARCH_MODE = os.getenv("PERSONAL_MCP_TOOL_SEARCH", "none").strip().lower()
TOOL_SEARCH_MAX_RESULTS = os.getenv("PERSONAL_MCP_TOOL_SEARCH_MAX_RESULTS", "5")
REGISTRY: DocsRegistry = load_docs_registry(
package_anchor="personal_mcp",
docs_root=DOCS_ROOT,
)
mcp = FastMCP("personal-mcp", on_duplicate="error")
def _parse_positive_int(value: str, *, env_name: str) -> int:
@@ -48,7 +41,7 @@ def _parse_positive_int(value: str, *, env_name: str) -> int:
return parsed
def _install_tool_fallback_transforms() -> None:
def _install_tool_fallback_transforms(mcp: FastMCP) -> None:
# Expose list_resources/read_resource for tool-only clients.
mcp.add_transform(ResourcesAsTools(mcp))
@@ -95,9 +88,9 @@ def _make_prompt_handler(content: str):
return prompt_handler
def _register_prompt_objects() -> None:
for prompt_id in REGISTRY.prompts_in_load_order:
prompt = REGISTRY.prompts_by_id[prompt_id]
def _register_prompt_objects(mcp: FastMCP, registry: DocsRegistry) -> None:
for prompt_id in registry.prompts_in_load_order:
prompt = registry.prompts_by_id[prompt_id]
annotations: dict[str, Any] = {}
params: list[Parameter] = []
@@ -129,187 +122,179 @@ def _register_prompt_objects() -> None:
)
@mcp.resource(
"resource://catalog/skills_index",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def skills_index() -> dict[str, Any]:
return build_skills_index_payload(REGISTRY)
@mcp.resource(
"resource://catalog/skills_index{?q,tag,capability,cursor,limit}",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def skills_index_query(
q: str | None = None,
tag: str | None = None,
capability: str | None = None,
cursor: str | None = None,
limit: int | None = None,
) -> dict[str, Any]:
return build_skills_index_payload(
REGISTRY,
query=q,
tag=tag,
capability=capability,
cursor=cursor,
limit=limit,
def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
@mcp.resource(
"resource://catalog/skills_index",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def skills_index() -> dict[str, Any]:
return build_skills_index_payload(registry)
@mcp.resource(
"resource://catalog/skills/{skill_id}",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def skill_detail(skill_id: str) -> dict[str, Any]:
return build_skill_detail_payload(REGISTRY, skill_id)
@mcp.resource(
"resource://skills/{skill_id}/document",
mime_type="text/markdown",
tags={"skill-doc"},
annotations=_ro_annotations(),
)
def skill_document(skill_id: str) -> dict[str, str]:
return read_skill_document(REGISTRY, skill_id)
@mcp.resource(
"resource://skills/{skill_id}/references/{ref_id}",
mime_type="text/markdown",
tags={"reference"},
annotations=_ro_annotations(),
)
def skill_reference(skill_id: str, ref_id: str) -> dict[str, str]:
return read_skill_reference(REGISTRY, skill_id=skill_id, ref_id=ref_id)
@mcp.resource(
"resource://docs/{path*}",
mime_type="text/markdown",
tags={"docs"},
annotations=_ro_annotations(),
)
def docs_markdown(path: str) -> dict[str, str]:
return read_docs_markdown_path(REGISTRY, path)
@mcp.resource(
"resource://catalog/prompts_index",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def prompts_index() -> dict[str, Any]:
return build_prompts_index_payload(REGISTRY)
@mcp.resource(
"resource://catalog/prompts_index{?q,tag,cursor,limit}",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def prompts_index_query(
q: str | None = None,
tag: str | None = None,
cursor: str | None = None,
limit: int | None = None,
) -> dict[str, Any]:
return build_prompts_index_payload(
REGISTRY,
query=q,
tag=tag,
cursor=cursor,
limit=limit,
@mcp.resource(
"resource://catalog/skills_index{?q,tag,capability,cursor,limit}",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def skills_index_query(
q: str | None = None,
tag: str | None = None,
capability: str | None = None,
cursor: str | None = None,
limit: int | None = None,
) -> dict[str, Any]:
return build_skills_index_payload(
registry,
query=q,
tag=tag,
capability=capability,
cursor=cursor,
limit=limit,
)
@mcp.resource(
"resource://catalog/prompts/{prompt_id}",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def prompt_detail(prompt_id: str) -> dict[str, Any]:
return build_prompt_detail_payload(REGISTRY, prompt_id)
@mcp.resource(
"resource://prompts/{prompt_id}/document",
mime_type="text/markdown",
tags={"prompt-doc"},
annotations=_ro_annotations(),
)
def prompt_document(prompt_id: str) -> dict[str, str]:
return read_prompt_document(REGISTRY, prompt_id)
@mcp.tool
def search_patterns(
query: str = "",
tags: list[str] | None = None,
skip: int = 0,
limit: int = 20,
) -> dict[str, Any]:
"""Search normalized pattern metadata with optional tags and pagination."""
return search_patterns_payload(
REGISTRY,
query=query,
tags=tags,
skip=skip,
limit=limit,
@mcp.resource(
"resource://catalog/skills/{skill_id}",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def skill_detail(skill_id: str) -> dict[str, Any]:
return build_skill_detail_payload(registry, skill_id)
@mcp.tool
def get_pattern_by_id(id: str) -> dict[str, Any]:
"""Return one normalized pattern by stable id."""
return get_pattern_by_id_payload(REGISTRY, id)
@mcp.tool
def get_skill_document_by_id(skill_id: str) -> dict[str, Any]:
"""Return the canonical skill document payload for a stable skill id."""
if skill_id not in REGISTRY.skills_by_id:
return {"found": False, "id": skill_id}
return {
"found": True,
"document": read_skill_document(REGISTRY, skill_id),
}
@mcp.tool
def search_prompts(
query: str = "",
tags: list[str] | None = None,
skip: int = 0,
limit: int = 20,
) -> dict[str, Any]:
"""Search prompt metadata with optional tags and pagination."""
return search_prompts_payload(
REGISTRY,
query=query,
tags=tags,
skip=skip,
limit=limit,
@mcp.resource(
"resource://skills/{skill_id}/document",
mime_type="text/markdown",
tags={"skill-doc"},
annotations=_ro_annotations(),
)
def skill_document(skill_id: str) -> dict[str, str]:
return read_skill_document(registry, skill_id)
@mcp.resource(
"resource://skills/{skill_id}/references/{ref_id}",
mime_type="text/markdown",
tags={"reference"},
annotations=_ro_annotations(),
)
def skill_reference(skill_id: str, ref_id: str) -> dict[str, str]:
return read_skill_reference(registry, skill_id=skill_id, ref_id=ref_id)
@mcp.resource(
"resource://docs/{path*}",
mime_type="text/markdown",
tags={"docs"},
annotations=_ro_annotations(),
)
def docs_markdown(path: str) -> dict[str, str]:
return read_docs_markdown_path(registry, path)
@mcp.resource(
"resource://catalog/prompts_index",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def prompts_index() -> dict[str, Any]:
return build_prompts_index_payload(registry)
@mcp.resource(
"resource://catalog/prompts_index{?q,tag,cursor,limit}",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def prompts_index_query(
q: str | None = None,
tag: str | None = None,
cursor: str | None = None,
limit: int | None = None,
) -> dict[str, Any]:
return build_prompts_index_payload(
registry,
query=q,
tag=tag,
cursor=cursor,
limit=limit,
)
@mcp.resource(
"resource://catalog/prompts/{prompt_id}",
mime_type="application/json",
tags={"catalog"},
annotations=_ro_annotations(),
)
def prompt_detail(prompt_id: str) -> dict[str, Any]:
return build_prompt_detail_payload(registry, prompt_id)
@mcp.resource(
"resource://prompts/{prompt_id}/document",
mime_type="text/markdown",
tags={"prompt-doc"},
annotations=_ro_annotations(),
)
def prompt_document(prompt_id: str) -> dict[str, str]:
return read_prompt_document(registry, prompt_id)
@mcp.tool
def search_patterns(
query: str = "",
tags: list[str] | None = None,
skip: int = 0,
limit: int = 20,
) -> dict[str, Any]:
"""Search normalized pattern metadata with optional tags and pagination."""
return search_patterns_payload(
registry,
query=query,
tags=tags,
skip=skip,
limit=limit,
)
@mcp.tool
def get_pattern_by_id(id: str) -> dict[str, Any]:
"""Return one normalized pattern by stable id."""
return get_pattern_by_id_payload(registry, id)
@mcp.tool
def get_skill_document_by_id(skill_id: str) -> dict[str, Any]:
"""Return the canonical skill document payload for a stable skill id."""
if skill_id not in registry.skills_by_id:
return {"found": False, "id": skill_id}
return {
"found": True,
"document": read_skill_document(registry, skill_id),
}
@mcp.tool
def search_prompts(
query: str = "",
tags: list[str] | None = None,
skip: int = 0,
limit: int = 20,
) -> dict[str, Any]:
"""Search prompt metadata with optional tags and pagination."""
return search_prompts_payload(
registry,
query=query,
tags=tags,
skip=skip,
limit=limit,
)
@mcp.tool
def get_prompt_by_id(prompt_id: str) -> dict[str, Any]:
"""Return one prompt by stable id."""
return get_prompt_by_id_payload(registry, prompt_id)
@mcp.tool
def get_prompt_by_id(prompt_id: str) -> dict[str, Any]:
"""Return one prompt by stable id."""
return get_prompt_by_id_payload(REGISTRY, prompt_id)
_install_tool_fallback_transforms()
_register_prompt_objects()
def create_mcp() -> FastMCP:
registry = get_docs_registry()
mcp = FastMCP("personal-mcp", on_duplicate="error")
_register_components(mcp, registry)
_register_prompt_objects(mcp, registry)
_install_tool_fallback_transforms(mcp)
return mcp
+15 -10
View File
@@ -1,4 +1,5 @@
from collections.abc import Generator
from collections.abc import Iterator
from dataclasses import dataclass
from dataclasses import field
from importlib.resources.abc import Traversable
@@ -6,26 +7,32 @@ 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: PurePosixPath
relpath: DocsPath
"""The relative path of the document within the package resources."""
content: str = field(repr=False)
"""The raw markdown content of the document."""
frontmatter: str | None = field(repr=False, default=None)
"""The raw YAML frontmatter of the document, if present."""
def __post_init__(self) -> None:
object.__setattr__(self, "relpath", parse_docs_path(self.relpath))
@classmethod
def from_root(cls, root: Traversable):
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: PurePosixPath, resource: Traversable) -> Self:
def from_resource(cls, relpath: DocsPath, resource: Traversable) -> Self:
"""Load a markdown document from a package resource."""
raw = resource.read_text(encoding="utf-8")
frontmatter = get_raw_frontmatter(raw)
@@ -49,17 +56,15 @@ def walk_resources(
*,
suffix: str = ".md",
prefix: PurePosixPath | None = None,
) -> Generator[tuple[PurePosixPath, Traversable]]:
) -> Iterator[tuple[PurePosixPath, Traversable]]:
"""Recursively yield all resources in node, with their full path."""
prefix = prefix if prefix is not None else PurePosixPath()
prefix = prefix or PurePosixPath()
for child in sorted(node.iterdir(), key=lambda item: item.name):
relpath = prefix.joinpath(child.name)
relpath = prefix / child.name
if child.is_dir():
yield from walk_resources(child, suffix=suffix, prefix=relpath)
continue
if not child.is_file() or not child.name.lower().endswith(suffix):
continue
yield relpath, child
elif child.is_file() and child.name.lower().endswith(suffix):
yield relpath, child
def get_raw_frontmatter(raw: str) -> str | None:
+2 -2
View File
@@ -27,7 +27,7 @@ class PromptFilesBundle:
@classmethod
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
prompt = next(iter(p for p in paths if p.relpath.name == "PROMPT.md"))
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath.as_posix()))
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath))
other = tuple(p for p in sorted_paths if p != prompt)
return cls(
slug=slug,
@@ -41,7 +41,7 @@ def group_prompt_paths(docs: Iterable[MarkdownDocument]) -> dict[str, set[Markdo
grouped: dict[str, set[MarkdownDocument]] = {}
for doc in sorted(
filter(lambda d: d.prompt_slug is not None, docs),
key=lambda d: d.relpath.as_posix(),
key=lambda d: d.relpath,
):
if doc.prompt_slug:
grouped.setdefault(doc.prompt_slug, set()).add(doc)
+26 -8
View File
@@ -1,19 +1,19 @@
import re
from collections.abc import Iterable
from collections.abc import Mapping
from dataclasses import dataclass
from fnmatch import fnmatch
from importlib.resources.abc import Traversable
from itertools import groupby
from itertools import starmap
from pathlib import PurePosixPath
from typing import Self
from personal_mcp.registry.models.common import SKILL_ID_RE
from personal_mcp.registry.models.common import DocsPath
from personal_mcp.registry.models.common import ReferenceEntry
from personal_mcp.registry.models.skill import SkillFrontmatter
from personal_mcp.registry.models.skill import StoredSkill
from personal_mcp.registry.models.skill import StoredSkillReference
from personal_mcp.skills.document_loader import _reference_id_from_filename
from personal_mcp.skills.document_loader import _title_from_reference_filename
from .document import MarkdownDocument
@@ -39,8 +39,9 @@ class SkillFilesBundle:
@classmethod
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
skill = next(iter(p for p in paths if p.relpath.name == "SKILL.md"))
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath.as_posix()))
references = tuple(p for p in sorted_paths if fnmatch(p.relpath.as_posix(), f"skills/{slug}/references/*.md"))
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath))
references_dir = PurePosixPath("skills", slug, "references")
references = tuple(p for p in sorted_paths if p.relpath.parent == references_dir)
other = tuple(p for p in sorted_paths if p not in references and p != skill)
return cls(
slug=slug,
@@ -60,6 +61,23 @@ def group_skill_paths(docs: Iterable[MarkdownDocument]) -> dict[str, set[Markdow
return {k: set(g) for k, g in grouped if k}
def _title_from_reference_filename(filename: str) -> str:
stem = PurePosixPath(filename).stem
normalized = stem.replace("-", " ").replace("_", " ").split()
if not normalized:
return stem
return " ".join(token.capitalize() for token in normalized)
def _reference_id_from_filename(filename: str) -> str | None:
stem = PurePosixPath(filename).stem.strip().lower().replace("_", "-")
normalized = re.sub(r"[^a-z0-9-]+", "-", stem)
normalized = re.sub(r"-+", "-", normalized).strip("-")
if not normalized or not SKILL_ID_RE.fullmatch(normalized):
return None
return normalized
def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, ReferenceEntry]:
discovered: dict[str, ReferenceEntry] = {}
for reference_doc in bundle.references:
@@ -67,7 +85,7 @@ def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, Reference
if ref_id is None:
continue
discovered[ref_id] = ReferenceEntry(
path=PurePosixPath("references").joinpath(reference_doc.relpath.name).as_posix(),
path=PurePosixPath("references", reference_doc.relpath.name),
title=_title_from_reference_filename(reference_doc.relpath.name),
)
return discovered
@@ -76,7 +94,7 @@ def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, Reference
def build_stored_skill(
*,
bundle: SkillFilesBundle,
docs_by_relpath: Mapping[PurePosixPath, MarkdownDocument],
docs_by_relpath: Mapping[DocsPath, MarkdownDocument],
) -> StoredSkill:
frontmatter = SkillFrontmatter.from_raw_yaml(bundle.skill.frontmatter)
metadata = frontmatter.x_personal_mcp
@@ -85,7 +103,7 @@ def build_stored_skill(
references: dict[str, StoredSkillReference] = {}
for ref_id, entry in sorted(merged_entries.items()):
ref_relpath = PurePosixPath("skills").joinpath(bundle.slug).joinpath(entry.path)
ref_relpath = PurePosixPath("skills", bundle.slug, entry.path)
if ref_relpath not in docs_by_relpath:
raise KeyError(f"reference document not found for '{metadata.id}:{ref_id}' at {ref_relpath.as_posix()}")
ref_doc = docs_by_relpath[ref_relpath]
+14 -44
View File
@@ -1,17 +1,14 @@
from __future__ import annotations
import importlib
from collections import defaultdict
from pathlib import Path
from pathlib import PurePosixPath
import yaml
from functools import cache
from importlib.resources import files
from personal_mcp.registry.ingest.document import MarkdownDocument
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
from personal_mcp.registry.ingest.skill import SkillFilesBundle
from personal_mcp.registry.ingest.skill import build_stored_skill
from personal_mcp.registry.models.common import _normalize_docs_path
from personal_mcp.registry.models.common import DocsPath
from personal_mcp.registry.models.prompt import StoredPrompt
from personal_mcp.registry.models.registry import DocsRegistry
from personal_mcp.registry.models.registry import PromptRecord
@@ -21,25 +18,7 @@ from personal_mcp.registry.models.registry import SkillRecord
from personal_mcp.registry.models.registry import SkillSummaryRecord
def _parse_frontmatter(raw_frontmatter: str | None, *, path: PurePosixPath) -> dict[str, object]:
"""Parse frontmatter YAML into a mapping for downstream validation.
This helper is retained for compatibility with model-validation tests that
exercise gate behavior directly at parse boundaries.
"""
if raw_frontmatter is None:
raise ValueError(f"missing YAML frontmatter: {path.as_posix()}")
parsed = yaml.safe_load(raw_frontmatter)
if not isinstance(parsed, dict):
raise TypeError(f"frontmatter must parse to an object: {path.as_posix()}")
return parsed
def _build_skill_record(
*, bundle: SkillFilesBundle, docs_by_relpath: dict[PurePosixPath, MarkdownDocument]
) -> SkillRecord:
def _build_skill_record(*, bundle: SkillFilesBundle, docs_by_relpath: dict[DocsPath, MarkdownDocument]) -> SkillRecord:
stored = build_stored_skill(bundle=bundle, docs_by_relpath=docs_by_relpath)
metadata = stored.frontmatter.x_personal_mcp
references: dict[str, ReferenceRecord] = {}
@@ -47,7 +26,7 @@ def _build_skill_record(
references[ref_id] = ReferenceRecord(
ref_id=ref_id,
uri=f"resource://skills/{metadata.id}/references/{ref_id}",
relpath=ref.relpath.as_posix(),
relpath=ref.relpath,
mime_type=ref.entry.mime_type,
title=ref.entry.title,
content=ref.content,
@@ -61,7 +40,7 @@ def _build_skill_record(
tags=tuple(metadata.tags),
capabilities=tuple(metadata.capabilities),
document_uri=f"resource://skills/{metadata.id}/document",
document_relpath=stored.relpath.as_posix(),
document_relpath=stored.relpath,
document_content=stored.content,
references=references,
)
@@ -80,7 +59,7 @@ def _build_prompt_record(*, bundle: PromptFilesBundle) -> PromptRecord:
capabilities=tuple(metadata.capabilities),
arguments=dict(metadata.arguments),
document_uri=f"resource://prompts/{metadata.id}/document",
document_relpath=stored.relpath.as_posix(),
document_relpath=stored.relpath,
document_content=stored.content,
)
@@ -116,23 +95,14 @@ def _build_tag_index_prompts(
return {tag: tuple(ids) for tag, ids in sorted(tag_index.items())}
def _resolve_docs_root(*, package_anchor: str, docs_root: str) -> Path:
package = importlib.import_module(package_anchor)
package_file = getattr(package, "__file__", None)
if package_file is None:
raise ValueError(f"package anchor '{package_anchor}' has no file location")
@cache
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)
resolved = Path(package_file).resolve().parent.joinpath(docs_root).resolve()
if not resolved.exists() or not resolved.is_dir():
raise FileNotFoundError(f"docs root does not exist or is not a directory: {resolved}")
return resolved
def load_docs_registry(*, package_anchor: str, docs_root: str = "docs") -> DocsRegistry:
docs_path = _resolve_docs_root(package_anchor=package_anchor, docs_root=docs_root)
docs = MarkdownDocument.from_root(docs_path)
docs_markdown_by_path = {_normalize_docs_path(relpath.as_posix()): doc.content for relpath, doc in docs.items()}
docs_markdown_by_path = {relpath: doc.content for relpath, doc in docs.items()}
skill_bundles = SkillFilesBundle.from_docs(docs.values())
prompt_bundles = PromptFilesBundle.from_docs(docs.values())
+29 -23
View File
@@ -2,12 +2,13 @@ import re
from collections.abc import Mapping
from pathlib import PurePosixPath
from types import MappingProxyType
from typing import Annotated
from typing import ClassVar
from typing import Final
from pydantic import BaseModel
from pydantic import BeforeValidator
from pydantic import ConfigDict
from pydantic import field_validator
SKILL_ID_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9-]*$")
SEMVER_RE: Final[re.Pattern[str]] = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
@@ -29,30 +30,35 @@ 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:
raise ValueError("docs path must use POSIX separators")
path = PurePosixPath(raw)
if path.is_absolute() or ".." in path.parts:
raise ValueError("path must be a docs-relative path")
if path.as_posix() != raw:
raise ValueError("path must be normalized")
if path.suffix.lower() != ".md":
raise ValueError("path must point to a markdown file")
return path
def parse_reference_path(value: str | PurePosixPath) -> PurePosixPath:
path = parse_docs_path(value)
if len(path.parts) < 2 or path.parts[0] != "references":
raise ValueError("reference path must stay under references/")
return path
type DocsPath = Annotated[PurePosixPath, BeforeValidator(parse_docs_path)]
type ReferencePath = Annotated[PurePosixPath, BeforeValidator(parse_reference_path)]
class ReferenceEntry(StrictFrozenModel):
"""Reference metadata for a markdown file within a skill."""
path: str
path: ReferencePath
mime_type: str = "text/markdown"
title: str | None = None
@field_validator("path")
@classmethod
def validate_reference_path(cls, value: str) -> str:
path = PurePosixPath(value)
if path.is_absolute() or ".." in path.parts:
raise ValueError("reference path must be a relative in-skill path")
if not str(path).startswith("references/"):
raise ValueError("reference path must stay under references/")
if path.suffix.lower() != ".md":
raise ValueError("reference path must target a markdown file")
return path.as_posix()
def _normalize_docs_path(path: str) -> str:
normalized = PurePosixPath(path)
if normalized.is_absolute() or ".." in normalized.parts:
raise ValueError("path must be a normalized docs-relative path")
if normalized.suffix.lower() != ".md":
raise ValueError("path must point to a markdown file")
return normalized.as_posix()
+2 -2
View File
@@ -1,6 +1,5 @@
import re
from collections.abc import Mapping
from pathlib import PurePosixPath
from typing import TYPE_CHECKING
import yaml
@@ -10,6 +9,7 @@ from pydantic import model_validator
from .common import SEMVER_RE
from .common import SKILL_ID_RE
from .common import DocsPath
from .common import StrictFrozenModel
from .common import frozen_mapping
@@ -103,7 +103,7 @@ class StoredPrompt(StrictFrozenModel):
"""Normalized prompt document content with path and frontmatter for storage in the registry."""
prompt_id: str
relpath: PurePosixPath
relpath: DocsPath
content: str
frontmatter: PromptFrontmatter
+10 -5
View File
@@ -3,17 +3,22 @@ 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
from .prompt import PromptArgumentEntry
def _empty_docs_mapping() -> Mapping[DocsPath, str]:
return frozen_mapping()
class ReferenceRecord(StrictFrozenModel):
"""Registry record for a resolved skill reference document."""
ref_id: str
uri: str
relpath: str
relpath: DocsPath
mime_type: str
title: str | None
content: str
@@ -29,7 +34,7 @@ class SkillRecord(StrictFrozenModel):
tags: tuple[str, ...]
capabilities: tuple[str, ...]
document_uri: str
document_relpath: str
document_relpath: DocsPath
document_content: str
references: Mapping[str, ReferenceRecord] = Field(default_factory=frozen_mapping)
@@ -74,7 +79,7 @@ class PromptRecord(StrictFrozenModel):
capabilities: tuple[str, ...]
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
document_uri: str
document_relpath: str
document_relpath: DocsPath
document_content: str
@field_validator("arguments", mode="before")
@@ -196,8 +201,8 @@ class DocsRegistry(StrictFrozenModel):
skills_by_id: Mapping[str, SkillRecord] = Field(default_factory=frozen_mapping)
skills_in_load_order: tuple[str, ...]
skills_summary_in_load_order: tuple[SkillSummaryRecord, ...]
docs_markdown_by_path: Mapping[str, str] = Field(default_factory=frozen_mapping)
docs_markdown_path_index: tuple[str, ...]
docs_markdown_by_path: Mapping[DocsPath, str] = Field(default_factory=_empty_docs_mapping)
docs_markdown_path_index: tuple[DocsPath, ...]
tag_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
capability_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
prompts_by_id: Mapping[str, PromptRecord] = Field(default_factory=frozen_mapping)
+3 -3
View File
@@ -1,5 +1,4 @@
from collections.abc import Mapping
from pathlib import PurePosixPath
import yaml
from pydantic import Field
@@ -8,6 +7,7 @@ from pydantic import model_validator
from .common import SEMVER_RE
from .common import SKILL_ID_RE
from .common import DocsPath
from .common import ReferenceEntry
from .common import StrictFrozenModel
from .common import frozen_mapping
@@ -91,7 +91,7 @@ class StoredSkillReference(StrictFrozenModel):
"""Structured representation of a skill reference markdown document."""
ref_id: str
relpath: PurePosixPath
relpath: DocsPath
content: str
entry: ReferenceEntry
@@ -100,7 +100,7 @@ class StoredSkill(StrictFrozenModel):
"""Structured representation of a skill markdown document."""
skill_id: str
relpath: PurePosixPath
relpath: DocsPath
content: str
frontmatter: SkillFrontmatter
references: Mapping[str, StoredSkillReference] = Field(default_factory=frozen_mapping)
+10 -10
View File
@@ -1,4 +1,4 @@
from .models.common import _normalize_docs_path
from .models.common import parse_docs_path
from .models.registry import DocsRegistry
@@ -10,7 +10,7 @@ def read_skill_document(registry: DocsRegistry, skill_id: str) -> dict[str, str]
"id": skill.skill_id,
"uri": skill.document_uri,
"format": "markdown",
"source_path": f"docs/{skill.document_relpath}",
"source_path": f"docs/{skill.document_relpath.as_posix()}",
"content": skill.document_content,
}
@@ -32,20 +32,20 @@ def read_skill_reference(
"skill_id": skill_id,
"uri": reference.uri,
"format": "markdown",
"source_path": f"docs/{reference.relpath}",
"source_path": f"docs/{reference.relpath.as_posix()}",
"content": reference.content,
}
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
normalized_path = _normalize_docs_path(path)
if normalized_path not in registry.docs_markdown_by_path:
raise KeyError(f"unknown docs path: {normalized_path}")
docs_path = parse_docs_path(path)
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/{normalized_path}",
"uri": f"resource://docs/{docs_path.as_posix()}",
"format": "markdown",
"source_path": f"docs/{normalized_path}",
"content": registry.docs_markdown_by_path[normalized_path],
"source_path": f"docs/{docs_path.as_posix()}",
"content": registry.docs_markdown_by_path[docs_path],
}
@@ -57,6 +57,6 @@ def read_prompt_document(registry: DocsRegistry, prompt_id: str) -> dict[str, st
"id": prompt.prompt_id,
"uri": prompt.document_uri,
"format": "markdown",
"source_path": f"docs/{prompt.document_relpath}",
"source_path": f"docs/{prompt.document_relpath.as_posix()}",
"content": prompt.document_content,
}
-125
View File
@@ -1,125 +0,0 @@
from __future__ import annotations
import re
from importlib.resources.abc import Traversable
from pathlib import PurePosixPath
from typing import Any
import yaml
from ..registry.models.common import SKILL_ID_RE
from ..registry.models.common import ReferenceEntry
from ..registry.models.prompt import PromptFrontmatter
from ..registry.models.skill import SkillFrontmatter
def _parse_frontmatter(markdown: str, *, path: str) -> tuple[dict[str, Any], str]:
if not markdown.startswith("---"):
raise ValueError(f"missing YAML frontmatter: {path}")
lines = markdown.splitlines()
if len(lines) < 3 or lines[0].strip() != "---":
raise ValueError(f"invalid YAML frontmatter start: {path}")
end_index: int | None = None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
end_index = i
break
if end_index is None:
raise ValueError(f"missing YAML frontmatter terminator: {path}")
raw_yaml = "\n".join(lines[1:end_index])
body = "\n".join(lines[end_index + 1 :])
parsed = yaml.safe_load(raw_yaml)
if not isinstance(parsed, dict):
raise TypeError(f"frontmatter must parse to an object: {path}")
return parsed, body
def _walk_markdown(
node: Traversable,
*,
prefix: PurePosixPath | None = None,
) -> list[tuple[str, Traversable]]:
prefix = PurePosixPath() if prefix is None else prefix
results: list[tuple[str, Traversable]] = []
for child in sorted(node.iterdir(), key=lambda item: item.name):
relpath = prefix.joinpath(child.name)
if child.is_dir():
results.extend(_walk_markdown(child, prefix=relpath))
continue
if not child.is_file() or not child.name.lower().endswith(".md"):
continue
results.append((relpath.as_posix(), child))
return results
def _validate_skill_frontmatter(raw: dict[str, Any], *, skill_dir_name: str) -> SkillFrontmatter:
model = SkillFrontmatter.model_validate(raw)
if model.name != skill_dir_name:
raise ValueError("frontmatter name must exactly match skill directory name")
if model.x_personal_mcp.id != model.name:
raise ValueError("x-personal-mcp.id must exactly match name")
expected_capability = f"resource://skills/{model.name}/document"
if expected_capability not in model.x_personal_mcp.capabilities:
raise ValueError(f"capabilities must include {expected_capability}")
return model
def _validate_prompt_frontmatter(raw: dict[str, Any], *, prompt_dir_name: str) -> PromptFrontmatter:
model = PromptFrontmatter.model_validate(raw)
if model.name != prompt_dir_name:
raise ValueError("frontmatter name must exactly match prompt directory name")
if model.x_personal_mcp.id != model.name:
raise ValueError("x-personal-mcp.id must exactly match name")
expected_capability = f"resource://prompts/{model.name}/document"
if expected_capability not in model.x_personal_mcp.capabilities:
raise ValueError(f"capabilities must include {expected_capability}")
return model
def _title_from_reference_filename(filename: str) -> str:
stem = PurePosixPath(filename).stem
normalized = stem.replace("-", " ").replace("_", " ").split()
if not normalized:
return stem
return " ".join(token.capitalize() for token in normalized)
def _reference_id_from_filename(filename: str) -> str | None:
stem = PurePosixPath(filename).stem.strip().lower().replace("_", "-")
normalized = re.sub(r"[^a-z0-9-]+", "-", stem)
normalized = re.sub(r"-+", "-", normalized).strip("-")
if not normalized:
return None
if not SKILL_ID_RE.fullmatch(normalized):
return None
return normalized
def _discover_top_level_references(
*,
skill_dir: Traversable,
) -> dict[str, ReferenceEntry]:
references_dir = skill_dir.joinpath("references")
if not references_dir.is_dir():
return {}
discovered: dict[str, ReferenceEntry] = {}
for child in sorted(references_dir.iterdir(), key=lambda item: item.name):
if child.is_dir() or not child.is_file():
continue
if not child.name.lower().endswith(".md"):
continue
ref_id = _reference_id_from_filename(child.name)
if ref_id is None:
continue
discovered[ref_id] = ReferenceEntry(
path=PurePosixPath("references").joinpath(child.name).as_posix(),
title=_title_from_reference_filename(child.name),
)
return discovered
+2 -2
View File
@@ -2,14 +2,14 @@ from fastapi import FastAPI
from ..config import Settings
from ..config import get_settings
from ..mcp import mcp
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 = mcp.http_app(
mcp_app = create_mcp().http_app(
path=runtime_settings.mounts.mcp,
json_response=True,
stateless_http=True,