changed to skill provider
This commit is contained in:
@@ -6,7 +6,6 @@ import pytest
|
||||
|
||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
||||
from personal_mcp.registry.ingest.skill import SkillFilesBundle
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
@@ -23,15 +22,6 @@ class TestCurrentDocsIngestion:
|
||||
|
||||
assert docs
|
||||
|
||||
def test_bundles_current_skills(self) -> None:
|
||||
"""Ensures all current canonical skill documents can be bundled."""
|
||||
docs = MarkdownDocument.from_root(DOCS_ROOT)
|
||||
expected_slugs = {path.parent.name for path in DOCS_ROOT.glob("skills/*/SKILL.md")}
|
||||
|
||||
bundles = SkillFilesBundle.from_docs(docs.values())
|
||||
|
||||
assert {bundle.slug for bundle in bundles} == expected_slugs
|
||||
|
||||
def test_bundles_current_prompts(self) -> None:
|
||||
"""Ensures all current canonical prompt documents can be bundled."""
|
||||
docs = MarkdownDocument.from_root(DOCS_ROOT)
|
||||
|
||||
@@ -81,27 +81,6 @@ class TestMarkdownDocument:
|
||||
|
||||
assert doc.frontmatter is None
|
||||
|
||||
class TestSkillSlugProperty:
|
||||
"""Covers skill_slug derivation from document relative paths."""
|
||||
|
||||
def test_returns_slug(self) -> None:
|
||||
"""Ensures skill_slug returns the slug for valid skills paths."""
|
||||
doc = MarkdownDocument(relpath=PurePosixPath("skills/demo/SKILL.md"), content="#")
|
||||
|
||||
assert doc.skill_slug == "demo"
|
||||
|
||||
def test_none_for_non_skill(self) -> None:
|
||||
"""Ensures skill_slug is None for non-skills paths."""
|
||||
doc = MarkdownDocument(relpath=PurePosixPath("docs/index.md"), content="#")
|
||||
|
||||
assert doc.skill_slug is None
|
||||
|
||||
def test_none_for_incomplete_skill(self) -> None:
|
||||
"""Ensures skill_slug is None for incomplete skills paths."""
|
||||
doc = MarkdownDocument(relpath=PurePosixPath("skills/demo.md"), content="#")
|
||||
|
||||
assert doc.skill_slug is None
|
||||
|
||||
class TestPromptSlugProperty:
|
||||
"""Covers prompt_slug derivation from document relative paths."""
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||
from personal_mcp.registry.ingest.skill import SkillFilesBundle
|
||||
from personal_mcp.registry.ingest.skill import group_skill_paths
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
MakeDoc = Callable[[str], MarkdownDocument]
|
||||
|
||||
|
||||
class TestSkillFilesBundle:
|
||||
"""Covers SkillFilesBundle construction and path-based categorization."""
|
||||
|
||||
class TestFromRoot:
|
||||
"""Covers bundle creation from resource roots."""
|
||||
|
||||
def test_builds_bundles(self, tmp_path: Path) -> None:
|
||||
"""Ensures from_root builds bundles from discovered markdown docs."""
|
||||
alpha = tmp_path / "skills" / "alpha"
|
||||
beta = tmp_path / "skills" / "beta"
|
||||
(alpha / "references").mkdir(parents=True)
|
||||
beta.mkdir(parents=True)
|
||||
(alpha / "SKILL.md").write_text("# alpha\n", encoding="utf-8")
|
||||
(alpha / "references" / "one.md").write_text("ref\n", encoding="utf-8")
|
||||
(beta / "SKILL.md").write_text("# beta\n", encoding="utf-8")
|
||||
|
||||
bundles = SkillFilesBundle.from_root(tmp_path)
|
||||
|
||||
assert {bundle.slug for bundle in bundles} == {"alpha", "beta"}
|
||||
|
||||
def test_delegates_to_from_docs(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Ensures from_root delegates bundle assembly to from_docs."""
|
||||
alpha = tmp_path / "skills" / "alpha"
|
||||
beta = tmp_path / "skills" / "beta"
|
||||
(alpha / "references").mkdir(parents=True)
|
||||
beta.mkdir(parents=True)
|
||||
(alpha / "SKILL.md").write_text("# alpha\n", encoding="utf-8")
|
||||
(alpha / "references" / "one.md").write_text("ref\n", encoding="utf-8")
|
||||
(beta / "SKILL.md").write_text("# beta\n", encoding="utf-8")
|
||||
|
||||
from_root = SkillFilesBundle.from_root(tmp_path)
|
||||
from_docs = SkillFilesBundle.from_docs(MarkdownDocument.from_root(tmp_path).values())
|
||||
|
||||
assert tuple(from_root) == from_docs
|
||||
|
||||
class TestFromDocs:
|
||||
"""Covers bundle creation from preloaded markdown documents."""
|
||||
|
||||
def test_groups_by_slug(self, make_doc: MakeDoc) -> None:
|
||||
"""Ensures from_docs groups documents by skill slug."""
|
||||
docs = [
|
||||
make_doc("skills/alpha/SKILL.md"),
|
||||
make_doc("skills/alpha/references/a.md"),
|
||||
make_doc("skills/beta/SKILL.md"),
|
||||
]
|
||||
|
||||
bundles = SkillFilesBundle.from_docs(docs)
|
||||
|
||||
assert {bundle.slug for bundle in bundles} == {"alpha", "beta"}
|
||||
|
||||
def test_one_bundle_per_slug(self, make_doc: MakeDoc) -> None:
|
||||
"""Ensures from_docs produces one SkillFilesBundle per slug."""
|
||||
docs = [
|
||||
make_doc("skills/alpha/SKILL.md"),
|
||||
make_doc("skills/alpha/references/r1.md"),
|
||||
make_doc("skills/alpha/references/r2.md"),
|
||||
]
|
||||
|
||||
bundles = SkillFilesBundle.from_docs(docs)
|
||||
|
||||
assert len(bundles) == 1
|
||||
assert bundles[0].slug == "alpha"
|
||||
|
||||
class TestFromPaths:
|
||||
"""Covers classification of skill, reference, and other documents."""
|
||||
|
||||
def test_selects_skill_md(self, make_doc: MakeDoc) -> None:
|
||||
"""Ensures from_paths selects SKILL.md as the primary document."""
|
||||
docs = {
|
||||
make_doc("skills/alpha/SKILL.md"),
|
||||
make_doc("skills/alpha/notes.md"),
|
||||
}
|
||||
|
||||
bundle = SkillFilesBundle.from_paths("alpha", docs)
|
||||
|
||||
assert bundle.skill.relpath.name == "SKILL.md"
|
||||
|
||||
def test_collects_references(self, make_doc: MakeDoc) -> None:
|
||||
"""Ensures from_paths captures reference docs under references/."""
|
||||
docs = {
|
||||
make_doc("skills/alpha/SKILL.md"),
|
||||
make_doc("skills/alpha/references/r1.md"),
|
||||
make_doc("skills/alpha/references/r2.md"),
|
||||
make_doc("skills/alpha/notes.md"),
|
||||
}
|
||||
|
||||
bundle = SkillFilesBundle.from_paths("alpha", docs)
|
||||
|
||||
assert {doc.relpath.as_posix() for doc in bundle.references} == {
|
||||
"skills/alpha/references/r1.md",
|
||||
"skills/alpha/references/r2.md",
|
||||
}
|
||||
|
||||
def test_collects_other_docs(self, make_doc: MakeDoc) -> None:
|
||||
"""Ensures from_paths classifies non-reference docs as other docs."""
|
||||
docs = {
|
||||
make_doc("skills/alpha/SKILL.md"),
|
||||
make_doc("skills/alpha/references/r1.md"),
|
||||
make_doc("skills/alpha/notes.md"),
|
||||
make_doc("skills/alpha/changelog.md"),
|
||||
}
|
||||
|
||||
bundle = SkillFilesBundle.from_paths("alpha", docs)
|
||||
|
||||
assert {doc.relpath.as_posix() for doc in bundle.other} == {
|
||||
"skills/alpha/changelog.md",
|
||||
"skills/alpha/notes.md",
|
||||
}
|
||||
|
||||
|
||||
class TestGroupSkillPaths:
|
||||
"""Covers grouping markdown documents by derived skill slug."""
|
||||
|
||||
def test_groups_slugged_docs(self, make_doc: MakeDoc) -> None:
|
||||
"""Ensures group_skill_paths groups only documents with a slug."""
|
||||
docs = [
|
||||
make_doc("skills/alpha/SKILL.md"),
|
||||
make_doc("skills/alpha/references/r.md"),
|
||||
make_doc("skills/beta/SKILL.md"),
|
||||
]
|
||||
|
||||
grouped = group_skill_paths(docs)
|
||||
|
||||
assert set(grouped) == {"alpha", "beta"}
|
||||
|
||||
def test_excludes_unslugged_docs(self, make_doc: MakeDoc) -> None:
|
||||
"""Ensures group_skill_paths excludes documents without skill slugs."""
|
||||
docs = [
|
||||
make_doc("docs/index.md"),
|
||||
make_doc("content/usage.md"),
|
||||
]
|
||||
|
||||
assert group_skill_paths(docs) == {}
|
||||
|
||||
def test_returns_sets(self, make_doc: MakeDoc) -> None:
|
||||
"""Ensures group_skill_paths returns sets of docs per slug."""
|
||||
grouped = group_skill_paths([make_doc("skills/alpha/SKILL.md")])
|
||||
|
||||
assert isinstance(grouped["alpha"], set)
|
||||
|
||||
def test_stable_grouping(self, make_doc: MakeDoc) -> None:
|
||||
"""Ensures group_skill_paths behaves consistently after internal sorting."""
|
||||
docs = [
|
||||
make_doc("skills/beta/SKILL.md"),
|
||||
make_doc("skills/alpha/references/a.md"),
|
||||
make_doc("skills/alpha/SKILL.md"),
|
||||
]
|
||||
|
||||
grouped_forward = group_skill_paths(docs)
|
||||
grouped_reverse = group_skill_paths(list(reversed(docs)))
|
||||
|
||||
assert grouped_forward == grouped_reverse
|
||||
@@ -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")
|
||||
@@ -5,42 +5,15 @@ import pytest
|
||||
from personal_mcp.registry.models.registry import DocsRegistry
|
||||
from personal_mcp.registry.models.registry import PromptRecord
|
||||
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
||||
from personal_mcp.registry.models.registry import ReferenceRecord
|
||||
from personal_mcp.registry.models.registry import SkillRecord
|
||||
from personal_mcp.registry.models.registry import SkillSummaryRecord
|
||||
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
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _make_registry() -> DocsRegistry:
|
||||
skill_path = PurePosixPath("skills/demo/SKILL.md")
|
||||
reference_path = PurePosixPath("skills/demo/references/guide.md")
|
||||
prompt_path = PurePosixPath("prompts/demo-prompt/PROMPT.md")
|
||||
index_path = PurePosixPath("index.md")
|
||||
reference = ReferenceRecord(
|
||||
ref_id="guide",
|
||||
uri="resource://skills/demo/references/guide",
|
||||
relpath=reference_path,
|
||||
mime_type="text/markdown",
|
||||
title="Guide",
|
||||
content="# guide",
|
||||
)
|
||||
skill = SkillRecord(
|
||||
skill_id="demo",
|
||||
name="demo",
|
||||
description="demo skill",
|
||||
version="1.0.0",
|
||||
tags=("testing",),
|
||||
capabilities=("resource://skills/demo/document",),
|
||||
document_uri="resource://skills/demo/document",
|
||||
document_relpath=skill_path,
|
||||
document_content="# demo",
|
||||
references={"guide": reference},
|
||||
)
|
||||
prompt = PromptRecord(
|
||||
prompt_id="demo-prompt",
|
||||
name="demo-prompt",
|
||||
@@ -54,13 +27,8 @@ def _make_registry() -> DocsRegistry:
|
||||
document_content="# prompt",
|
||||
)
|
||||
return DocsRegistry(
|
||||
skills_by_id={skill.skill_id: skill},
|
||||
skills_in_load_order=(skill.skill_id,),
|
||||
skills_summary_in_load_order=(SkillSummaryRecord.from_record(skill),),
|
||||
docs_markdown_by_path={index_path: "# index"},
|
||||
docs_markdown_path_index=(index_path,),
|
||||
tag_to_skill_ids={"testing": (skill.skill_id,)},
|
||||
capability_to_skill_ids={},
|
||||
prompts_by_id={prompt.prompt_id: prompt},
|
||||
prompts_in_load_order=(prompt.prompt_id,),
|
||||
prompts_summary_in_load_order=(PromptSummaryRecord.from_record(prompt),),
|
||||
@@ -87,8 +55,4 @@ def test_rejects_non_posix_docs_path() -> None:
|
||||
def test_serializes_record_paths_in_document_payloads() -> None:
|
||||
registry = _make_registry()
|
||||
|
||||
assert read_skill_document(registry, "demo")["source_path"] == "docs/skills/demo/SKILL.md"
|
||||
assert read_skill_reference(registry, skill_id="demo", ref_id="guide")["source_path"] == (
|
||||
"docs/skills/demo/references/guide.md"
|
||||
)
|
||||
assert read_prompt_document(registry, "demo-prompt")["source_path"] == ("docs/prompts/demo-prompt/PROMPT.md")
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastmcp import Client
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.skills import get_skill_manifest
|
||||
from fastmcp.utilities.skills import list_skills
|
||||
|
||||
from personal_mcp.skills import create_skills_provider
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
SKILLS_ROOT = Path(__file__).parents[2] / "docs" / "skills"
|
||||
|
||||
|
||||
class TestSkillsProvider:
|
||||
"""Covers native FastMCP skill discovery and retrieval."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovers_authored_skills(self) -> None:
|
||||
"""Ensures each authored skill is exposed with its description."""
|
||||
expected_names = {path.parent.name for path in SKILLS_ROOT.glob("*/SKILL.md")}
|
||||
mcp = FastMCP("skills-test")
|
||||
mcp.add_provider(create_skills_provider())
|
||||
|
||||
async with Client(mcp) as client:
|
||||
skills = await list_skills(client)
|
||||
|
||||
assert {skill.name for skill in skills} == expected_names
|
||||
assert all(skill.description for skill in skills)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reads_manifest_and_supporting_file(self) -> None:
|
||||
"""Ensures manifests disclose hashed files that remain directly readable."""
|
||||
mcp = FastMCP("skills-test")
|
||||
mcp.add_provider(create_skills_provider())
|
||||
|
||||
async with Client(mcp) as client:
|
||||
manifest = await get_skill_manifest(client, "mcp-details")
|
||||
reference = next(file for file in manifest.files if file.path.startswith("references/"))
|
||||
contents = await client.read_resource(f"skill://mcp-details/{reference.path}")
|
||||
|
||||
assert any(file.path == "SKILL.md" for file in manifest.files)
|
||||
assert all(file.hash.startswith("sha256:") for file in manifest.files)
|
||||
assert reference.size > 0
|
||||
assert contents
|
||||
|
||||
def test_frontmatter_names_match_directories(self) -> None:
|
||||
"""Ensures provider identity and authored skill names remain aligned."""
|
||||
for skill_file in SKILLS_ROOT.glob("*/SKILL.md"):
|
||||
raw = skill_file.read_text(encoding="utf-8")
|
||||
frontmatter = yaml.safe_load(raw.split("---", 2)[1])
|
||||
|
||||
assert set(frontmatter) == {"name", "description"}
|
||||
assert frontmatter["name"] == skill_file.parent.name
|
||||
assert frontmatter["description"]
|
||||
+30
-130
@@ -6,173 +6,73 @@ import pytest
|
||||
|
||||
pytestmark = pytest.mark.smoke
|
||||
|
||||
|
||||
REQUIRED_TOOL_NAMES = (
|
||||
RETIRED_TOOL_NAMES = {
|
||||
"search_patterns",
|
||||
"get_pattern_by_id",
|
||||
"get_skill_document_by_id",
|
||||
"search_prompts",
|
||||
"get_prompt_by_id",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
REQUIRED_RESOURCE_URIS = (
|
||||
"resource://catalog/skills_index",
|
||||
"resource://catalog/prompts_index",
|
||||
)
|
||||
|
||||
TOOL_NAME_PARAMETERS = tuple(
|
||||
pytest.param(
|
||||
tool_name,
|
||||
id=tool_name.replace("_", "-"),
|
||||
)
|
||||
for tool_name in REQUIRED_TOOL_NAMES
|
||||
)
|
||||
|
||||
|
||||
SEARCH_QUERY_PARAMETERS = (
|
||||
pytest.param(
|
||||
"pytest",
|
||||
{"pytesting"},
|
||||
id="query-pytest",
|
||||
),
|
||||
pytest.param(
|
||||
"asyncio",
|
||||
{"pytesting", "async-fastapi-sqlmodel"},
|
||||
id="query-asyncio",
|
||||
),
|
||||
pytest.param(
|
||||
"fastapi testing",
|
||||
{"pytesting"},
|
||||
id="query-fastapi-testing",
|
||||
),
|
||||
pytest.param(
|
||||
"asyncio fastapi testing deterministic pytest",
|
||||
{"pytesting"},
|
||||
id="query-composite-async-fastapi-testing-deterministic-pytest",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
RESOURCE_URI_PARAMETERS = tuple(
|
||||
pytest.param(
|
||||
resource_uri,
|
||||
id=resource_uri.removeprefix("resource://").replace("/", "-"),
|
||||
)
|
||||
for resource_uri in REQUIRED_RESOURCE_URIS
|
||||
)
|
||||
|
||||
|
||||
class TestMcpCatalogSurface:
|
||||
"""Covers smoke-level MCP catalog discovery and tool execution paths."""
|
||||
class TestMcpSkillsSurface:
|
||||
"""Covers native skill resources over the HTTP MCP surface."""
|
||||
|
||||
class TestTools:
|
||||
"""Covers MCP tool-list and tool-call smoke behavior."""
|
||||
"""Covers generic resource fallback tools for native skills."""
|
||||
|
||||
@pytest.mark.parametrize("tool_name", TOOL_NAME_PARAMETERS)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_core_catalog_tools(
|
||||
self,
|
||||
mcp_session_factory,
|
||||
tool_name: str,
|
||||
) -> None:
|
||||
"""Ensures tools/list exposes each required core catalog tool name."""
|
||||
async def test_lists_resource_fallback_tools(self, mcp_session_factory) -> None:
|
||||
"""Ensures generic resource tools replace skill-specific catalog tools."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.list_tools()
|
||||
tool_names = {tool.name for tool in result.tools}
|
||||
|
||||
assert tool_name in tool_names
|
||||
assert {"list_resources", "read_resource"}.issubset(tool_names)
|
||||
assert RETIRED_TOOL_NAMES.isdisjoint(tool_names)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_search_patterns_tool(self, mcp_session_factory) -> None:
|
||||
"""Ensures tools/call succeeds for search_patterns with basic args."""
|
||||
async def test_reads_skill_through_fallback_tool(self, mcp_session_factory) -> None:
|
||||
"""Ensures tool-only clients can read a native skill resource."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.call_tool(
|
||||
"search_patterns",
|
||||
{
|
||||
"query": "pytest",
|
||||
"limit": 5,
|
||||
},
|
||||
"read_resource",
|
||||
{"uri": "skill://mcp-details/SKILL.md"},
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
assert result.content
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query", "expected_skill_ids"),
|
||||
SEARCH_QUERY_PARAMETERS,
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_patterns_matches_expected_skills_for_query_terms(
|
||||
self,
|
||||
mcp_session_factory,
|
||||
query: str,
|
||||
expected_skill_ids: set[str],
|
||||
) -> None:
|
||||
"""Ensures query terms return expected skill IDs from search_patterns."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.call_tool(
|
||||
"search_patterns",
|
||||
{
|
||||
"query": query,
|
||||
"limit": 20,
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
assert result.content
|
||||
|
||||
payload = json.loads(result.content[0].text)
|
||||
found_skill_ids = {pattern["id"] for pattern in payload["patterns"]}
|
||||
|
||||
assert expected_skill_ids.issubset(found_skill_ids)
|
||||
|
||||
class TestResources:
|
||||
"""Covers MCP resource and resource-template discovery."""
|
||||
"""Covers native skill resources, manifests, and file templates."""
|
||||
|
||||
@pytest.mark.parametrize("resource_uri", RESOURCE_URI_PARAMETERS)
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_catalog_resources(
|
||||
self,
|
||||
mcp_session_factory,
|
||||
resource_uri: str,
|
||||
) -> None:
|
||||
"""Ensures resources/list exposes each required catalog resource URI."""
|
||||
async def test_lists_main_file_and_manifest(self, mcp_session_factory) -> None:
|
||||
"""Ensures resources/list exposes native skill entry points."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.list_resources()
|
||||
resource_uris = {str(resource.uri) for resource in result.resources}
|
||||
|
||||
assert resource_uri in resource_uris
|
||||
assert "skill://mcp-details/SKILL.md" in resource_uris
|
||||
assert "skill://mcp-details/_manifest" in resource_uris
|
||||
assert "resource://catalog/skills_index" not in resource_uris
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_resource_templates(self, mcp_session_factory) -> None:
|
||||
"""Ensures resources/templates/list includes skills and prompt templates."""
|
||||
"""Ensures supporting files use per-skill wildcard templates."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.list_resource_templates()
|
||||
template_uris = {template.uriTemplate for template in result.resourceTemplates}
|
||||
|
||||
assert "resource://skills/{skill_id}/document" in template_uris
|
||||
assert "resource://prompts/{prompt_id}/document" in template_uris
|
||||
assert "skill://mcp-details/{path*}" in template_uris
|
||||
assert "resource://skills/{skill_id}/document" not in template_uris
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reads_mcp_details_skill_document(self, mcp_session_factory) -> None:
|
||||
"""Ensures read_resource resolves the mcp-details skill document URI."""
|
||||
async def test_reads_manifest_and_supporting_file(self, mcp_session_factory) -> None:
|
||||
"""Ensures manifest paths resolve through the supporting-file template."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.call_tool(
|
||||
"read_resource",
|
||||
{"uri": "resource://skills/mcp-details/document"},
|
||||
)
|
||||
manifest_result = await mcp_session.read_resource("skill://mcp-details/_manifest")
|
||||
manifest = json.loads(manifest_result.contents[0].text)
|
||||
reference = next(file["path"] for file in manifest["files"] if file["path"].startswith("references/"))
|
||||
reference_result = await mcp_session.read_resource(f"skill://mcp-details/{reference}")
|
||||
|
||||
assert result.isError is False
|
||||
assert result.content
|
||||
|
||||
class TestPrompts:
|
||||
"""Covers MCP prompt discovery surface."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_registered_prompts(self, mcp_session_factory) -> None:
|
||||
"""Ensures prompts/list returns at least one registered prompt."""
|
||||
async with mcp_session_factory() as mcp_session:
|
||||
result = await mcp_session.list_prompts()
|
||||
|
||||
assert result.prompts
|
||||
assert manifest["skill"] == "mcp-details"
|
||||
assert reference_result.contents
|
||||
|
||||
Reference in New Issue
Block a user