Files
prompts/src/personal_mcp/registry/models/prompt.py
T
2026-06-21 16:53:43 -05:00

123 lines
3.9 KiB
Python

import re
from collections.abc import Mapping
from pathlib import PurePosixPath
from typing import Any
from typing import Literal
import yaml
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
type PromptArgumentType = Literal[
"string",
"number",
"integer",
"boolean",
"array",
"object",
]
class PromptArgumentEntry(StrictFrozenModel):
"""Schema for a single prompt argument definition."""
type: PromptArgumentType
description: str | None = None
required: bool = False
default: Any | None = None
enum: tuple[str, ...] | None = None
@field_validator("enum")
@classmethod
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(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
class StoredPrompt(StrictFrozenModel):
"""Normalized prompt document content with path and frontmatter for storage in the registry."""
prompt_id: str
relpath: PurePosixPath
content: str
frontmatter: PromptFrontmatter | None = None
@field_validator("frontmatter", mode="before")
@classmethod
def parse_frontmatter_yaml(cls, value: str | None):
if value is None:
return None
try:
data = yaml.safe_load(value)
except yaml.YAMLError as e:
raise ValueError(f"invalid YAML in frontmatter: {e}") from e
return PromptFrontmatter.model_validate(data)