WIP simplifying load/startup
This commit is contained in:
@@ -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())
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../docs
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user