diff --git a/src/personal_mcp/catalog/server.py b/src/personal_mcp/catalog/server.py index eea995a..1bdb3e8 100644 --- a/src/personal_mcp/catalog/server.py +++ b/src/personal_mcp/catalog/server.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from personal_mcp.registry.contracts import DocsRegistry +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 SkillPatternPayload diff --git a/src/personal_mcp/mcp.py b/src/personal_mcp/mcp.py index da073d6..bf98f00 100644 --- a/src/personal_mcp/mcp.py +++ b/src/personal_mcp/mcp.py @@ -20,8 +20,8 @@ from personal_mcp.catalog.server import get_pattern_by_id_payload from personal_mcp.catalog.server import get_prompt_by_id_payload from personal_mcp.catalog.server import search_patterns_payload from personal_mcp.catalog.server import search_prompts_payload -from personal_mcp.registry.contracts import DocsRegistry from personal_mcp.registry.load import load_docs_registry +from personal_mcp.registry.models.registry import DocsRegistry 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 diff --git a/src/personal_mcp/registry/load.py b/src/personal_mcp/registry/load.py index 577943b..aed074e 100644 --- a/src/personal_mcp/registry/load.py +++ b/src/personal_mcp/registry/load.py @@ -5,6 +5,8 @@ from collections import defaultdict from pathlib import Path from pathlib import PurePosixPath +import yaml + from personal_mcp.registry.ingest.document import MarkdownDocument from personal_mcp.registry.ingest.prompt import PromptFilesBundle from personal_mcp.registry.ingest.skill import SkillFilesBundle @@ -19,6 +21,22 @@ from personal_mcp.registry.models.registry import SkillRecord from personal_mcp.registry.models.registry import SkillSummaryRecord +def _parse_frontmatter(raw_frontmatter: str | None, *, path: PurePosixPath) -> dict[str, object]: + """Parse frontmatter YAML into a mapping for downstream validation. + + This helper is retained for compatibility with model-validation tests that + exercise gate behavior directly at parse boundaries. + """ + if raw_frontmatter is None: + raise ValueError(f"missing YAML frontmatter: {path.as_posix()}") + + parsed = yaml.safe_load(raw_frontmatter) + if not isinstance(parsed, dict): + raise TypeError(f"frontmatter must parse to an object: {path.as_posix()}") + + return parsed + + def _build_skill_record( *, bundle: SkillFilesBundle, docs_by_relpath: dict[PurePosixPath, MarkdownDocument] ) -> SkillRecord: diff --git a/src/personal_mcp/registry/read.py b/src/personal_mcp/registry/read.py index 97e676b..dd43888 100644 --- a/src/personal_mcp/registry/read.py +++ b/src/personal_mcp/registry/read.py @@ -1,5 +1,5 @@ -from .contracts import DocsRegistry from .models.common import _normalize_docs_path +from .models.registry import DocsRegistry def read_skill_document(registry: DocsRegistry, skill_id: str) -> dict[str, str]: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d12aadb --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package marker for intra-suite imports.""" diff --git a/tests/registry/models/test_prompt_validation.py b/tests/registry/models/test_prompt_validation.py index 2c40057..3f8d8a5 100644 --- a/tests/registry/models/test_prompt_validation.py +++ b/tests/registry/models/test_prompt_validation.py @@ -5,8 +5,8 @@ from pydantic import ValidationError from personal_mcp.registry.ingest.prompt import PromptFilesBundle from personal_mcp.registry.load import _build_prompt_record -from personal_mcp.registry.load import _to_prompt_summary from personal_mcp.registry.load import load_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 @@ -154,7 +154,7 @@ class TestPromptValidationGates: frontmatter = make_prompt_frontmatter_payload(prompt_id="initial") bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter) record = _build_prompt_record(bundle=bundle) - summary = _to_prompt_summary(record) + summary = PromptSummaryRecord.from_record(record) assert summary.model_dump() == { "prompt_id": "initial", diff --git a/tests/registry/models/test_registry_payload_models.py b/tests/registry/models/test_registry_payload_models.py new file mode 100644 index 0000000..ef4964c --- /dev/null +++ b/tests/registry/models/test_registry_payload_models.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import pytest + +from personal_mcp.registry.models.prompt import PromptArgumentEntry +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 + +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"), + depends_on=("base-skill",), + document_uri="resource://skills/demo-skill/document", + document_relpath="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", + mime_type="text/markdown", + title="Zeta", + content="# zeta", + ), + "alpha": ReferenceRecord( + ref_id="alpha", + uri="resource://skills/demo-skill/references/alpha", + relpath="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", + name="demo-prompt", + description="demo prompt", + version="0.9.0", + tags=("testing",), + capabilities=("resource://prompts/demo-prompt/document",), + arguments={ + "topic": PromptArgumentEntry( + type="string", + required=True, + description="topic to discuss", + ) + }, + document_uri="resource://prompts/demo-prompt/document", + document_relpath="prompts/demo-prompt/PROMPT.md", + document_content="# demo", + ) + + +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"], + "depends_on": ["base-skill"], + "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() + + payload = PromptSummaryPayload.from_record(record).model_dump() + + assert payload == { + "id": "demo-prompt", + "name": "demo-prompt", + "description": "demo prompt", + "tags": ["testing"], + "capabilities": ["resource://prompts/demo-prompt/document"], + "version": "0.9.0", + "document_uri": "resource://prompts/demo-prompt/document", + "detail_uri": "resource://catalog/prompts/demo-prompt", + } diff --git a/tests/registry/models/test_skill_validation.py b/tests/registry/models/test_skill_validation.py index a52192c..4874be3 100644 --- a/tests/registry/models/test_skill_validation.py +++ b/tests/registry/models/test_skill_validation.py @@ -8,8 +8,8 @@ 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.load import _to_summary from personal_mcp.registry.load import load_docs_registry +from personal_mcp.registry.models.registry import SkillSummaryRecord 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 @@ -168,7 +168,7 @@ class TestSkillValidationGates: 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 = _to_summary(record) + summary = SkillSummaryRecord.from_record(record) assert summary.model_dump() == { "skill_id": "alpha", diff --git a/tests/web/conftest.py b/tests/web/conftest.py index eea496e..f1a35da 100644 --- a/tests/web/conftest.py +++ b/tests/web/conftest.py @@ -2,11 +2,10 @@ from __future__ import annotations import os from collections.abc import AsyncIterator -from collections.abc import Iterator import pytest import pytest_asyncio -from fastapi.testclient import TestClient +from httpx import ASGITransport from httpx import AsyncClient from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client @@ -14,10 +13,15 @@ from mcp.client.streamable_http import streamable_http_client from personal_mcp.web.app import create_app -@pytest.fixture -def client() -> Iterator[TestClient]: - """Provides a TestClient bound to a fresh application instance.""" - with TestClient(create_app()) as test_client: +@pytest_asyncio.fixture +async def client() -> AsyncIterator[AsyncClient]: + """Provides an AsyncClient bound to a fresh application instance.""" + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + timeout=10.0, + ) as test_client: yield test_client diff --git a/tests/web/test_endpoint_connections.py b/tests/web/test_endpoint_connections.py index 28eb794..ec3e284 100644 --- a/tests/web/test_endpoint_connections.py +++ b/tests/web/test_endpoint_connections.py @@ -1,7 +1,7 @@ from __future__ import annotations import pytest -from fastapi.testclient import TestClient +from httpx import AsyncClient from mcp import ClientSession pytestmark = pytest.mark.smoke @@ -13,9 +13,10 @@ class TestMcpHttpEndpoints: class TestHealthz: """Covers health endpoint smoke behavior.""" - def test_returns_ok_payload(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_returns_ok_payload(self, client: AsyncClient) -> None: """Ensures GET /healthz responds with a healthy status payload.""" - response = client.get("/healthz") + response = await client.get("/healthz") assert response.status_code == 200 assert response.json() == {"status": "ok"} @@ -23,9 +24,10 @@ class TestMcpHttpEndpoints: class TestDocsRoute: """Covers static docs route smoke behavior.""" - def test_serves_docs_entrypoint(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_serves_docs_entrypoint(self, client: AsyncClient) -> None: """Ensures GET /docs returns the docs site entrypoint response.""" - response = client.get("/docs") + response = await client.get("/docs") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] @@ -36,11 +38,11 @@ class TestMcpHttpEndpoints: @pytest.mark.asyncio async def test_rejects_get_stream_without_support( self, - client: TestClient, + client: AsyncClient, mcp_session: ClientSession, ) -> None: """Ensures GET /mcp returns method not allowed for current transport mode.""" - response = client.get( + response = await client.get( "/mcp", headers={"Accept": "text/event-stream"}, ) diff --git a/tests/web/test_mcp_skills.py b/tests/web/test_mcp_skills.py new file mode 100644 index 0000000..3cb8f9b --- /dev/null +++ b/tests/web/test_mcp_skills.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import pytest +from mcp import ClientSession + +pytestmark = pytest.mark.smoke + + +class TestMcpCatalogSurface: + """Covers smoke-level MCP catalog discovery and tool execution paths.""" + + class TestTools: + """Covers MCP tool-list and tool-call smoke behavior.""" + + @pytest.mark.asyncio + async def test_lists_core_catalog_tools(self, mcp_session: ClientSession) -> None: + """Ensures tools/list exposes the core catalog tool names.""" + result = await mcp_session.list_tools() + tool_names = {tool.name for tool in result.tools} + + assert { + "search_patterns", + "get_pattern_by_id", + "get_skill_document_by_id", + "search_prompts", + "get_prompt_by_id", + }.issubset(tool_names) + + @pytest.mark.asyncio + async def test_calls_search_patterns_tool(self, mcp_session: ClientSession) -> None: + """Ensures tools/call succeeds for search_patterns with basic args.""" + result = await mcp_session.call_tool( + "search_patterns", + { + "query": "pytest", + "limit": 5, + }, + ) + + assert result.isError is False + assert result.content + + class TestResources: + """Covers MCP resource and resource-template discovery.""" + + @pytest.mark.asyncio + async def test_lists_catalog_resources(self, mcp_session: ClientSession) -> None: + """Ensures resources/list exposes core catalog resource URIs.""" + result = await mcp_session.list_resources() + resource_uris = {str(resource.uri) for resource in result.resources} + + assert "resource://catalog/skills_index" in resource_uris + assert "resource://catalog/prompts_index" in resource_uris + + @pytest.mark.asyncio + async def test_lists_resource_templates(self, mcp_session: ClientSession) -> None: + """Ensures resources/templates/list includes skills and prompt templates.""" + 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 + + class TestPrompts: + """Covers MCP prompt discovery surface.""" + + @pytest.mark.asyncio + async def test_lists_registered_prompts(self, mcp_session: ClientSession) -> None: + """Ensures prompts/list returns at least one registered prompt.""" + result = await mcp_session.list_prompts() + + assert result.prompts