changed to skill provider

This commit is contained in:
John Lancaster
2026-08-07 19:09:54 -05:00
parent c6817a074e
commit 44edffb8b7
48 changed files with 552 additions and 2946 deletions
@@ -8,9 +8,7 @@ import yaml
from pydantic import ValidationError
from personal_mcp.registry.ingest.document import MarkdownDocument
from personal_mcp.registry.models.common import ReferenceEntry
from personal_mcp.registry.models.common import parse_docs_path
from personal_mcp.registry.models.common import parse_reference_path
from personal_mcp.registry.models.registry import DocsRegistry
pytestmark = pytest.mark.unit
@@ -30,35 +28,6 @@ def make_markdown_document(
)
def make_skill_frontmatter_payload(
*,
skill_id: str,
name: str | None = None,
version: str = "1.0.0",
description: str = "demo skill",
tags: tuple[str, ...] = ("testing",),
capabilities: tuple[str, ...] | None = None,
references: dict[str, dict[str, Any]] | None = None,
) -> str:
"""Builds frontmatter payload YAML for skill conversion tests."""
canonical_name = name or skill_id
x_personal_mcp: dict[str, Any] = {
"id": skill_id,
"version": version,
"tags": list(tags),
"capabilities": list(capabilities or (f"resource://skills/{canonical_name}/document",)),
}
if references is not None:
x_personal_mcp["references"] = references
payload: dict[str, Any] = {
"name": canonical_name,
"description": description,
"x-personal-mcp": x_personal_mcp,
}
return yaml.safe_dump(payload, sort_keys=False)
def make_prompt_frontmatter_payload(
*,
prompt_id: str,
@@ -121,19 +90,6 @@ class TestGate5ContractValidation:
with pytest.raises(ValueError):
parse_docs_path(value)
def test_reference_entry_materializes_reference_path(self) -> None:
"""Ensures authored reference strings become constrained path objects."""
entry = ReferenceEntry.model_validate({"path": "references/guides/setup.md"})
assert entry.path == PurePosixPath("references/guides/setup.md")
assert parse_reference_path(entry.path) == entry.path
@pytest.mark.parametrize("value", ("guide.md", "other/guide.md", "references.md"))
def test_reference_path_stays_under_references(self, value: str) -> None:
"""Ensures in-skill references remain below the references directory."""
with pytest.raises(ValueError, match="stay under references"):
parse_reference_path(value)
class TestGate6FreezeValidation:
"""Gate 6: immutable in-memory registry snapshot semantics."""
@@ -143,13 +99,8 @@ class TestGate6FreezeValidation:
index_path = PurePosixPath("index.md")
source_docs = {index_path: "# index\n"}
registry = DocsRegistry(
skills_by_id={},
skills_in_load_order=(),
skills_summary_in_load_order=(),
docs_markdown_by_path=source_docs,
docs_markdown_path_index=(index_path,),
tag_to_skill_ids={},
capability_to_skill_ids={},
prompts_by_id={},
prompts_in_load_order=(),
prompts_summary_in_load_order=(),
@@ -164,13 +115,8 @@ class TestGate6FreezeValidation:
def test_docs_registry_instance_is_frozen(self) -> None:
"""Ensures frozen model prevents attribute reassignment."""
registry = DocsRegistry(
skills_by_id={},
skills_in_load_order=(),
skills_summary_in_load_order=(),
docs_markdown_by_path={},
docs_markdown_path_index=(),
tag_to_skill_ids={},
capability_to_skill_ids={},
prompts_by_id={},
prompts_in_load_order=(),
prompts_summary_in_load_order=(),
@@ -5,15 +5,12 @@ from pathlib import PurePosixPath
import pytest
from pydantic import ValidationError
from personal_mcp.registry.ingest.document import MarkdownDocument
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
from personal_mcp.registry.load import _build_prompt_record
from personal_mcp.registry.load import get_docs_registry
from personal_mcp.registry.models.registry import PromptSummaryRecord
from tests.registry.models.test_document_validation import assert_model_is_frozen
from tests.registry.models.test_document_validation import make_markdown_document
from tests.registry.models.test_document_validation import make_prompt_frontmatter_payload
from tests.registry.models.test_document_validation import make_skill_frontmatter_payload
pytestmark = pytest.mark.unit
@@ -113,35 +110,6 @@ class TestPromptValidationGates:
assert record.arguments["topic"].required is True
assert record.arguments["topic"].description == "topic to discuss"
class TestGate4GraphValidation:
"""Gate 4: validate cross-entity identifier coherence."""
def test_prompt_id_collision_with_skill_id_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensures prompt and skill ids cannot collide in published registry."""
skill_frontmatter = make_skill_frontmatter_payload(skill_id="shared")
prompt_frontmatter = make_prompt_frontmatter_payload(prompt_id="shared")
documents = {
PurePosixPath("skills/shared/SKILL.md"): make_markdown_document(
"skills/shared/SKILL.md",
frontmatter=skill_frontmatter,
),
PurePosixPath("prompts/shared/PROMPT.md"): make_markdown_document(
"prompts/shared/PROMPT.md",
frontmatter=prompt_frontmatter,
),
}
def fake_from_root(_cls, _root):
return documents
monkeypatch.setattr(MarkdownDocument, "from_root", classmethod(fake_from_root))
get_docs_registry.cache_clear()
try:
with pytest.raises(ValueError, match="prompt_id collides with existing skill_id"):
get_docs_registry()
finally:
get_docs_registry.cache_clear()
class TestGate5ContractValidation:
"""Gate 5: validate model_dump contract shape for API surfaces."""
@@ -1,56 +1,14 @@
from __future__ import annotations
import json
from pathlib import PurePosixPath
import pytest
from personal_mcp.catalog.server import build_skill_detail_payload
from personal_mcp.registry.models.prompt import PromptArgumentEntry
from personal_mcp.registry.models.registry import DocsRegistry
from personal_mcp.registry.models.registry import PromptRecord
from personal_mcp.registry.models.registry import PromptSummaryPayload
from personal_mcp.registry.models.registry import ReferenceRecord
from personal_mcp.registry.models.registry import SkillPatternPayload
from personal_mcp.registry.models.registry import SkillRecord
from personal_mcp.registry.models.registry import SkillSummaryPayload
from personal_mcp.registry.models.registry import SkillSummaryRecord
pytestmark = pytest.mark.unit
def _make_skill_record() -> SkillRecord:
return SkillRecord(
skill_id="demo-skill",
name="demo-skill",
description="demo skill",
version="1.2.3",
tags=("testing", "catalog"),
capabilities=("resource://skills/demo-skill/document", "resource://catalog/skills_index"),
document_uri="resource://skills/demo-skill/document",
document_relpath=PurePosixPath("skills/demo-skill/SKILL.md"),
document_content="# demo",
references={
"zeta": ReferenceRecord(
ref_id="zeta",
uri="resource://skills/demo-skill/references/zeta",
relpath=PurePosixPath("skills/demo-skill/references/zeta.md"),
mime_type="text/markdown",
title="Zeta",
content="# zeta",
),
"alpha": ReferenceRecord(
ref_id="alpha",
uri="resource://skills/demo-skill/references/alpha",
relpath=PurePosixPath("skills/demo-skill/references/alpha.md"),
mime_type="text/markdown",
title="Alpha",
content="# alpha",
),
},
)
def _make_prompt_record() -> PromptRecord:
return PromptRecord(
prompt_id="demo-prompt",
@@ -72,46 +30,6 @@ def _make_prompt_record() -> PromptRecord:
)
def test_skill_pattern_payload_from_record_shape() -> None:
record = _make_skill_record()
payload = SkillPatternPayload.from_record(record).model_dump()
assert payload == {
"id": "demo-skill",
"name": "demo-skill",
"version": "1.2.3",
"description": "demo skill",
"tags": ["testing", "catalog"],
"capabilities": ["resource://skills/demo-skill/document", "resource://catalog/skills_index"],
"resources": ["resource://skills/demo-skill/document", "resource://catalog/skills_index"],
}
def test_skill_summary_payload_from_record_shape() -> None:
record = _make_skill_record()
payload = SkillSummaryPayload.from_record(record).model_dump()
assert payload == {
"id": "demo-skill",
"name": "demo-skill",
"description": "demo skill",
"tags": ["testing", "catalog"],
"capabilities": ["resource://skills/demo-skill/document", "resource://catalog/skills_index"],
"version": "1.2.3",
"document_uri": "resource://skills/demo-skill/document",
"detail_uri": "resource://catalog/skills/demo-skill",
"resources": {
"document": "resource://skills/demo-skill/document",
"references": [
"resource://skills/demo-skill/references/alpha",
"resource://skills/demo-skill/references/zeta",
],
},
}
def test_prompt_summary_payload_from_record_shape() -> None:
record = _make_prompt_record()
@@ -127,25 +45,3 @@ def test_prompt_summary_payload_from_record_shape() -> None:
"document_uri": "resource://prompts/demo-prompt/document",
"detail_uri": "resource://catalog/prompts/demo-prompt",
}
def test_skill_detail_serializes_reference_paths() -> None:
record = _make_skill_record()
registry = DocsRegistry(
skills_by_id={record.skill_id: record},
skills_in_load_order=(record.skill_id,),
skills_summary_in_load_order=(SkillSummaryRecord.from_record(record),),
docs_markdown_by_path={},
docs_markdown_path_index=(),
tag_to_skill_ids={},
capability_to_skill_ids={},
prompts_by_id={},
prompts_in_load_order=(),
prompts_summary_in_load_order=(),
tag_to_prompt_ids={},
)
payload = build_skill_detail_payload(registry, record.skill_id)
assert payload["resources"]["references"]["alpha"]["path"] == ("skills/demo-skill/references/alpha.md")
json.dumps(payload)
@@ -1,130 +0,0 @@
from __future__ import annotations
import pytest
from personal_mcp.registry.load import get_docs_registry
pytestmark = pytest.mark.unit
REGISTRY = get_docs_registry()
# Convention: every skill should include tags for the core libraries/frameworks
# it relies on so search_patterns query terms map to discoverable skills.
REQUIRED_LIBRARY_TAGS_BY_SKILL = {
"copilot-customization": {"copilot", "vscode", "mcp"},
"async-fastapi-sqlmodel": {"fastapi", "sqlalchemy", "asyncio"},
"fastapi-uv-docker": {"fastapi", "uv", "uvicorn", "docker"},
"mcp-details": {"mcp", "fastmcp"},
"nicegui": {"nicegui", "fastapi"},
"pytesting": {"pytest", "testing", "fastapi", "asyncio", "anyio"},
"python-logging": {"python", "logging"},
"python-typing": {"python", "typing"},
"ruff-linting-formating": {"ruff", "python"},
"vscode-configuration": {"vscode", "debugpy", "fastapi", "python"},
"zensical-docs": {"zensical", "mkdocs", "mkdocs-material", "mkdocstrings"},
}
LIBRARY_OR_PLATFORM_TAGS = {
"anyio",
"asyncio",
"copilot",
"debugpy",
"docker",
"fastapi",
"fastmcp",
"logging",
"mcp",
"mkdocs",
"mkdocs-material",
"mkdocstrings",
"nicegui",
"pytest",
"python",
"ruff",
"sqlalchemy",
"typing",
"uv",
"uvicorn",
"vscode",
"zensical",
}
DOMAIN_FACET_TAGS = {
"agent-skills",
"architecture",
"authoring",
"bootstrap",
"ci",
"configuration",
"custom-agents",
"customization",
"deterministic",
"discovery",
"docs",
"documentation",
"formatting",
"frontend",
"hooks",
"information-architecture",
"instructions",
"launch-json",
"linting",
"modernization",
"observability",
"personal-mcp",
"prompts",
"references",
"scaffolding",
"skills",
"source-docs",
"static-analysis",
"tasks-json",
"testing",
"type-hints",
"ui",
}
TAG_CONVENTION_PARAMETERS = tuple(
pytest.param(skill_id, required_tags, id=skill_id)
for skill_id, required_tags in sorted(REQUIRED_LIBRARY_TAGS_BY_SKILL.items())
)
SKILL_IDS = tuple(pytest.param(skill_id, id=skill_id) for skill_id in sorted(REGISTRY.skills_by_id))
class TestSkillTagConventions:
"""Covers tag taxonomy conventions for skill discoverability."""
class TestRequiredLibraryTags:
"""Covers required per-skill library and framework tags."""
@pytest.mark.parametrize(("skill_id", "required_tags"), TAG_CONVENTION_PARAMETERS)
def test_includes_required_library_and_framework_tags(
self,
skill_id: str,
required_tags: set[str],
) -> None:
"""Ensures each skill includes its required library/framework tags."""
skill = REGISTRY.skills_by_id[skill_id]
skill_tags = set(skill.tags)
assert required_tags.issubset(skill_tags)
class TestTagTaxonomyShape:
"""Covers baseline tag-shape guarantees across all skills."""
@pytest.mark.parametrize("skill_id", SKILL_IDS)
def test_includes_library_or_platform_tag(self, skill_id: str) -> None:
"""Ensures each skill includes at least one library/platform tag."""
skill = REGISTRY.skills_by_id[skill_id]
skill_tags = set(skill.tags)
assert skill_tags & LIBRARY_OR_PLATFORM_TAGS
@pytest.mark.parametrize("skill_id", SKILL_IDS)
def test_includes_domain_facet_tag(self, skill_id: str) -> None:
"""Ensures each skill includes at least one domain facet tag."""
skill = REGISTRY.skills_by_id[skill_id]
skill_tags = set(skill.tags)
assert skill_tags & DOMAIN_FACET_TAGS
@@ -1,172 +0,0 @@
from __future__ import annotations
from pathlib import PurePosixPath
import pytest
from pydantic import ValidationError
from personal_mcp.registry.ingest.document import MarkdownDocument
from personal_mcp.registry.ingest.skill import SkillFilesBundle
from personal_mcp.registry.load import _build_skill_record
from personal_mcp.registry.models.registry import SkillSummaryRecord
from tests.registry.models.test_document_validation import assert_model_is_frozen
from tests.registry.models.test_document_validation import make_markdown_document
from tests.registry.models.test_document_validation import make_skill_frontmatter_payload
pytestmark = pytest.mark.unit
def _make_skill_bundle(
*,
slug: str,
frontmatter: str | None,
reference_files: tuple[str, ...] = (),
other_files: tuple[str, ...] = (),
) -> SkillFilesBundle:
skill = make_markdown_document(
f"skills/{slug}/SKILL.md",
frontmatter=frontmatter,
)
references = tuple(make_markdown_document(f"skills/{slug}/references/{filename}") for filename in reference_files)
other = tuple(make_markdown_document(f"skills/{slug}/{filename}") for filename in other_files)
return SkillFilesBundle(slug=slug, skill=skill, references=references, other=other)
def _docs_index(bundle: SkillFilesBundle) -> dict[PurePosixPath, MarkdownDocument]:
docs = {bundle.skill.relpath: bundle.skill}
for doc in bundle.references:
docs[doc.relpath] = doc
for doc in bundle.other:
docs[doc.relpath] = doc
return docs
class TestSkillValidationGates:
"""Gate-oriented validation coverage for skill conversion."""
class TestGate1LayoutValidation:
"""Gate 1: enforce required source shape before metadata parsing."""
def test_missing_frontmatter_fails_fast(self) -> None:
"""Ensures conversion rejects missing frontmatter at the layout gate."""
bundle = _make_skill_bundle(slug="alpha", frontmatter=None)
with pytest.raises(ValueError, match="missing YAML frontmatter"):
_build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
class TestGate2MetadataValidation:
"""Gate 2: validate skill metadata via pydantic models."""
def test_rejects_non_semver_version(self) -> None:
"""Ensures semver violations fail during frontmatter model validation."""
frontmatter = make_skill_frontmatter_payload(skill_id="alpha", version="not-semver")
bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter)
with pytest.raises(ValidationError, match="version must be semver"):
_build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
def test_rejects_reserved_name_tokens(self) -> None:
"""Ensures reserved words remain blocked for skill names."""
frontmatter = make_skill_frontmatter_payload(skill_id="alpha", name="claude-skill")
bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter)
with pytest.raises(ValidationError, match="reserved words anthropic or claude"):
_build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
def test_rejects_missing_primary_capability(self) -> None:
"""Ensures canonical document capability is required."""
frontmatter = make_skill_frontmatter_payload(skill_id="alpha", capabilities=("resource://docs/index.md",))
bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter)
with pytest.raises(ValueError, match="capabilities must include"):
_build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
class TestGate3ResourceValidation:
"""Gate 3: resolve document and reference resources deterministically."""
def test_discovers_reference_from_filename(self) -> None:
"""Ensures top-level reference files are converted into reference records."""
frontmatter = make_skill_frontmatter_payload(skill_id="alpha")
bundle = _make_skill_bundle(
slug="alpha",
frontmatter=frontmatter,
reference_files=("quick-start.md",),
)
record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
assert set(record.references) == {"quick-start"}
assert record.references["quick-start"].uri == "resource://skills/alpha/references/quick-start"
assert record.references["quick-start"].title == "Quick Start"
def test_declared_reference_requires_existing_document(self) -> None:
"""Ensures declared reference paths must map to discovered markdown files."""
frontmatter = make_skill_frontmatter_payload(
skill_id="alpha",
references={
"guide": {
"path": "references/guide.md",
"title": "Guide",
}
},
)
bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter)
with pytest.raises(KeyError, match="reference document not found"):
_build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
class TestGate5ContractValidation:
"""Gate 5: validate model_dump contract shape for API surfaces."""
def test_skill_record_model_dump_contains_contract_fields(self) -> None:
"""Ensures skill record serialization contains expected resource contract keys."""
frontmatter = make_skill_frontmatter_payload(skill_id="alpha")
bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter)
record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
dumped = record.model_dump()
assert dumped["skill_id"] == "alpha"
assert dumped["document_uri"] == "resource://skills/alpha/document"
assert "references" in dumped
assert "document_content" in dumped
def test_skill_summary_projection_stays_stable(self) -> None:
"""Ensures summary projection keeps only index-safe fields."""
frontmatter = make_skill_frontmatter_payload(skill_id="alpha")
bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter)
record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
summary = SkillSummaryRecord.from_record(record)
assert summary.model_dump() == {
"skill_id": "alpha",
"name": "alpha",
"description": "demo skill",
"tags": ("testing",),
"capabilities": ("resource://skills/alpha/document",),
"document_uri": "resource://skills/alpha/document",
"version": "1.0.0",
}
class TestGate6FreezeValidation:
"""Gate 6: ensure immutable runtime records."""
def test_skill_record_instance_is_frozen(self) -> None:
"""Ensures validated skill records cannot be mutated after creation."""
frontmatter = make_skill_frontmatter_payload(skill_id="alpha")
bundle = _make_skill_bundle(slug="alpha", frontmatter=frontmatter)
record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
assert_model_is_frozen(record, attr="name", value="mutated")
def test_reference_record_values_are_frozen(self) -> None:
"""Ensures nested reference records are immutable after publication."""
frontmatter = make_skill_frontmatter_payload(skill_id="alpha")
bundle = _make_skill_bundle(
slug="alpha",
frontmatter=frontmatter,
reference_files=("guide.md",),
)
record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
assert_model_is_frozen(record.references["guide"], attr="title", value="Mutated")