WIP simplifying load/startup

This commit is contained in:
John Lancaster
2026-07-26 17:23:39 -05:00
parent 5e20f69cfe
commit 42ea105bee
30 changed files with 855 additions and 766 deletions
+2 -2
View File
@@ -98,7 +98,7 @@ class TestMarkdownDocument:
def test_none_for_incomplete_skill(self) -> None:
"""Ensures skill_slug is None for incomplete skills paths."""
doc = MarkdownDocument(relpath=PurePosixPath("skills/demo"), content="#")
doc = MarkdownDocument(relpath=PurePosixPath("skills/demo.md"), content="#")
assert doc.skill_slug is None
@@ -119,7 +119,7 @@ class TestMarkdownDocument:
def test_none_for_incomplete_prompt(self) -> None:
"""Ensures prompt_slug is None for incomplete prompt paths."""
doc = MarkdownDocument(relpath=PurePosixPath("prompts/demo"), content="#")
doc = MarkdownDocument(relpath=PurePosixPath("prompts/demo.md"), content="#")
assert doc.prompt_slug is None
@@ -8,8 +8,9 @@ import yaml
from pydantic import ValidationError
from personal_mcp.registry.ingest.document import MarkdownDocument
from personal_mcp.registry.load import _parse_frontmatter
from personal_mcp.registry.models.common import _normalize_docs_path
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
@@ -95,37 +96,43 @@ def assert_model_is_frozen(instance: Any, *, attr: str, value: Any) -> None:
setattr(instance, attr, value)
class TestGate1LayoutValidation:
"""Gate 1: source layout and parse shape constraints."""
def test_parse_frontmatter_requires_payload(self) -> None:
"""Ensures missing frontmatter fails before metadata validation."""
with pytest.raises(ValueError, match="missing YAML frontmatter"):
_parse_frontmatter(None, path=PurePosixPath("skills/alpha/SKILL.md"))
def test_parse_frontmatter_requires_mapping(self) -> None:
"""Ensures non-object YAML payloads are rejected at parse time."""
with pytest.raises(TypeError, match="frontmatter must parse to an object"):
_parse_frontmatter("- one\n- two\n", path=PurePosixPath("skills/alpha/SKILL.md"))
def test_parse_frontmatter_accepts_mapping(self) -> None:
"""Ensures valid mapping payload is returned for downstream validation."""
parsed = _parse_frontmatter("name: alpha\n", path=PurePosixPath("skills/alpha/SKILL.md"))
assert parsed == {"name": "alpha"}
class TestGate5ContractValidation:
"""Gate 5: canonical resource-path contract normalization."""
"""Gate 5: canonical resource-path contracts."""
def test_normalize_docs_path_keeps_posix_relative_paths(self) -> None:
"""Ensures docs paths remain normalized before registry publication."""
assert _normalize_docs_path("skills/demo/SKILL.md") == "skills/demo/SKILL.md"
def test_parse_docs_path_returns_pure_posix_path(self) -> None:
"""Ensures boundary strings become path objects before publication."""
path = parse_docs_path("skills/demo/SKILL.md")
def test_normalize_docs_path_rejects_parent_traversal(self) -> None:
"""Ensures traversal attempts fail contract validation."""
with pytest.raises(ValueError, match="normalized docs-relative path"):
_normalize_docs_path("../outside.md")
assert path == PurePosixPath("skills/demo/SKILL.md")
assert isinstance(path, PurePosixPath)
@pytest.mark.parametrize(
"value",
(
"/absolute.md",
"../outside.md",
"skills\\demo\\SKILL.md",
"skills//demo/SKILL.md",
"skills/demo/README.txt",
),
)
def test_parse_docs_path_rejects_invalid_paths(self, value: str) -> None:
"""Ensures non-canonical docs paths fail contract validation."""
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:
@@ -133,13 +140,14 @@ class TestGate6FreezeValidation:
def test_docs_registry_copies_mapping_inputs(self) -> None:
"""Ensures registry snapshots are isolated from caller-owned mapping mutations."""
source_docs = {"index.md": "# index\n"}
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.md",),
docs_markdown_path_index=(index_path,),
tag_to_skill_ids={},
capability_to_skill_ids={},
prompts_by_id={},
@@ -148,9 +156,10 @@ class TestGate6FreezeValidation:
tag_to_prompt_ids={},
)
source_docs["other.md"] = "# other\n"
source_docs[PurePosixPath("other.md")] = "# other\n"
assert "other.md" not in registry.docs_markdown_by_path
assert PurePosixPath("other.md") not in registry.docs_markdown_by_path
assert registry.docs_markdown_path_index == (index_path,)
def test_docs_registry_instance_is_frozen(self) -> None:
"""Ensures frozen model prevents attribute reassignment."""
@@ -168,4 +177,8 @@ class TestGate6FreezeValidation:
tag_to_prompt_ids={},
)
assert_model_is_frozen(registry, attr="docs_markdown_path_index", value=("index.md",))
assert_model_is_frozen(
registry,
attr="docs_markdown_path_index",
value=(PurePosixPath("index.md"),),
)
+26 -15
View File
@@ -1,13 +1,15 @@
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.prompt import PromptFilesBundle
from personal_mcp.registry.load import _build_prompt_record
from personal_mcp.registry.load import load_docs_registry
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 as_markdown
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
@@ -91,7 +93,7 @@ class TestPromptValidationGates:
record = _build_prompt_record(bundle=bundle)
assert record.document_uri == "resource://prompts/initial/document"
assert record.document_relpath == "prompts/initial/PROMPT.md"
assert record.document_relpath == PurePosixPath("prompts/initial/PROMPT.md")
def test_preserves_argument_schema(self) -> None:
"""Ensures argument metadata survives conversion unchanged."""
@@ -114,21 +116,31 @@ class TestPromptValidationGates:
class TestGate4GraphValidation:
"""Gate 4: validate cross-entity identifier coherence."""
def test_prompt_id_collision_with_skill_id_fails(self, tmp_path) -> None:
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_dir = tmp_path / "skills" / "shared"
prompt_dir = tmp_path / "prompts" / "shared"
skill_dir.mkdir(parents=True)
prompt_dir.mkdir(parents=True)
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,
),
}
(skill_dir / "SKILL.md").write_text(as_markdown(skill_frontmatter), encoding="utf-8")
(prompt_dir / "PROMPT.md").write_text(as_markdown(prompt_frontmatter), encoding="utf-8")
def fake_from_root(_cls, _root):
return documents
with pytest.raises(ValueError, match="prompt_id collides with existing skill_id"):
load_docs_registry(package_anchor="personal_mcp", docs_root=str(tmp_path))
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."""
@@ -187,5 +199,4 @@ class TestPromptValidationGates:
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
record = _build_prompt_record(bundle=bundle)
with pytest.raises(ValidationError, match="Instance is frozen"):
record.arguments["topic"].required = False
assert_model_is_frozen(record.arguments["topic"], attr="required", value=False)
@@ -1,14 +1,20 @@
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
@@ -22,13 +28,13 @@ def _make_skill_record() -> SkillRecord:
tags=("testing", "catalog"),
capabilities=("resource://skills/demo-skill/document", "resource://catalog/skills_index"),
document_uri="resource://skills/demo-skill/document",
document_relpath="skills/demo-skill/SKILL.md",
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="skills/demo-skill/references/zeta.md",
relpath=PurePosixPath("skills/demo-skill/references/zeta.md"),
mime_type="text/markdown",
title="Zeta",
content="# zeta",
@@ -36,7 +42,7 @@ def _make_skill_record() -> SkillRecord:
"alpha": ReferenceRecord(
ref_id="alpha",
uri="resource://skills/demo-skill/references/alpha",
relpath="skills/demo-skill/references/alpha.md",
relpath=PurePosixPath("skills/demo-skill/references/alpha.md"),
mime_type="text/markdown",
title="Alpha",
content="# alpha",
@@ -61,7 +67,7 @@ def _make_prompt_record() -> PromptRecord:
)
},
document_uri="resource://prompts/demo-prompt/document",
document_relpath="prompts/demo-prompt/PROMPT.md",
document_relpath=PurePosixPath("prompts/demo-prompt/PROMPT.md"),
document_content="# demo",
)
@@ -121,3 +127,25 @@ 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)
@@ -2,14 +2,11 @@ from __future__ import annotations
import pytest
from personal_mcp.registry.load import load_docs_registry
from personal_mcp.registry.load import get_docs_registry
pytestmark = pytest.mark.unit
REGISTRY = load_docs_registry(
package_anchor="personal_mcp",
docs_root="../../docs",
)
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.
@@ -21,7 +18,7 @@ REQUIRED_LIBRARY_TAGS_BY_SKILL = {
"nicegui": {"nicegui", "fastapi"},
"nicegui-ui-customization": {"nicegui", "fastapi"},
"pytesting": {"pytest", "testing", "fastapi", "asyncio", "anyio"},
"python-logging-dictconfig": {"python", "logging"},
"python-logging": {"python", "logging"},
"python-typing": {"python", "typing"},
"ruff-linting-formating": {"ruff", "python"},
"vscode-configuration": {"vscode", "debugpy", "fastapi", "python"},
@@ -59,6 +56,7 @@ DOMAIN_FACET_TAGS = {
"authoring",
"bootstrap",
"ci",
"configuration",
"custom-agents",
"customization",
"deterministic",
@@ -169,5 +169,4 @@ class TestSkillValidationGates:
)
record = _build_skill_record(bundle=bundle, docs_by_relpath=_docs_index(bundle))
with pytest.raises(ValidationError, match="Instance is frozen"):
record.references["guide"].title = "Mutated"
assert_model_is_frozen(record.references["guide"], attr="title", value="Mutated")
+94
View File
@@ -0,0 +1,94 @@
from pathlib import PurePosixPath
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",
description="demo prompt",
version="1.0.0",
tags=("testing",),
capabilities=("resource://prompts/demo-prompt/document",),
arguments={},
document_uri="resource://prompts/demo-prompt/document",
document_relpath=prompt_path,
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),),
tag_to_prompt_ids={"testing": (prompt.prompt_id,)},
)
def test_reads_docs_path_from_string_boundary() -> None:
payload = read_docs_markdown_path(_make_registry(), "index.md")
assert payload == {
"uri": "resource://docs/index.md",
"format": "markdown",
"source_path": "docs/index.md",
"content": "# index",
}
def test_rejects_non_posix_docs_path() -> None:
with pytest.raises(ValueError, match="POSIX separators"):
read_docs_markdown_path(_make_registry(), "skills\\demo\\SKILL.md")
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")
+1 -1
View File
@@ -32,7 +32,7 @@ def mcp_session_factory():
@asynccontextmanager
async def create_session(*, initialize: bool = True) -> AsyncIterator[ClientSession]:
app = create_app()
mcp_url = f"http://testserver{app.state.settings.mcp_route}"
mcp_url = f"http://testserver{app.state.settings.mounts.mcp}"
async with (
app.router.lifespan_context(app),
AsyncClient(