Files
prompts/src/personal_mcp/registry/models/prompt.py
T

148 lines
5.3 KiB
Python

import re
from collections.abc import Mapping
from typing import TYPE_CHECKING
import yaml
from pydantic import Field
from pydantic import field_validator
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
if TYPE_CHECKING:
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
class PromptArgumentEntry(StrictFrozenModel):
"""Schema for a single prompt argument definition."""
title: str | None = None
description: str | None = None
required: bool = False
class PromptMetadata(StrictFrozenModel):
"""Canonical metadata describing a prompt contract and arguments."""
id: str
version: str
tags: tuple[str, ...] = ()
capabilities: tuple[str, ...] = Field(min_length=1)
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
@field_validator("id")
@classmethod
def validate_id(cls, value: str) -> str:
if not SKILL_ID_RE.fullmatch(value):
raise ValueError("id must be lowercase kebab-case and start with a letter")
return value
@field_validator("version")
@classmethod
def validate_version(cls, value: str) -> str:
if not SEMVER_RE.fullmatch(value):
raise ValueError("version must be semver")
return value
@field_validator("tags")
@classmethod
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: 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(StrictFrozenModel):
"""Parsed PROMPT frontmatter including personal-mcp metadata."""
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")
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
if not SKILL_ID_RE.fullmatch(value):
raise ValueError("name must be lowercase kebab-case and start with a letter")
if "anthropic" in value or "claude" in value:
raise ValueError("name must not contain reserved words anthropic or claude")
return value
@classmethod
def from_raw_yaml(cls, raw: str | None) -> "PromptFrontmatter":
if raw is None:
raise ValueError("missing YAML frontmatter")
try:
data = yaml.safe_load(raw)
except yaml.YAMLError as e:
raise ValueError(f"invalid YAML in frontmatter: {e}") from e
if not isinstance(data, dict):
raise TypeError("frontmatter must parse to an object")
return cls.model_validate(data)
class StoredPrompt(StrictFrozenModel):
"""Normalized prompt document content with path and frontmatter for storage in the registry."""
prompt_id: str
relpath: DocsPath
content: str
frontmatter: PromptFrontmatter
@field_validator("frontmatter", mode="before")
@classmethod
def parse_frontmatter_yaml(cls, value: PromptFrontmatter | str | None) -> PromptFrontmatter:
if isinstance(value, PromptFrontmatter):
return value
return PromptFrontmatter.from_raw_yaml(value)
@model_validator(mode="after")
def validate_contract(self) -> "StoredPrompt":
parts = self.relpath.parts
if len(parts) < 3 or parts[0] != "prompts":
raise ValueError("prompt relpath must be under prompts/<slug>/")
prompt_dir_name = parts[1]
if self.frontmatter.name != prompt_dir_name:
raise ValueError("frontmatter name must exactly match prompt directory name")
if self.frontmatter.x_personal_mcp.id != self.frontmatter.name:
raise ValueError("x-personal-mcp.id must exactly match name")
expected_capability = f"resource://prompts/{self.frontmatter.name}/document"
if expected_capability not in self.frontmatter.x_personal_mcp.capabilities:
raise ValueError(f"capabilities must include {expected_capability}")
if self.prompt_id != self.frontmatter.x_personal_mcp.id:
raise ValueError("prompt_id must exactly match x-personal-mcp.id")
return self
@classmethod
def from_bundle(cls, bundle: "PromptFilesBundle") -> "StoredPrompt":
frontmatter = PromptFrontmatter.from_raw_yaml(bundle.prompt.frontmatter)
return cls.model_validate(
{
"prompt_id": frontmatter.x_personal_mcp.id,
"relpath": bundle.prompt.relpath,
"content": bundle.prompt.content,
"frontmatter": frontmatter,
}
)