diff --git a/src/personal_mcp/registry/contracts.py b/src/personal_mcp/registry/contracts.py index 139714b..aa4e9c9 100644 --- a/src/personal_mcp/registry/contracts.py +++ b/src/personal_mcp/registry/contracts.py @@ -1,80 +1,17 @@ -from dataclasses import dataclass -from dataclasses import field - from .models.prompt import PromptArgumentEntry +from .models.registry import DocsRegistry +from .models.registry import PromptRecord +from .models.registry import PromptSummaryRecord +from .models.registry import ReferenceRecord +from .models.registry import SkillRecord +from .models.registry import SkillSummaryRecord - -@dataclass(frozen=True) -class ReferenceRecord: - ref_id: str - uri: str - relpath: str - mime_type: str - title: str | None - content: str - - -@dataclass(frozen=True) -class SkillRecord: - skill_id: str - name: str - description: str - version: str - tags: tuple[str, ...] - capabilities: tuple[str, ...] - depends_on: tuple[str, ...] - document_uri: str - document_relpath: str - document_content: str - references: dict[str, ReferenceRecord] - - -@dataclass(frozen=True) -class SkillSummaryRecord: - skill_id: str - name: str - description: str - tags: tuple[str, ...] - capabilities: tuple[str, ...] - document_uri: str - version: str - - -@dataclass(frozen=True) -class PromptRecord: - prompt_id: str - name: str - description: str - version: str - tags: tuple[str, ...] - capabilities: tuple[str, ...] - arguments: dict[str, PromptArgumentEntry] - document_uri: str - document_relpath: str - document_content: str - - -@dataclass(frozen=True) -class PromptSummaryRecord: - prompt_id: str - name: str - description: str - tags: tuple[str, ...] - capabilities: tuple[str, ...] - document_uri: str - version: str - - -@dataclass(frozen=True) -class DocsRegistry: - skills_by_id: dict[str, SkillRecord] - skills_in_load_order: tuple[str, ...] - skills_summary_in_load_order: tuple[SkillSummaryRecord, ...] - docs_markdown_by_path: dict[str, str] - docs_markdown_path_index: tuple[str, ...] - tag_to_skill_ids: dict[str, tuple[str, ...]] - capability_to_skill_ids: dict[str, tuple[str, ...]] - prompts_by_id: dict[str, PromptRecord] = field(default_factory=dict) - prompts_in_load_order: tuple[str, ...] = () - prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = () - tag_to_prompt_ids: dict[str, tuple[str, ...]] = field(default_factory=dict) +__all__ = [ + "DocsRegistry", + "PromptArgumentEntry", + "PromptRecord", + "PromptSummaryRecord", + "ReferenceRecord", + "SkillRecord", + "SkillSummaryRecord", +] diff --git a/src/personal_mcp/registry/ingest/__init__.py b/src/personal_mcp/registry/ingest/__init__.py new file mode 100644 index 0000000..f225055 --- /dev/null +++ b/src/personal_mcp/registry/ingest/__init__.py @@ -0,0 +1 @@ +"""Functions to produce immutable dataclasses representing the document registry.""" diff --git a/src/personal_mcp/registry/file.py b/src/personal_mcp/registry/ingest/document.py similarity index 100% rename from src/personal_mcp/registry/file.py rename to src/personal_mcp/registry/ingest/document.py diff --git a/src/personal_mcp/registry/skills.py b/src/personal_mcp/registry/ingest/skills.py similarity index 98% rename from src/personal_mcp/registry/skills.py rename to src/personal_mcp/registry/ingest/skills.py index 0e5db8a..1f0e142 100644 --- a/src/personal_mcp/registry/skills.py +++ b/src/personal_mcp/registry/ingest/skills.py @@ -6,7 +6,7 @@ from itertools import groupby from itertools import starmap from typing import Self -from .file import MarkdownDocument +from .document import MarkdownDocument @dataclass(frozen=True, slots=True) diff --git a/src/personal_mcp/registry/models/__init__.py b/src/personal_mcp/registry/models/__init__.py index e69de29..7e60720 100644 --- a/src/personal_mcp/registry/models/__init__.py +++ b/src/personal_mcp/registry/models/__init__.py @@ -0,0 +1,47 @@ +from .common import SEMVER_RE +from .common import SKILL_ID_RE +from .common import ReferenceEntry +from .common import StrictFrozenModel +from .document import MarkdownDocumentModel +from .document import SkillFilesBundleModel +from .document import to_markdown_document_model +from .prompt import PromptArgumentEntry +from .prompt import PromptDocumentModel +from .prompt import PromptFrontmatter +from .prompt import PromptMetadata +from .registry import DocsRegistry +from .registry import PromptRecord +from .registry import PromptSummaryRecord +from .registry import ReferenceRecord +from .registry import SkillRecord +from .registry import SkillSummaryRecord +from .skill import PersonalMcpMetadata +from .skill import SkillDocumentModel +from .skill import SkillFrontmatter +from .skill import SkillMetadata +from .skill import SkillReferenceDocumentModel + +__all__ = [ + "SEMVER_RE", + "SKILL_ID_RE", + "DocsRegistry", + "MarkdownDocumentModel", + "PersonalMcpMetadata", + "PromptArgumentEntry", + "PromptDocumentModel", + "PromptFrontmatter", + "PromptMetadata", + "PromptRecord", + "PromptSummaryRecord", + "ReferenceEntry", + "ReferenceRecord", + "SkillDocumentModel", + "SkillFilesBundleModel", + "SkillFrontmatter", + "SkillMetadata", + "SkillRecord", + "SkillReferenceDocumentModel", + "SkillSummaryRecord", + "StrictFrozenModel", + "to_markdown_document_model", +] diff --git a/src/personal_mcp/registry/models/common.py b/src/personal_mcp/registry/models/common.py index 0d8ec48..4fe1da6 100644 --- a/src/personal_mcp/registry/models/common.py +++ b/src/personal_mcp/registry/models/common.py @@ -1,5 +1,8 @@ import re +from collections.abc import Mapping from pathlib import PurePosixPath +from types import MappingProxyType +from typing import TypeVar from pydantic import BaseModel from pydantic import ConfigDict @@ -8,10 +11,25 @@ from pydantic import field_validator SKILL_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$") SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$") +K = TypeVar("K") +V = TypeVar("V") -class ReferenceEntry(BaseModel): - model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) +class StrictFrozenModel(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + validate_by_alias=True, + validate_by_name=True, + str_strip_whitespace=True, + ) + + +def frozen_mapping(value: Mapping[K, V] | None = None) -> Mapping[K, V]: + return MappingProxyType(dict(value or {})) + + +class ReferenceEntry(StrictFrozenModel): path: str mime_type: str = "text/markdown" title: str | None = None diff --git a/src/personal_mcp/registry/models/document.py b/src/personal_mcp/registry/models/document.py new file mode 100644 index 0000000..4198a0b --- /dev/null +++ b/src/personal_mcp/registry/models/document.py @@ -0,0 +1,51 @@ +from pathlib import PurePosixPath + +from .common import StrictFrozenModel + + +class MarkdownDocumentModel(StrictFrozenModel): + relpath: str + content: str + frontmatter: str | None = None + skill_slug: str | None = None + + +class SkillFilesBundleModel(StrictFrozenModel): + slug: str + skill: MarkdownDocumentModel + references: tuple[MarkdownDocumentModel, ...] = () + other: tuple[MarkdownDocumentModel, ...] = () + + @classmethod + def from_parts( + cls, + *, + slug: str, + skill: MarkdownDocumentModel, + references: tuple[MarkdownDocumentModel, ...] = (), + other: tuple[MarkdownDocumentModel, ...] = (), + ) -> "SkillFilesBundleModel": + # Ensure deterministic ordering even when the source collection is unordered. + references_sorted = tuple(sorted(references, key=lambda doc: doc.relpath)) + other_sorted = tuple(sorted(other, key=lambda doc: doc.relpath)) + return cls( + slug=slug, + skill=skill, + references=references_sorted, + other=other_sorted, + ) + + +def to_markdown_document_model( + *, + relpath: PurePosixPath, + content: str, + frontmatter: str | None, + skill_slug: str | None, +) -> MarkdownDocumentModel: + return MarkdownDocumentModel( + relpath=relpath.as_posix(), + content=content, + frontmatter=frontmatter, + skill_slug=skill_slug, + ) diff --git a/src/personal_mcp/registry/models/prompt.py b/src/personal_mcp/registry/models/prompt.py index 13f0c79..5f9aaec 100644 --- a/src/personal_mcp/registry/models/prompt.py +++ b/src/personal_mcp/registry/models/prompt.py @@ -1,23 +1,22 @@ import re +from collections.abc import Mapping from typing import Any -from pydantic import BaseModel -from pydantic import ConfigDict from pydantic import Field from pydantic import field_validator from .common import SEMVER_RE from .common import SKILL_ID_RE +from .common import StrictFrozenModel +from .common import frozen_mapping -class PromptArgumentEntry(BaseModel): - model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) - +class PromptArgumentEntry(StrictFrozenModel): type: str = Field(min_length=1) description: str | None = None required: bool = False default: Any | None = None - enum: list[str] | None = None + enum: tuple[str, ...] | None = None @field_validator("type") @classmethod @@ -36,20 +35,18 @@ class PromptArgumentEntry(BaseModel): @field_validator("enum") @classmethod - def validate_enum(cls, value: list[str] | None) -> list[str] | None: + def validate_enum(cls, value: tuple[str, ...] | None) -> tuple[str, ...] | None: if value is not None and not value: raise ValueError("enum must contain at least one value when provided") return value -class PromptMetadata(BaseModel): - model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) - +class PromptMetadata(StrictFrozenModel): id: str version: str - tags: list[str] = Field(default_factory=list) - capabilities: list[str] = Field(min_length=1) - arguments: dict[str, PromptArgumentEntry] = Field(default_factory=dict) + tags: tuple[str, ...] = () + capabilities: tuple[str, ...] = Field(min_length=1) + arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping) @field_validator("id") @classmethod @@ -67,24 +64,27 @@ class PromptMetadata(BaseModel): @field_validator("tags") @classmethod - def validate_tags(cls, value: list[str]) -> list[str]: + def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]: for tag in value: if not SKILL_ID_RE.fullmatch(tag): raise ValueError(f"invalid tag: {tag}") return value + @field_validator("arguments", mode="before") + @classmethod + def freeze_arguments(cls, value: Mapping[str, PromptArgumentEntry] | None) -> Mapping[str, PromptArgumentEntry]: + return frozen_mapping(value) + @field_validator("arguments") @classmethod - def validate_argument_names(cls, value: dict[str, PromptArgumentEntry]) -> dict[str, PromptArgumentEntry]: + def validate_argument_names(cls, value: Mapping[str, PromptArgumentEntry]) -> Mapping[str, PromptArgumentEntry]: for name in value: if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name): raise ValueError(f"invalid prompt argument name: {name}") return value -class PromptFrontmatter(BaseModel): - model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) - +class PromptFrontmatter(StrictFrozenModel): name: str = Field(min_length=1, max_length=64) description: str = Field(min_length=1, max_length=1024) x_personal_mcp: PromptMetadata = Field(alias="x-personal-mcp") @@ -97,3 +97,10 @@ class PromptFrontmatter(BaseModel): if "anthropic" in value or "claude" in value: raise ValueError("name must not contain reserved words anthropic or claude") return value + + +class PromptDocumentModel(StrictFrozenModel): + prompt_id: str + relpath: str + content: str + frontmatter: PromptFrontmatter diff --git a/src/personal_mcp/registry/models/registry.py b/src/personal_mcp/registry/models/registry.py new file mode 100644 index 0000000..17d1602 --- /dev/null +++ b/src/personal_mcp/registry/models/registry.py @@ -0,0 +1,101 @@ +from collections.abc import Mapping + +from pydantic import Field +from pydantic import field_validator + +from .common import StrictFrozenModel +from .common import frozen_mapping +from .prompt import PromptArgumentEntry + + +class ReferenceRecord(StrictFrozenModel): + ref_id: str + uri: str + relpath: str + mime_type: str + title: str | None + content: str + + +class SkillRecord(StrictFrozenModel): + skill_id: str + name: str + description: str + version: str + tags: tuple[str, ...] + capabilities: tuple[str, ...] + depends_on: tuple[str, ...] + document_uri: str + document_relpath: str + document_content: str + references: Mapping[str, ReferenceRecord] = Field(default_factory=frozen_mapping) + + @field_validator("references", mode="before") + @classmethod + def freeze_references(cls, value: Mapping[str, ReferenceRecord] | None) -> Mapping[str, ReferenceRecord]: + return frozen_mapping(value) + + +class SkillSummaryRecord(StrictFrozenModel): + skill_id: str + name: str + description: str + tags: tuple[str, ...] + capabilities: tuple[str, ...] + document_uri: str + version: str + + +class PromptRecord(StrictFrozenModel): + prompt_id: str + name: str + description: str + version: str + tags: tuple[str, ...] + capabilities: tuple[str, ...] + arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping) + document_uri: str + document_relpath: str + document_content: str + + @field_validator("arguments", mode="before") + @classmethod + def freeze_arguments(cls, value: Mapping[str, PromptArgumentEntry] | None) -> Mapping[str, PromptArgumentEntry]: + return frozen_mapping(value) + + +class PromptSummaryRecord(StrictFrozenModel): + prompt_id: str + name: str + description: str + tags: tuple[str, ...] + capabilities: tuple[str, ...] + document_uri: str + version: str + + +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, ...] + 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) + prompts_in_load_order: tuple[str, ...] = () + prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = () + tag_to_prompt_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping) + + @field_validator( + "skills_by_id", + "docs_markdown_by_path", + "tag_to_skill_ids", + "capability_to_skill_ids", + "prompts_by_id", + "tag_to_prompt_ids", + mode="before", + ) + @classmethod + def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]: + return frozen_mapping(value) diff --git a/src/personal_mcp/registry/models/skill.py b/src/personal_mcp/registry/models/skill.py index dd4357b..240ac9a 100644 --- a/src/personal_mcp/registry/models/skill.py +++ b/src/personal_mcp/registry/models/skill.py @@ -1,22 +1,22 @@ -from pydantic import BaseModel -from pydantic import ConfigDict +from collections.abc import Mapping + from pydantic import Field from pydantic import field_validator from .common import SEMVER_RE from .common import SKILL_ID_RE from .common import ReferenceEntry +from .common import StrictFrozenModel +from .common import frozen_mapping -class PersonalMcpMetadata(BaseModel): - model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) - +class SkillMetadata(StrictFrozenModel): id: str version: str - tags: list[str] = Field(default_factory=list) - capabilities: list[str] = Field(min_length=1) - depends_on: list[str] = Field(default_factory=list) - references: dict[str, ReferenceEntry] = Field(default_factory=dict) + tags: tuple[str, ...] = () + capabilities: tuple[str, ...] = Field(min_length=1) + depends_on: tuple[str, ...] = () + references: Mapping[str, ReferenceEntry] = Field(default_factory=frozen_mapping) @field_validator("id") @classmethod @@ -34,7 +34,7 @@ class PersonalMcpMetadata(BaseModel): @field_validator("tags") @classmethod - def validate_tags(cls, value: list[str]) -> list[str]: + def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]: for tag in value: if not SKILL_ID_RE.fullmatch(tag): raise ValueError(f"invalid tag: {tag}") @@ -42,29 +42,32 @@ class PersonalMcpMetadata(BaseModel): @field_validator("depends_on") @classmethod - def validate_depends_on(cls, value: list[str]) -> list[str]: + def validate_depends_on(cls, value: tuple[str, ...]) -> tuple[str, ...]: for dep in value: if not SKILL_ID_RE.fullmatch(dep): raise ValueError(f"invalid depends_on skill id: {dep}") return value + @field_validator("references", mode="before") + @classmethod + def freeze_references(cls, value: Mapping[str, ReferenceEntry] | None) -> Mapping[str, ReferenceEntry]: + return frozen_mapping(value) + @field_validator("references") @classmethod - def validate_reference_ids(cls, value: dict[str, ReferenceEntry]) -> dict[str, ReferenceEntry]: + def validate_reference_ids(cls, value: Mapping[str, ReferenceEntry]) -> Mapping[str, ReferenceEntry]: for ref_id in value: if not SKILL_ID_RE.fullmatch(ref_id): raise ValueError(f"invalid reference id: {ref_id}") return value -class SkillFrontmatter(BaseModel): - model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) - +class SkillFrontmatter(StrictFrozenModel): name: str = Field(min_length=1, max_length=64) description: str = Field(min_length=1, max_length=1024) when_to_use: str | None = None - allowed_tools: str | list[str] | None = Field(default=None, alias="allowed-tools") - disallowed_tools: str | list[str] | None = Field( + allowed_tools: str | tuple[str, ...] | None = Field(default=None, alias="allowed-tools") + disallowed_tools: str | tuple[str, ...] | None = Field( default=None, alias="disallowed-tools", ) @@ -74,11 +77,18 @@ class SkillFrontmatter(BaseModel): ) user_invocable: bool | None = Field(default=None, alias="user-invocable") argument_hint: str | None = Field(default=None, alias="argument-hint") - arguments: str | list[str] | None = None + arguments: str | tuple[str, ...] | None = None license: str | None = None compatibility: str | None = None - metadata: dict[str, str] | None = None - x_personal_mcp: PersonalMcpMetadata = Field(alias="x-personal-mcp") + metadata: Mapping[str, str] | None = None + x_personal_mcp: SkillMetadata = Field(alias="x-personal-mcp") + + @field_validator("metadata", mode="before") + @classmethod + def freeze_metadata(cls, value: Mapping[str, str] | None) -> Mapping[str, str] | None: + if value is None: + return None + return frozen_mapping(value) @field_validator("name") @classmethod @@ -88,3 +98,21 @@ class SkillFrontmatter(BaseModel): if "anthropic" in value or "claude" in value: raise ValueError("name must not contain reserved words anthropic or claude") return value + + +class SkillDocumentModel(StrictFrozenModel): + skill_id: str + relpath: str + content: str + frontmatter: SkillFrontmatter + + +class SkillReferenceDocumentModel(StrictFrozenModel): + ref_id: str + relpath: str + content: str + entry: ReferenceEntry + + +# Transitional alias while callers move to neutral naming. +PersonalMcpMetadata = SkillMetadata